diff --git a/components/dm-settings-modal.html b/components/dm-settings-modal.html
new file mode 100644
index 00000000..dfc500b4
--- /dev/null
+++ b/components/dm-settings-modal.html
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Privacy
+
+
+
+
+
+
+
+
+
+
+
DM Relay Hints
+
+ Publish relay hints for direct messages as kind:10050 events. Use WSS-only relay URLs so encrypted DM delivery stays secure.
+
+
+
+
+
+
+
+
+
+
+
+ Your list
+
+
+
No DM relay hints configured.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/ui/dm/AppShell.js b/js/ui/dm/AppShell.js
index 5a050e5a..742933cb 100644
--- a/js/ui/dm/AppShell.js
+++ b/js/ui/dm/AppShell.js
@@ -179,6 +179,12 @@ export class AppShell {
settingsBtn.ariaLabel = "Direct message settings";
settingsBtn.addEventListener("click", (e) => {
e.stopPropagation();
+
+ if (typeof onOpenSettings === "function") {
+ onOpenSettings();
+ return;
+ }
+
let popover = sidebar.querySelector(".dm-settings-popover");
if (popover) {
popover.remove();
@@ -198,9 +204,6 @@ export class AppShell {
const relaySettings = DMRelaySettings({ document: doc });
popover.appendChild(relaySettings);
sidebarHeader.appendChild(popover);
- if (typeof onOpenSettings === "function") {
- onOpenSettings(popover);
- }
const closeHandler = (event) => {
if (!popover.contains(event.target) && event.target !== settingsBtn && !settingsBtn.contains(event.target)) {
diff --git a/js/ui/dm/DMSettingsModalController.js b/js/ui/dm/DMSettingsModalController.js
new file mode 100644
index 00000000..bde25d0c
--- /dev/null
+++ b/js/ui/dm/DMSettingsModalController.js
@@ -0,0 +1,261 @@
+import { userLogger } from "../../utils/logger.js";
+import { sanitizeRelayList } from "../../nostr/nip46Client.js";
+
+export class DMSettingsModalController {
+ constructor() {
+ this.modal = null;
+ this.initialized = false;
+ this.callbacks = {};
+ this.state = {
+ privacySettings: {},
+ relayHints: [],
+ };
+ }
+
+ async load() {
+ if (this.initialized) return;
+
+ try {
+ // Check if already in DOM (e.g. from server-side render or previous load)
+ if (document.getElementById("dmSettingsModal")) {
+ this.modal = document.getElementById("dmSettingsModal");
+ this.bindElements();
+ this.initialized = true;
+ return;
+ }
+
+ const response = await fetch("components/dm-settings-modal.html");
+ if (!response.ok) throw new Error("HTML not found");
+ const html = await response.text();
+ const template = document.createElement("template");
+ template.innerHTML = html;
+
+ document.body.appendChild(template.content);
+
+ this.modal = document.getElementById("dmSettingsModal");
+ this.bindElements();
+ this.initialized = true;
+ } catch (error) {
+ userLogger.error("Failed to load DM settings modal:", error);
+ }
+ }
+
+ bindElements() {
+ if (!this.modal) return;
+
+ this.closeButton = this.modal.querySelector("#closeDmSettingsModal");
+ this.backdrop = this.modal.querySelector(".bv-modal-backdrop");
+
+ // Privacy
+ this.readReceiptsToggle = this.modal.querySelector("#dmSettingsReadReceipts");
+ this.typingIndicatorsToggle = this.modal.querySelector("#dmSettingsTypingIndicators");
+
+ // Relays
+ this.relayInput = this.modal.querySelector("#dmSettingsRelayInput");
+ this.relayAddBtn = this.modal.querySelector("#dmSettingsRelayAddBtn");
+ this.relayList = this.modal.querySelector("#dmSettingsRelayList");
+ this.relayEmpty = this.modal.querySelector("#dmSettingsRelayEmpty");
+ this.relayPublishBtn = this.modal.querySelector("#dmSettingsRelayPublishBtn");
+ this.relayStatus = this.modal.querySelector("#dmSettingsRelayStatus");
+
+ // Events
+ this.closeButton?.addEventListener("click", () => this.hide());
+ this.backdrop?.addEventListener("click", () => this.hide());
+
+ this.readReceiptsToggle?.addEventListener("change", (e) => {
+ this.handlePrivacyChange("readReceiptsEnabled", e.target.checked);
+ });
+
+ this.typingIndicatorsToggle?.addEventListener("change", (e) => {
+ this.handlePrivacyChange("typingIndicatorsEnabled", e.target.checked);
+ });
+
+ this.relayAddBtn?.addEventListener("click", () => this.handleAddRelay());
+ this.relayInput?.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ this.handleAddRelay();
+ }
+ });
+
+ this.relayPublishBtn?.addEventListener("click", () => this.handlePublish());
+ }
+
+ /**
+ * @param {Object} options
+ * @param {Object} options.privacySettings { readReceiptsEnabled, typingIndicatorsEnabled }
+ * @param {Array} options.relayHints Array of relay URLs
+ * @param {Function} options.onPrivacyChange (key, value) => void
+ * @param {Function} options.onAddRelay (url) => void
+ * @param {Function} options.onRemoveRelay (url) => void
+ * @param {Function} options.onPublishRelays (urls) => Promise
+ */
+ async show(options = {}) {
+ if (!this.initialized) await this.load();
+ if (!this.modal) return;
+
+ this.callbacks = {
+ onPrivacyChange: options.onPrivacyChange || (() => {}),
+ onAddRelay: options.onAddRelay || (() => {}),
+ onRemoveRelay: options.onRemoveRelay || (() => {}),
+ onPublishRelays: options.onPublishRelays || (async () => {}),
+ };
+
+ this.state.privacySettings = options.privacySettings || {};
+ this.state.relayHints = Array.isArray(options.relayHints) ? [...options.relayHints] : [];
+
+ this.refreshUI();
+
+ this.modal.classList.remove("hidden");
+ this.modal.setAttribute("aria-hidden", "false");
+ document.body.classList.add("modal-open");
+ }
+
+ hide() {
+ if (!this.modal) return;
+ this.modal.classList.add("hidden");
+ this.modal.setAttribute("aria-hidden", "true");
+ // Check if other modals are open before removing body class
+ // For now simple removal is likely fine or we can rely on ProfileModal logic handling global state
+ // But since this opens *on top* of profile modal usually, we might want to be careful.
+ // If Profile Modal is open, body should stay modal-open.
+ // We can just check if profileModal is not hidden?
+ // Or just leave it if we assume profile modal is behind.
+ // However, for correctness:
+ const profileModal = document.getElementById("profileModal");
+ if (!profileModal || profileModal.classList.contains("hidden")) {
+ document.body.classList.remove("modal-open");
+ }
+ }
+
+ refreshUI() {
+ if (!this.initialized) return;
+
+ // Privacy
+ if (this.readReceiptsToggle) {
+ this.readReceiptsToggle.checked = !!this.state.privacySettings.readReceiptsEnabled;
+ }
+ if (this.typingIndicatorsToggle) {
+ this.typingIndicatorsToggle.checked = !!this.state.privacySettings.typingIndicatorsEnabled;
+ }
+
+ // Relays
+ this.renderRelayList();
+ if (this.relayStatus) this.relayStatus.textContent = "";
+ }
+
+ renderRelayList() {
+ if (!this.relayList || !this.relayEmpty) return;
+
+ this.relayList.innerHTML = "";
+ const relays = this.state.relayHints;
+
+ if (relays.length === 0) {
+ this.relayList.classList.add("hidden");
+ this.relayEmpty.classList.remove("hidden");
+ } else {
+ this.relayList.classList.remove("hidden");
+ this.relayEmpty.classList.add("hidden");
+
+ relays.forEach(url => {
+ const li = document.createElement("li");
+ li.className = "flex items-center justify-between gap-3 p-3 bg-surface-2 rounded-md border border-border/50";
+
+ const span = document.createElement("span");
+ span.className = "text-sm text-text break-all font-mono";
+ span.textContent = url;
+
+ const removeBtn = document.createElement("button");
+ removeBtn.type = "button";
+ removeBtn.className = "btn-ghost btn-icon p-1 text-muted hover:text-critical focus-ring";
+ removeBtn.ariaLabel = `Remove ${url}`;
+ removeBtn.innerHTML = ``;
+
+ removeBtn.addEventListener("click", () => this.handleRemoveRelay(url));
+
+ li.appendChild(span);
+ li.appendChild(removeBtn);
+ this.relayList.appendChild(li);
+ });
+ }
+ }
+
+ handlePrivacyChange(key, value) {
+ this.state.privacySettings[key] = value;
+ this.callbacks.onPrivacyChange(key, value);
+ }
+
+ handleAddRelay() {
+ const rawValue = this.relayInput?.value?.trim() || "";
+ if (!rawValue) return;
+
+ const sanitized = sanitizeRelayList([rawValue]);
+ const url = sanitized[0];
+
+ if (!url) {
+ this.setRelayStatus("Invalid WSS URL.", "error");
+ return;
+ }
+
+ if (this.state.relayHints.includes(url)) {
+ this.setRelayStatus("Relay already added.", "error");
+ this.relayInput.value = "";
+ return;
+ }
+
+ this.state.relayHints.push(url);
+ this.renderRelayList();
+ this.relayInput.value = "";
+ this.setRelayStatus("Relay added (unsaved). Publish to save.", "info");
+
+ this.callbacks.onAddRelay(url);
+ }
+
+ handleRemoveRelay(url) {
+ this.state.relayHints = this.state.relayHints.filter(r => r !== url);
+ this.renderRelayList();
+ this.setRelayStatus("Relay removed (unsaved).", "info");
+
+ this.callbacks.onRemoveRelay(url);
+ }
+
+ async handlePublish() {
+ if (this.state.relayHints.length === 0) {
+ this.setRelayStatus("Add at least one relay before publishing.", "error");
+ return;
+ }
+
+ this.setRelayStatus("Publishing...", "info");
+ this.relayPublishBtn.disabled = true;
+
+ try {
+ const result = await this.callbacks.onPublishRelays(this.state.relayHints);
+ if (result && result.ok) {
+ const acceptedCount = Array.isArray(result.accepted) ? result.accepted.length : 0;
+ this.setRelayStatus(`Published to ${acceptedCount} relay(s).`, "success");
+ } else {
+ this.setRelayStatus("Failed to publish.", "error");
+ }
+ } catch (error) {
+ this.setRelayStatus("Error publishing relays.", "error");
+ userLogger.error("Failed to publish DM relays:", error);
+ } finally {
+ this.relayPublishBtn.disabled = false;
+ }
+ }
+
+ setRelayStatus(message, type = "info") {
+ if (!this.relayStatus) return;
+ this.relayStatus.textContent = message;
+
+ this.relayStatus.classList.remove("text-status-success", "text-status-danger", "text-muted");
+
+ if (type === "error") {
+ this.relayStatus.classList.add("text-status-danger");
+ } else if (type === "success") {
+ this.relayStatus.classList.add("text-status-success");
+ } else {
+ this.relayStatus.classList.add("text-muted");
+ }
+ }
+}
diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js
index 864af7a2..f0a77b31 100644
--- a/js/ui/profileModalController.js
+++ b/js/ui/profileModalController.js
@@ -44,6 +44,7 @@ import {
setLinkPreviewAutoFetch,
} from "../utils/linkPreviewSettings.js";
import { SubscriptionHistoryController } from "./subscriptionHistoryController.js";
+import { DMSettingsModalController } from "./dm/DMSettingsModalController.js";
const noop = () => {};
@@ -1432,6 +1433,7 @@ export class ProfileModalController {
this.adminEmptyMessages = new Map();
this.hashtagPreferencesUnsubscribe = null;
this.subscriptionHistoryController = new SubscriptionHistoryController();
+ this.dmSettingsModalController = new DMSettingsModalController();
if (
this.subscriptionsService &&
@@ -2498,6 +2500,69 @@ export class ProfileModalController {
}
}
+ openDmSettingsModal() {
+ const owner = this.resolveActiveDmRelayOwner();
+ if (!owner) {
+ this.showError("Please sign in to manage DM settings.");
+ return;
+ }
+
+ const privacySettings = this.getDmPrivacySettingsSnapshot();
+ const relayHints = this.getActiveDmRelayPreferences();
+
+ this.dmSettingsModalController.show({
+ privacySettings,
+ relayHints,
+ onPrivacyChange: (key, value) => {
+ this.persistDmPrivacySettings({ [key]: value });
+ if (key === "readReceiptsEnabled") {
+ this.showStatus(
+ value ? "Read receipts enabled." : "Read receipts disabled.",
+ );
+ } else if (key === "typingIndicatorsEnabled") {
+ this.showStatus(
+ value ? "Typing indicators enabled." : "Typing indicators disabled.",
+ );
+ }
+ },
+ onPublishRelays: async (urls) => {
+ return this.handleDmSettingsPublish(urls);
+ },
+ });
+ }
+
+ async handleDmSettingsPublish(relays) {
+ const owner = this.resolveActiveDmRelayOwner();
+ if (!owner) {
+ return { ok: false, error: "not-logged-in" };
+ }
+
+ // Update local state first
+ this.setActiveDmRelayPreferences(relays);
+
+ // Publish using existing logic
+ const callback = this.callbacks.onPublishDmRelayPreferences;
+ if (!callback || callback === noop) {
+ return { ok: false, error: "unavailable" };
+ }
+
+ try {
+ const result = await callback({
+ pubkey: owner,
+ relays,
+ controller: this,
+ });
+
+ if (result?.ok) {
+ this.populateDmRelayPreferences(); // Refresh old UI just in case
+ return result;
+ }
+ return { ok: false, error: result?.error || "failed" };
+ } catch (error) {
+ return { ok: false, error: error };
+ }
+ }
+
buildDmRecipientContext(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey);
if (!normalized) {
@@ -4644,9 +4709,7 @@ export class ProfileModalController {
this.handleTypingIndicatorsToggle(enabled);
},
onOpenSettings: () => {
- this.cacheDmRelayElements();
- this.bindDmRelayControls();
- this.populateDmRelayPreferences();
+ this.openDmSettingsModal();
},
});
} catch (error) {
diff --git a/verification/relay_ui.png b/verification/relay_ui.png
deleted file mode 100644
index e6d9ac2b..00000000
Binary files a/verification/relay_ui.png and /dev/null differ
diff --git a/verification/verify_relay_ui.py b/verification/verify_relay_ui.py
deleted file mode 100644
index 89b76a92..00000000
--- a/verification/verify_relay_ui.py
+++ /dev/null
@@ -1,75 +0,0 @@
-from playwright.sync_api import sync_playwright
-import time
-
-def run():
- with sync_playwright() as p:
- browser = p.chromium.launch(headless=True)
- page = browser.new_page()
-
- # Set dummy profile in localStorage to simulate login
- page.add_init_script("""
- localStorage.setItem("bitvid:savedProfiles", JSON.stringify([{pubkey: "0000000000000000000000000000000000000000000000000000000000000001", authType: "nip07"}]));
- localStorage.setItem("bitvid:activeProfilePubkey", "0000000000000000000000000000000000000000000000000000000000000001");
- """)
-
- page.goto("http://localhost:8080")
-
- # Wait for app to load and hydrate
- try:
- page.wait_for_selector("#profileButton:not(.hidden)", timeout=10000)
- except:
- print("Profile button did not appear. Dumping console logs.")
- # Maybe auth failed or slow.
- # Let's try to find if app is attached to window? No.
- pass
-
- # If profile button is visible, click it
- if page.is_visible("#profileButton"):
- print("Clicking profile button...")
- page.click("#profileButton")
- else:
- print("Profile button not visible. Trying to find it anyway (maybe hidden class issue).")
- # If hidden, maybe just click it?
- # Or maybe I need to wait longer.
- # Let's try to access the modal directly if it's already loaded? No it loads on show.
-
- # Wait for modal
- try:
- page.wait_for_selector("#profileModal", state="visible", timeout=5000)
- except:
- print("Profile modal did not appear.")
-
- # Click "Relays" nav
- try:
- page.click("#profileNavRelays")
- except:
- print("Could not click Relays nav.")
-
- # Wait for relay list
- page.wait_for_selector("#relayList", state="visible")
-
- # Check if relayHealthPanel is gone
- health_panel = page.query_selector("#relayHealthPanel")
- if health_panel:
- print("FAILURE: #relayHealthPanel should not exist!")
- else:
- print("SUCCESS: #relayHealthPanel is gone.")
-
- # Check if telemetry toggle is present (moved)
- toggle = page.query_selector("#relayHealthTelemetryOptIn")
- if toggle:
- print("SUCCESS: Telemetry toggle found.")
- else:
- print("FAILURE: Telemetry toggle not found.")
-
- # Check for health stats placeholders in relay list items
- # If there are no relays, we might see "No relays configured".
- # We can add a relay via UI or script.
-
- # Take screenshot
- page.screenshot(path="verification/relay_ui.png")
-
- browser.close()
-
-if __name__ == "__main__":
- run()