Merge pull request #2072 from PR0M3TH3AN/dm-settings-modal-8234527829829252881

Refactor DM settings to modal
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-02 14:43:42 -05:00
commit b478cbaf01
6 files changed
+441 -81

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
<div
id="dmSettingsModal"
class="bv-modal hidden"
role="dialog"
aria-modal="true"
aria-labelledby="dmSettingsTitle"
tabindex="-1"
data-ds="new"
>
<div class="bv-modal-backdrop"></div>
<div
class="bv-modal__panel w-full max-w-2xl h-auto max-h-[90vh] rounded-xl border border-border/50 bg-surface shadow-2xl overflow-hidden flex flex-col"
tabindex="-1"
>
<!-- Modal Header -->
<div class="modal-header border-b border-border/60 p-4 flex items-center justify-between bg-surface-2">
<h2 id="dmSettingsTitle" class="text-lg font-bold text-text">DM Settings</h2>
<button id="closeDmSettingsModal" class="modal-close btn-ghost btn-icon p-1 rounded-md text-muted hover:text-text focus-ring">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="flex-1 overflow-y-auto p-6 space-y-8 bg-surface">
<!-- Privacy Settings -->
<section class="space-y-4">
<h3 class="text-base font-semibold text-text">Privacy</h3>
<div class="card p-4 space-y-4">
<label class="flex items-center justify-between gap-3 cursor-pointer">
<div class="space-y-1">
<span class="block text-sm font-medium text-text">Read receipts</span>
<span class="block text-xs text-muted">Let others know when you’ve seen their messages.</span>
</div>
<input type="checkbox" id="dmSettingsReadReceipts" class="toggle" />
</label>
<div class="h-px bg-border/40"></div>
<label class="flex items-center justify-between gap-3 cursor-pointer">
<div class="space-y-1">
<span class="block text-sm font-medium text-text">Typing indicators</span>
<span class="block text-xs text-muted">Show when you’re typing a message.</span>
</div>
<input type="checkbox" id="dmSettingsTypingIndicators" class="toggle" />
</label>
</div>
</section>
<!-- Relay Hints -->
<section class="space-y-4">
<div class="space-y-1">
<h3 class="text-base font-semibold text-text">DM Relay Hints</h3>
<p class="text-sm text-muted">
Publish relay hints for direct messages as <span class="font-medium">kind:10050</span> events. Use WSS-only relay URLs so encrypted DM delivery stays secure.
</p>
</div>
<div class="card p-5 space-y-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-muted-strong" for="dmSettingsRelayInput">
<span class="mb-2 block font-medium">Add a DM relay</span>
<input
id="dmSettingsRelayInput"
type="text"
inputmode="url"
autocomplete="off"
class="input"
placeholder="wss://relay.example.com"
/>
</label>
<button id="dmSettingsRelayAddBtn" type="button" class="btn focus-ring">
Add relay
</button>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-muted-strong uppercase tracking-wider">Your list</span>
</div>
<ul id="dmSettingsRelayList" class="grid-stack space-y-2" data-orientation="vertical"></ul>
<p id="dmSettingsRelayEmpty" class="text-sm text-muted italic hidden">No DM relay hints configured.</p>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 pt-2 border-t border-border/40">
<span id="dmSettingsRelayStatus" class="text-xs text-muted" role="status" aria-live="polite"></span>
<button id="dmSettingsRelayPublishBtn" type="button" class="btn-ghost focus-ring text-xs">
Publish relay hints
</button>
</div>
</div>
</section>
</div>
</div>
</div>
+6 -3
View File
@@ -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)) {
+261
View File
@@ -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<result>
*/
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`;
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");
}
}
}
+66 -3
View File
@@ -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) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

-75
View File
@@ -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()