Add per-profile settings store and mask wallet inputs

This commit is contained in:
thePR0M3TH3AN committed 2025-10-21 17:46:41 -04:00
1 parent 1ce38ba0de
commit 094559b618
6 files changed
+520 -40

No files matched your search

+16 -16
View File
@@ -6,6 +6,7 @@ import {
} from "../nwcSettings.js";
import { MAX_WALLET_DEFAULT_ZAP } from "../config.js";
import { devLogger, userLogger } from "../utils/logger.js";
import createProfileSettingsStore from "../state/profileSettingsStore.js";
const NWC_URI_SCHEME = "nostr+walletconnect://";
@@ -53,7 +54,10 @@ export default class NwcSettingsService {
this.createDefaultSettings = isFunction(createDefaultSettings)
? createDefaultSettings
: () => createDefaultNwcSettings();
this.cache = new Map();
this.cache = createProfileSettingsStore({
clone: (value) => this.cloneSettings(value),
logger: this.logger.dev,
});
}
cloneSettings(settings) {
@@ -101,7 +105,8 @@ export default class NwcSettingsService {
? { ...settings }
: this.createDefaultNwcSettings();
this.cache.set(normalized, record);
return this.cloneSettings(record);
const cached = this.cache.get(normalized);
return cached || this.createDefaultNwcSettings();
} catch (error) {
if (isFunction(this.logger.user?.warn)) {
this.logger.user.warn(
@@ -111,7 +116,8 @@ export default class NwcSettingsService {
}
const fallback = this.createDefaultNwcSettings();
this.cache.set(normalized, fallback);
return this.cloneSettings(fallback);
const cached = this.cache.get(normalized);
return cached || this.createDefaultNwcSettings();
}
}
@@ -121,7 +127,7 @@ export default class NwcSettingsService {
return this.createDefaultNwcSettings();
}
const cached = this.cache.get(normalized);
return cached ? this.cloneSettings(cached) : this.createDefaultNwcSettings();
return cached ? cached : this.createDefaultNwcSettings();
}
createDefaultNwcSettings() {
@@ -178,7 +184,8 @@ export default class NwcSettingsService {
? { ...updated }
: this.createDefaultNwcSettings();
this.cache.set(normalized, record);
return this.cloneSettings(record);
const cached = this.cache.get(normalized);
return cached || this.createDefaultNwcSettings();
} catch (error) {
if (isFunction(this.logger.user?.warn)) {
this.logger.user.warn(
@@ -294,14 +301,6 @@ export default class NwcSettingsService {
const normalizedActive = this.getNormalizedPubkey(
pubkey !== undefined ? pubkey : this.getActivePubkey(),
);
const normalizedPrevious = this.getNormalizedPubkey(previousPubkey);
if (
normalizedPrevious &&
(!normalizedActive || normalizedPrevious !== normalizedActive)
) {
await this.clearStoredNwcSettings(normalizedPrevious, { silent: true });
}
if (normalizedActive) {
await this.hydrateNwcSettingsForPubkey(normalizedActive);
@@ -315,13 +314,14 @@ export default class NwcSettingsService {
previousPubkey !== undefined ? previousPubkey : pubkey,
);
if (normalizedPrevious) {
await this.clearStoredNwcSettings(normalizedPrevious, { silent: false });
this.cache.delete(normalizedPrevious);
}
this.clearCache();
return this.createDefaultNwcSettings();
}
onIdentityChanged() {
this.clearCache();
// Intentionally preserve cached settings so profile switches retain
// per-account configuration. Explicit cache clearing remains
// available via `clearCache()` when required by callers.
}
}
+74
View File
@@ -0,0 +1,74 @@
import { devLogger } from "../utils/logger.js";
function noopClone(value) {
return value;
}
export function createProfileSettingsStore({ clone = noopClone, logger = devLogger } = {}) {
const cache = new Map();
const cloneValue = (value) => {
try {
return clone(value);
} catch (error) {
if (logger && typeof logger.warn === "function") {
logger.warn("[profileSettingsStore] Failed to clone value", error);
}
return value;
}
};
const store = {
set(key, value) {
if (!key) {
return store;
}
cache.set(key, cloneValue(value));
return store;
},
get(key) {
if (!key || !cache.has(key)) {
return undefined;
}
return cloneValue(cache.get(key));
},
has(key) {
if (!key) {
return false;
}
return cache.has(key);
},
delete(key) {
if (!key) {
return false;
}
return cache.delete(key);
},
clear() {
cache.clear();
return store;
},
keys() {
return Array.from(cache.keys());
},
values() {
return Array.from(cache.values()).map((value) => cloneValue(value));
},
entries() {
return Array.from(cache.entries()).map(([key, value]) => [
key,
cloneValue(value),
]);
},
};
Object.defineProperty(store, "size", {
get() {
return cache.size;
},
});
return store;
}
export default createProfileSettingsStore;
+178 -24
View File
@@ -21,6 +21,7 @@ const DEFAULT_ADMIN_DM_IMAGE_URL =
"https://beta.bitvid.network/assets/jpg/video-thumbnail-fallback.jpg";
const DEFAULT_BITVID_WEBSITE_URL = "https://bitvid.network/";
const NWC_URI_SCHEME = "nostr+walletconnect://";
const SECRET_PLACEHOLDER = "*****";
const DEFAULT_MAX_WALLET_DEFAULT_ZAP = 100000000;
const DEFAULT_SAVED_PROFILE_LABEL = "Saved profile";
@@ -1262,7 +1263,15 @@ export class ProfileModalController {
}
if (this.walletUriInput instanceof HTMLElement) {
this.walletUriInput.addEventListener("focus", () => {
this.revealSecretInputValue(this.walletUriInput);
});
this.walletUriInput.addEventListener("blur", () => {
this.handleSecretInputBlur(this.walletUriInput);
this.applyWalletControlState();
});
this.walletUriInput.addEventListener("input", () => {
this.handleSecretInputChange(this.walletUriInput);
this.applyWalletControlState();
});
}
@@ -2984,10 +2993,7 @@ export class ProfileModalController {
applyWalletControlState() {
const hasActive = Boolean(this.normalizeHexPubkey(this.getActivePubkey()));
const busy = this.isWalletBusy();
const uriValue =
typeof this.walletUriInput?.value === "string"
? this.walletUriInput.value.trim()
: "";
const uriValue = this.getSecretInputValue(this.walletUriInput);
const hasUri = uriValue.length > 0;
const applyDisabledState = (element, disabled) => {
@@ -3049,20 +3055,17 @@ export class ProfileModalController {
refreshWalletPaneState() {
const hasActive = Boolean(this.normalizeHexPubkey(this.getActivePubkey()));
const setInputValue = (element, value) => {
if (element && typeof element === "object" && "value" in element) {
if (!hasActive) {
this.setSecretInputValue(this.walletUriInput, "");
if (this.walletDefaultZapInput && "value" in this.walletDefaultZapInput) {
try {
element.value = value;
this.walletDefaultZapInput.value = "";
} catch (error) {
if (element instanceof HTMLElement) {
element.setAttribute("data-value", value);
if (this.walletDefaultZapInput instanceof HTMLElement) {
this.walletDefaultZapInput.setAttribute("data-value", "");
}
}
}
};
if (!hasActive) {
setInputValue(this.walletUriInput, "");
setInputValue(this.walletDefaultZapInput, "");
this.updateWalletStatus("Sign in to connect a wallet.", "info");
this.applyWalletControlState();
return;
@@ -3072,13 +3075,20 @@ export class ProfileModalController {
if (!settings || typeof settings !== "object") {
settings = this.services.nwcSettings.createDefaultNwcSettings();
}
setInputValue(this.walletUriInput, settings.nwcUri || "");
setInputValue(
this.walletDefaultZapInput,
settings.defaultZap === null || settings.defaultZap === undefined
? ""
: String(settings.defaultZap),
);
this.setSecretInputValue(this.walletUriInput, settings.nwcUri || "");
if (this.walletDefaultZapInput && "value" in this.walletDefaultZapInput) {
const defaultZapValue =
settings.defaultZap === null || settings.defaultZap === undefined
? ""
: String(settings.defaultZap);
try {
this.walletDefaultZapInput.value = defaultZapValue;
} catch (error) {
if (this.walletDefaultZapInput instanceof HTMLElement) {
this.walletDefaultZapInput.setAttribute("data-value", defaultZapValue);
}
}
}
if (settings.nwcUri) {
this.updateWalletStatus(
@@ -3092,11 +3102,155 @@ export class ProfileModalController {
this.applyWalletControlState();
}
isSecretInputElement(element) {
if (!element || typeof element !== "object") {
return false;
}
if (typeof HTMLInputElement !== "undefined" && element instanceof HTMLInputElement) {
return true;
}
return typeof element.value === "string";
}
sanitizeSecretValue(value) {
return typeof value === "string" ? value.trim() : "";
}
getSecretInputValue(element) {
if (!this.isSecretInputElement(element)) {
return "";
}
const placeholder =
typeof element.dataset?.secretPlaceholder === "string"
? element.dataset.secretPlaceholder
: SECRET_PLACEHOLDER;
const stored = this.sanitizeSecretValue(
typeof element.dataset?.secretValue === "string"
? element.dataset.secretValue
: "",
);
const raw = this.sanitizeSecretValue(element.value);
const isMasked = element.dataset?.secretMasked === "true";
if (isMasked && placeholder && raw === placeholder) {
return stored;
}
if (!raw && isMasked) {
return stored;
}
return raw;
}
setSecretInputValue(element, value) {
if (!this.isSecretInputElement(element)) {
return;
}
const sanitized = this.sanitizeSecretValue(value);
if (!sanitized) {
if (element.dataset) {
delete element.dataset.secretValue;
delete element.dataset.secretMasked;
delete element.dataset.secretPlaceholder;
}
element.value = "";
return;
}
const placeholder = SECRET_PLACEHOLDER;
if (element.dataset) {
element.dataset.secretValue = sanitized;
element.dataset.secretPlaceholder = placeholder;
element.dataset.secretMasked = "true";
}
element.value = placeholder;
}
revealSecretInputValue(element) {
if (!this.isSecretInputElement(element)) {
return;
}
const stored = this.sanitizeSecretValue(
typeof element.dataset?.secretValue === "string"
? element.dataset.secretValue
: "",
);
if (!stored) {
if (element.dataset) {
delete element.dataset.secretMasked;
}
return;
}
if (element.dataset) {
element.dataset.secretMasked = "false";
}
element.value = stored;
try {
if (typeof element.setSelectionRange === "function") {
const length = stored.length;
element.setSelectionRange(length, length);
}
} catch (error) {
// Ignore selection errors on unsupported input types.
}
}
handleSecretInputChange(element) {
if (!this.isSecretInputElement(element)) {
return;
}
const value = this.sanitizeSecretValue(element.value);
if (!value) {
if (element.dataset) {
element.dataset.secretMasked = "false";
delete element.dataset.secretValue;
}
return;
}
if (element.dataset) {
element.dataset.secretValue = value;
element.dataset.secretMasked = "false";
if (!element.dataset.secretPlaceholder) {
element.dataset.secretPlaceholder = SECRET_PLACEHOLDER;
}
}
}
handleSecretInputBlur(element) {
if (!this.isSecretInputElement(element)) {
return;
}
const value = this.sanitizeSecretValue(this.getSecretInputValue(element));
if (!value) {
if (element.dataset) {
delete element.dataset.secretValue;
delete element.dataset.secretMasked;
delete element.dataset.secretPlaceholder;
}
element.value = "";
return;
}
if (element.dataset) {
element.dataset.secretValue = value;
element.dataset.secretPlaceholder =
element.dataset.secretPlaceholder || SECRET_PLACEHOLDER;
element.dataset.secretMasked = "true";
}
element.value = element.dataset?.secretPlaceholder || SECRET_PLACEHOLDER;
}
getWalletFormValues() {
const uri =
typeof this.walletUriInput?.value === "string"
? this.walletUriInput.value.trim()
: "";
const uri = this.getSecretInputValue(this.walletUriInput);
const defaultZapRaw =
typeof this.walletDefaultZapInput?.value === "string"
? this.walletDefaultZapInput.value.trim()
+97
View File
@@ -0,0 +1,97 @@
import test from "node:test";
import assert from "node:assert/strict";
import NwcSettingsService from "../js/services/nwcSettingsService.js";
const DEFAULT_SETTINGS = {
nwcUri: "",
defaultZap: null,
lastChecked: null,
version: "",
};
function createService({ saved = new Map(), activeRef }) {
const service = new NwcSettingsService({
normalizeHexPubkey: (value) => (typeof value === "string" ? value : null),
getActivePubkey: () => activeRef.value,
loadSettings: async (pubkey) => saved.get(pubkey),
saveSettings: async (pubkey, partial = {}) => {
const current = saved.get(pubkey) || {};
const next = { ...current, ...partial };
saved.set(pubkey, next);
return next;
},
clearSettings: async (pubkey) => {
saved.delete(pubkey);
},
createDefaultSettings: () => ({ ...DEFAULT_SETTINGS }),
});
return service;
}
test("nwc settings service preserves settings across profile switches", async () => {
const saved = new Map([
["pub1", { nwcUri: "nostr+walletconnect://pub1?relay=wss://one" }],
["pub2", { nwcUri: "nostr+walletconnect://pub2?relay=wss://two" }],
]);
const activeRef = { value: null };
const service = createService({ saved, activeRef });
activeRef.value = "pub1";
await service.onLogin({ pubkey: "pub1", identityChanged: true });
const first = service.getActiveNwcSettings();
assert.equal(first.nwcUri, "nostr+walletconnect://pub1?relay=wss://one");
activeRef.value = "pub2";
await service.onLogin({
pubkey: "pub2",
previousPubkey: "pub1",
identityChanged: true,
});
const second = service.getActiveNwcSettings();
assert.equal(second.nwcUri, "nostr+walletconnect://pub2?relay=wss://two");
assert.equal(saved.has("pub1"), true, "previous profile data should stay persisted");
assert.equal(service.cache.has("pub1"), true, "previous profile should remain cached");
activeRef.value = "pub1";
await service.onLogin({
pubkey: "pub1",
previousPubkey: "pub2",
identityChanged: true,
});
const roundTrip = service.getActiveNwcSettings();
assert.equal(roundTrip.nwcUri, "nostr+walletconnect://pub1?relay=wss://one");
roundTrip.nwcUri = "mutated";
const unchanged = service.getActiveNwcSettings();
assert.equal(
unchanged.nwcUri,
"nostr+walletconnect://pub1?relay=wss://one",
"mutating returned settings should not affect cached data",
);
activeRef.value = "pub2";
await service.onLogout({ pubkey: "pub2" });
assert.equal(saved.has("pub2"), true, "logout should not clear persisted settings");
assert.equal(service.cache.has("pub2"), false, "logout should drop cached entry for account");
});
test("updateActiveNwcSettings returns cloned values", async () => {
const saved = new Map();
const activeRef = { value: "pub3" };
const service = createService({ saved, activeRef });
const updated = await service.updateActiveNwcSettings({
nwcUri: "nostr+walletconnect://pub3?relay=wss://three",
defaultZap: 777,
});
assert.equal(updated.nwcUri, "nostr+walletconnect://pub3?relay=wss://three");
assert.equal(updated.defaultZap, 777);
updated.nwcUri = "mutated";
const cached = service.getActiveNwcSettings();
assert.equal(cached.nwcUri, "nostr+walletconnect://pub3?relay=wss://three");
assert.equal(saved.get("pub3").nwcUri, "nostr+walletconnect://pub3?relay=wss://three");
});
+63
View File
@@ -433,6 +433,69 @@ for (const _ of [0]) {
});
});
test('wallet URI input masks persisted values and restores on focus', async (t) => {
const controller = createController();
await controller.load();
controller.state.setActivePubkey('a'.repeat(64));
await controller.show('wallet');
const sampleUri =
'nostr+walletconnect://pub1?relay=wss://relay.example.com';
await controller.services.nwcSettings.updateActiveNwcSettings({
nwcUri: sampleUri,
defaultZap: 21,
});
controller.refreshWalletPaneState();
assert.ok(
controller.walletUriInput instanceof window.HTMLElement,
'wallet URI input should exist',
);
assert.equal(controller.walletUriInput.value, '*****');
assert.equal(controller.walletUriInput.dataset.secretValue, sampleUri);
assert.equal(
controller.walletDisconnectButton?.classList.contains('hidden'),
false,
'disconnect button should remain visible when a URI exists',
);
const formValues = controller.getWalletFormValues();
assert.equal(formValues.uri, sampleUri);
controller.walletUriInput.dispatchEvent(new window.Event('focus'));
assert.equal(controller.walletUriInput.value, sampleUri);
controller.walletUriInput.dispatchEvent(new window.Event('blur'));
assert.equal(controller.walletUriInput.value, '*****');
controller.walletUriInput.dispatchEvent(new window.Event('focus'));
const updatedUri = `${sampleUri}&name=bitvid`;
controller.walletUriInput.value = updatedUri;
controller.walletUriInput.dispatchEvent(new window.Event('input'));
controller.walletUriInput.dispatchEvent(new window.Event('blur'));
const updatedValues = controller.getWalletFormValues();
assert.equal(updatedValues.uri, updatedUri);
assert.equal(controller.walletUriInput.value, '*****');
controller.walletUriInput.dispatchEvent(new window.Event('focus'));
controller.walletUriInput.value = '';
controller.walletUriInput.dispatchEvent(new window.Event('input'));
controller.walletUriInput.dispatchEvent(new window.Event('blur'));
assert.equal(controller.walletUriInput.value, '');
assert.equal(controller.walletUriInput.dataset.secretValue, undefined);
t.after(() => {
try {
controller.hide({ silent: true });
} catch {}
});
});
test('Profile modal uses abbreviated npub display', async () => {
const sampleProfiles = [
{
@@ -0,0 +1,92 @@
import test from "node:test";
import assert from "node:assert/strict";
import createProfileSettingsStore from "../../js/state/profileSettingsStore.js";
function createClone(value) {
if (!value || typeof value !== "object") {
return value;
}
return { ...value };
}
test("profile settings store clones values and tracks entries", () => {
const store = createProfileSettingsStore({ clone: createClone });
const first = { uri: "nostr+walletconnect://pub1" };
const second = { uri: "nostr+walletconnect://pub2" };
const result = store.set("pub1", first);
assert.strictEqual(result, store, "set() should return the store instance");
assert.equal(store.size, 1);
store.set("pub2", second);
assert.equal(store.size, 2);
const fromFirst = store.get("pub1");
assert.deepEqual(fromFirst, first);
assert.notStrictEqual(
fromFirst,
first,
"get() should return a cloned copy to avoid external mutation",
);
fromFirst.uri = "mutated";
const reloaded = store.get("pub1");
assert.equal(
reloaded.uri,
"nostr+walletconnect://pub1",
"mutating a retrieved object should not impact stored values",
);
const entries = store.entries();
assert.deepEqual(
entries,
[
["pub1", { uri: "nostr+walletconnect://pub1" }],
["pub2", { uri: "nostr+walletconnect://pub2" }],
],
);
const keys = store.keys();
assert.deepEqual(keys.sort(), ["pub1", "pub2"]);
const values = store.values();
assert.deepEqual(values, [
{ uri: "nostr+walletconnect://pub1" },
{ uri: "nostr+walletconnect://pub2" },
]);
assert.notStrictEqual(values[0], fromFirst);
assert.equal(store.has("pub1"), true);
assert.equal(store.delete("pub1"), true);
assert.equal(store.has("pub1"), false);
assert.equal(store.size, 1);
store.clear();
assert.equal(store.size, 0);
assert.equal(store.get("pub2"), undefined);
});
test("profile settings store ignores falsy keys and survives clone failures", () => {
const loggerCalls = [];
const store = createProfileSettingsStore({
clone: () => {
throw new Error("clone failure");
},
logger: {
warn: (message, error) => {
loggerCalls.push([message, error?.message]);
},
},
});
store.set(null, { foo: "bar" });
assert.equal(store.size, 0, "falsy keys should be ignored");
store.set("pub1", { foo: "bar" });
assert.equal(store.size, 1);
const value = store.get("pub1");
assert.deepEqual(value, { foo: "bar" });
assert.equal(loggerCalls.length > 0, true, "clone failure should be logged");
});