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 @@ + 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()