diff --git a/js/ui/dm/AppShell.js b/js/ui/dm/AppShell.js index 1ac096a1..1e32a5e6 100644 --- a/js/ui/dm/AppShell.js +++ b/js/ui/dm/AppShell.js @@ -2,6 +2,7 @@ import { Composer } from "./Composer.js"; import { ConversationList } from "./ConversationList.js"; import { MessageThread } from "./MessageThread.js"; import { NotificationCenter } from "./NotificationCenter.js"; +import { DMPrivacySettings } from "./DMPrivacySettings.js"; import { aggregateZapTotals } from "./zapHelpers.js"; function createElement(doc, tag, className, text) { @@ -121,6 +122,7 @@ export class AppShell { threadErrorType = "", composerState = "idle", privacyMode = "nip04", + dmPrivacySettings = { readReceiptsEnabled: false, typingIndicatorsEnabled: false }, notifications = [], signingAdapter = null, zapConfig = null, @@ -129,6 +131,8 @@ export class AppShell { onSendZap, onMarkConversationRead, onMarkAllRead, + onToggleReadReceipts, + onToggleTypingIndicators, } = {}) { if (!doc) { throw new Error("AppShell requires a document reference."); @@ -153,13 +157,47 @@ export class AppShell { ); const sidebar = createElement(doc, "aside", "dm-app-shell__sidebar"); + const sidebarHeader = createElement(doc, "div", "dm-app-shell__sidebar-header flex items-center justify-between p-4"); const sidebarTitle = createElement( doc, "h1", - "dm-app-shell__title", + "dm-app-shell__title text-lg font-bold", "Direct Messages", ); - sidebar.appendChild(sidebarTitle); + sidebarHeader.appendChild(sidebarTitle); + + const settingsBtn = createElement(doc, "button", "btn-ghost btn-icon"); + settingsBtn.innerHTML = ``; + settingsBtn.ariaLabel = "Direct message settings"; + settingsBtn.addEventListener("click", (e) => { + e.stopPropagation(); + let popover = sidebar.querySelector(".dm-settings-popover"); + if (popover) { + popover.remove(); + return; + } + popover = DMPrivacySettings({ + document: doc, + readReceiptsEnabled: dmPrivacySettings.readReceiptsEnabled, + typingIndicatorsEnabled: dmPrivacySettings.typingIndicatorsEnabled, + onToggleReadReceipts, + onToggleTypingIndicators, + }); + popover.classList.add("dm-settings-popover", "absolute", "z-50", "right-4", "top-12", "bg-surface", "border", "border-border", "rounded-xl"); + sidebarHeader.style.position = "relative"; + sidebarHeader.appendChild(popover); + + const closeHandler = (event) => { + if (!popover.contains(event.target) && event.target !== settingsBtn && !settingsBtn.contains(event.target)) { + popover.remove(); + doc.removeEventListener("click", closeHandler); + } + }; + doc.addEventListener("click", closeHandler); + }); + sidebarHeader.appendChild(settingsBtn); + sidebar.appendChild(sidebarHeader); + sidebar.appendChild( ConversationList({ document: doc, @@ -172,12 +210,7 @@ export class AppShell { ); const main = createElement(doc, "main", "dm-app-shell__main"); - main.appendChild( - NotificationCenter({ - document: doc, - notices: notifications, - }), - ); + main.appendChild( MessageThread({ document: doc, @@ -191,6 +224,8 @@ export class AppShell { totalsByConversation.get(activeConversation.id) || 0, profileZapTotalSats: totalsByProfile.get(activeConversation.pubkey) || 0, onMarkRead: onMarkConversationRead, + onSendZap, + zapConfig, }), ); main.appendChild( @@ -202,7 +237,14 @@ export class AppShell { zapRecipient: activeConversation, zapConfig, onSend: onSendMessage, - onSendZap, + // Zap button moved to MessageThread, so onSendZap here might be redundant if triggered from thread + // But Composer still handles text message sending. + }), + ); + main.appendChild( + NotificationCenter({ + document: doc, + notices: notifications, }), ); diff --git a/js/ui/dm/Composer.js b/js/ui/dm/Composer.js index 184630f2..f9f2a1d5 100644 --- a/js/ui/dm/Composer.js +++ b/js/ui/dm/Composer.js @@ -1,6 +1,3 @@ -import { createAndPublishZapRequest, resolveZapRecipient } from "../../payments/zapRequests.js"; -import { devLogger, userLogger } from "../../utils/logger.js"; - function createElement(doc, tag, className, text) { const element = doc.createElement(tag); if (className) { @@ -21,7 +18,6 @@ export function Composer({ zapRecipient = null, zapConfig = null, onSend, - onSendZap, } = {}) { if (!doc) { throw new Error("Composer requires a document reference."); @@ -87,15 +83,7 @@ export function Composer({ privacyToggle.setAttribute("aria-pressed", "false"); privacyToggle.setAttribute("aria-label", "Toggle privacy mode"); - const zapToggle = createElement(doc, "button", "dm-composer__menu-item", "Zap"); - zapToggle.type = "button"; - zapToggle.setAttribute("role", "menuitem"); - zapToggle.setAttribute("aria-expanded", "false"); - zapToggle.setAttribute("aria-label", "Open zap composer"); - zapToggle.setAttribute("aria-pressed", "false"); - moreMenu.appendChild(privacyToggle); - moreMenu.appendChild(zapToggle); tools.appendChild(attachButton); tools.appendChild(moreButton); @@ -126,64 +114,8 @@ export function Composer({ status.textContent = "Sending…"; } - const zapPanel = createElement(doc, "div", "dm-composer__zap-panel"); - zapPanel.id = "dm-composer-zap-panel"; - zapPanel.hidden = true; - zapToggle.setAttribute("aria-controls", zapPanel.id); - const zapRecipientLabel = createElement( - doc, - "span", - "dm-composer__zap-recipient", - zapRecipient?.name - ? `Zap ${zapRecipient.name}` - : "Zap this contact", - ); - zapPanel.appendChild(zapRecipientLabel); - - const zapAddress = createElement( - doc, - "span", - "dm-composer__zap-address", - zapRecipient?.lightningAddress || "Lightning address required.", - ); - zapPanel.appendChild(zapAddress); - - const zapAmountLabel = createElement(doc, "label", "dm-composer__zap-label", "Amount (sats)"); - zapAmountLabel.setAttribute("for", "dm-zap-amount-input"); - const zapAmountInput = createElement(doc, "input", "dm-composer__zap-input"); - zapAmountInput.type = "number"; - zapAmountInput.min = "1"; - zapAmountInput.step = "1"; - zapAmountInput.inputMode = "numeric"; - zapAmountInput.id = "dm-zap-amount-input"; - zapAmountInput.placeholder = "100"; - zapAmountInput.setAttribute("aria-label", "Zap amount in sats"); - zapPanel.appendChild(zapAmountLabel); - zapPanel.appendChild(zapAmountInput); - - const zapNoteLabel = createElement(doc, "label", "dm-composer__zap-label", "Zap note"); - zapNoteLabel.setAttribute("for", "dm-zap-note-input"); - const zapNoteInput = createElement(doc, "input", "dm-composer__zap-input"); - zapNoteInput.type = "text"; - zapNoteInput.id = "dm-zap-note-input"; - zapNoteInput.placeholder = "Optional note for this zap"; - zapNoteInput.setAttribute("aria-label", "Zap note"); - zapPanel.appendChild(zapNoteLabel); - zapPanel.appendChild(zapNoteInput); - - const zapActions = createElement(doc, "div", "dm-composer__zap-actions"); - const zapSendButton = createElement(doc, "button", "dm-composer__zap-send", "Send zap"); - zapSendButton.type = "button"; - zapSendButton.setAttribute("aria-label", "Send zap request"); - zapActions.appendChild(zapSendButton); - zapPanel.appendChild(zapActions); - - const zapStatus = createElement(doc, "div", "dm-composer__zap-status"); - zapPanel.appendChild(zapStatus); - form.appendChild(label); form.appendChild(textarea); - form.appendChild(zapPanel); form.appendChild(actions); form.appendChild(status); @@ -213,48 +145,6 @@ export function Composer({ onSend(textarea.value, payload); }; - let zapOpen = false; - let resolvedRecipient = null; - - const setZapStatus = (message, variant = "info") => { - zapStatus.textContent = message || ""; - zapStatus.dataset.state = variant; - }; - - const resolveRecipient = async () => { - if (!zapRecipient?.lightningAddress) { - setZapStatus("Add a Lightning address to enable zaps.", "error"); - return null; - } - setZapStatus("Resolving LNURL…", "loading"); - try { - const resolver = - typeof zapConfig?.resolveRecipient === "function" - ? zapConfig.resolveRecipient - : resolveZapRecipient; - resolvedRecipient = await resolver(zapRecipient.lightningAddress, { - fetcher: zapConfig?.fetcher, - }); - if ( - !resolvedRecipient?.metadata?.allowsNostr && - !resolvedRecipient?.metadata?.nostrPubkey - ) { - setZapStatus("Recipient LNURL does not support Nostr zaps.", "error"); - resolvedRecipient = null; - return null; - } - setZapStatus("LNURL ready for zaps.", "success"); - return resolvedRecipient; - } catch (error) { - devLogger.warn("[dm] Failed to resolve LNURL for zap recipient.", error); - setZapStatus( - error instanceof Error ? error.message : "Failed to resolve LNURL.", - "error", - ); - return null; - } - }; - form.addEventListener("submit", (event) => { event.preventDefault(); void handleSubmit(); @@ -281,77 +171,6 @@ export function Composer({ updatePrivacyLabel(); }); - zapToggle.addEventListener("click", () => { - zapOpen = !zapOpen; - zapPanel.hidden = !zapOpen; - zapToggle.setAttribute("aria-expanded", zapOpen ? "true" : "false"); - zapToggle.setAttribute("aria-pressed", zapOpen ? "true" : "false"); - if (zapOpen) { - void resolveRecipient(); - } else { - setZapStatus(""); - } - }); - - zapSendButton.addEventListener("click", async () => { - const amount = Number(zapAmountInput.value); - if (!Number.isFinite(amount) || amount <= 0) { - setZapStatus("Enter a zap amount greater than zero.", "error"); - return; - } - - const resolved = resolvedRecipient || (await resolveRecipient()); - if (!resolved) { - return; - } - - const comment = zapNoteInput.value.trim(); - const payload = { - amountSats: Math.round(amount), - comment, - recipient: zapRecipient, - resolved, - }; - - if (typeof onSendZap === "function") { - try { - await onSendZap(payload); - setZapStatus("Zap request sent. Awaiting receipt…", "success"); - } catch (error) { - userLogger.error("[dm] Zap request failed.", error); - setZapStatus( - error instanceof Error ? error.message : "Zap request failed.", - "error", - ); - } - return; - } - - if (zapConfig?.signer) { - try { - await createAndPublishZapRequest({ - address: zapRecipient?.lightningAddress, - recipientPubkey: zapRecipient?.pubkey, - relays: zapRecipient?.relayHints || zapConfig?.relays, - amountSats: Math.round(amount), - comment, - signer: zapConfig.signer, - pool: zapConfig.pool, - fetcher: zapConfig.fetcher, - }); - setZapStatus("Zap request published. Awaiting receipt…", "success"); - } catch (error) { - userLogger.error("[dm] Zap publish failed.", error); - setZapStatus( - error instanceof Error ? error.message : "Zap request failed.", - "error", - ); - } - } else { - setZapStatus("Connect a Nostr signer to send zap requests.", "error"); - } - }); - textarea.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); diff --git a/js/ui/dm/DMPrivacySettings.js b/js/ui/dm/DMPrivacySettings.js new file mode 100644 index 00000000..5a839fe5 --- /dev/null +++ b/js/ui/dm/DMPrivacySettings.js @@ -0,0 +1,72 @@ +export function DMPrivacySettings({ + document: doc, + readReceiptsEnabled = false, + typingIndicatorsEnabled = false, + onToggleReadReceipts, + onToggleTypingIndicators, +} = {}) { + if (!doc) { + throw new Error("DMPrivacySettings requires a document reference."); + } + + const root = doc.createElement("div"); + root.className = "dm-privacy-settings card p-4 space-y-4 shadow-lg"; + + const title = doc.createElement("h4"); + title.className = "text-sm font-semibold text-text"; + title.textContent = "Direct message privacy"; + root.appendChild(title); + + const desc = doc.createElement("p"); + desc.className = "text-xs text-muted mb-4"; + desc.textContent = "Control read receipts and typing indicators for direct messages."; + root.appendChild(desc); + + // Read Receipts Toggle + const readReceiptsRow = doc.createElement("div"); + readReceiptsRow.className = "flex items-center justify-between"; + + const readReceiptsLabel = doc.createElement("label"); + readReceiptsLabel.className = "text-sm text-text cursor-pointer"; + readReceiptsLabel.textContent = "Read receipts"; + readReceiptsLabel.setAttribute("for", "dm-read-receipts-toggle"); + readReceiptsRow.appendChild(readReceiptsLabel); + + const readReceiptsToggle = doc.createElement("input"); + readReceiptsToggle.type = "checkbox"; + readReceiptsToggle.id = "dm-read-receipts-toggle"; + readReceiptsToggle.className = "toggle toggle-sm toggle-accent"; + readReceiptsToggle.checked = readReceiptsEnabled; + readReceiptsToggle.addEventListener("change", (e) => { + if (typeof onToggleReadReceipts === "function") { + onToggleReadReceipts(e.target.checked); + } + }); + readReceiptsRow.appendChild(readReceiptsToggle); + root.appendChild(readReceiptsRow); + + // Typing Indicators Toggle + const typingIndicatorsRow = doc.createElement("div"); + typingIndicatorsRow.className = "flex items-center justify-between"; + + const typingIndicatorsLabel = doc.createElement("label"); + typingIndicatorsLabel.className = "text-sm text-text cursor-pointer"; + typingIndicatorsLabel.textContent = "Typing indicators"; + typingIndicatorsLabel.setAttribute("for", "dm-typing-indicators-toggle"); + typingIndicatorsRow.appendChild(typingIndicatorsLabel); + + const typingIndicatorsToggle = doc.createElement("input"); + typingIndicatorsToggle.type = "checkbox"; + typingIndicatorsToggle.id = "dm-typing-indicators-toggle"; + typingIndicatorsToggle.className = "toggle toggle-sm toggle-accent"; + typingIndicatorsToggle.checked = typingIndicatorsEnabled; + typingIndicatorsToggle.addEventListener("change", (e) => { + if (typeof onToggleTypingIndicators === "function") { + onToggleTypingIndicators(e.target.checked); + } + }); + typingIndicatorsRow.appendChild(typingIndicatorsToggle); + root.appendChild(typingIndicatorsRow); + + return root; +} diff --git a/js/ui/dm/MessageBubble.js b/js/ui/dm/MessageBubble.js index eea45877..061a7b29 100644 --- a/js/ui/dm/MessageBubble.js +++ b/js/ui/dm/MessageBubble.js @@ -33,7 +33,9 @@ export function MessageBubble({ const meta = createElement(doc, "div", "dm-message-bubble__meta"); if (timestamp) { - meta.appendChild(createElement(doc, "span", "dm-message-bubble__time", timestamp)); + // Added 'text-muted' class for theme-respecting color (usually handles light/dark text) + const timeSpan = createElement(doc, "span", "dm-message-bubble__time text-muted", timestamp); + meta.appendChild(timeSpan); } if (variant === "outgoing") { diff --git a/js/ui/dm/MessageThread.js b/js/ui/dm/MessageThread.js index 554d5aa9..3ced8116 100644 --- a/js/ui/dm/MessageThread.js +++ b/js/ui/dm/MessageThread.js @@ -2,6 +2,8 @@ import { DayDivider } from "./DayDivider.js"; import { MessageBubble } from "./MessageBubble.js"; import { ZapReceiptList } from "./ZapReceiptList.js"; import { formatZapAmount } from "./zapHelpers.js"; +import { createAndPublishZapRequest, resolveZapRecipient } from "../../payments/zapRequests.js"; +import { userLogger, devLogger } from "../../utils/logger.js"; function createElement(doc, tag, className, text) { const element = doc.createElement(tag); @@ -25,6 +27,8 @@ export function MessageThread({ conversationZapTotalSats = 0, profileZapTotalSats = 0, onMarkRead, + onSendZap, + zapConfig = null, } = {}) { if (!doc) { throw new Error("MessageThread requires a document reference."); @@ -84,6 +88,21 @@ export function MessageThread({ ); header.appendChild(zapSummary); + // Zap Button + const zapBtn = createElement(doc, "button", "btn-ghost btn-icon"); + zapBtn.innerHTML = ``; + zapBtn.ariaLabel = "Zap"; + zapBtn.addEventListener("click", () => { + const existingInterface = root.querySelector(".dm-zap-interface"); + if (existingInterface) { + existingInterface.remove(); + return; + } + const zapInterface = createZapInterface(doc, { contact, zapReceipts, onSendZap, zapConfig }); + root.appendChild(zapInterface); + }); + header.appendChild(zapBtn); + if (typeof onMarkRead === "function") { const actions = createElement(doc, "div", "ml-auto flex items-center gap-2"); const markReadButton = createElement(doc, "button", "btn-ghost", "Mark read"); @@ -146,14 +165,118 @@ export function MessageThread({ }); } - root.appendChild(timeline); + // Scroll to bottom + if (messages.length > 0) { + setTimeout(() => { + timeline.scrollTop = timeline.scrollHeight; + }, 0); + } - root.appendChild( + root.appendChild(timeline); + return root; +} + +function createZapInterface(doc, { contact, zapReceipts, onSendZap, zapConfig }) { + const container = createElement(doc, "div", "dm-zap-interface card p-4 absolute bottom-16 right-4 z-20 w-80 shadow-xl border border-border bg-surface"); + + const closeBtn = createElement(doc, "button", "absolute top-2 right-2 btn-ghost btn-xs", "✕"); + closeBtn.addEventListener("click", () => container.remove()); + container.appendChild(closeBtn); + + const title = createElement(doc, "h4", "text-sm font-bold mb-2", "Send Zap"); + container.appendChild(title); + + // Zap Form + const form = createElement(doc, "form", "space-y-2 mb-4"); + const amountInput = createElement(doc, "input", "input input-sm w-full"); + amountInput.type = "number"; + amountInput.placeholder = "Amount (sats)"; + amountInput.min = "1"; + + const noteInput = createElement(doc, "input", "input input-sm w-full"); + noteInput.type = "text"; + noteInput.placeholder = "Comment (optional)"; + + const sendBtn = createElement(doc, "button", "btn btn-sm btn-primary w-full", "Zap ⚡"); + const statusDiv = createElement(doc, "div", "text-xs text-center mt-1"); + + sendBtn.addEventListener("click", async (e) => { + e.preventDefault(); + const amount = parseInt(amountInput.value); + const comment = noteInput.value; + if (!amount || amount <= 0) { + statusDiv.textContent = "Invalid amount"; + statusDiv.className = "text-error text-xs text-center mt-1"; + return; + } + + statusDiv.textContent = "Sending..."; + statusDiv.className = "text-muted text-xs text-center mt-1"; + sendBtn.disabled = true; + + try { + if (typeof onSendZap === "function") { + // Use provided callback if available (e.g. from controller) + if (!contact.lightningAddress) { + throw new Error("No lightning address for this contact"); + } + + const resolver = zapConfig?.resolveRecipient || resolveZapRecipient; + const resolved = await resolver(contact.lightningAddress, { fetcher: zapConfig?.fetcher }); + if (!resolved) throw new Error("Could not resolve lightning address"); + + const payload = { + amountSats: amount, + comment, + recipient: contact, + resolved + }; + await onSendZap(payload); + statusDiv.textContent = "Zap sent!"; + statusDiv.className = "text-success text-xs text-center mt-1"; + amountInput.value = ""; + noteInput.value = ""; + } else if (zapConfig?.signer) { + await createAndPublishZapRequest({ + address: contact.lightningAddress, + recipientPubkey: contact.pubkey, + relays: contact.relayHints || zapConfig?.relays, + amountSats: amount, + comment, + signer: zapConfig.signer, + pool: zapConfig.pool, + fetcher: zapConfig.fetcher, + }); + statusDiv.textContent = "Zap sent!"; + statusDiv.className = "text-success text-xs text-center mt-1"; + amountInput.value = ""; + noteInput.value = ""; + } else { + throw new Error("No signer available"); + } + } catch (err) { + console.error(err); + statusDiv.textContent = "Failed to send zap"; + statusDiv.className = "text-error text-xs text-center mt-1"; + } finally { + sendBtn.disabled = false; + } + }); + + form.appendChild(amountInput); + form.appendChild(noteInput); + form.appendChild(sendBtn); + form.appendChild(statusDiv); + container.appendChild(form); + + // Receipts List + container.appendChild( ZapReceiptList({ document: doc, receipts: zapReceipts, - emptyLabel: "No zap receipts published yet.", + emptyLabel: "No recent zaps.", }), ); - return root; + + return container; } diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index e5a78276..f2ea097d 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -4421,6 +4421,8 @@ export class ProfileModalController { container.innerHTML = ""; try { + const dmPrivacySettings = this.getDmPrivacySettingsSnapshot(); + this.dmAppShell = new AppShell({ document, conversations, @@ -4429,8 +4431,12 @@ export class ProfileModalController { messages: timeline, threadState, privacyMode, + dmPrivacySettings, composerState: this.dmComposerState || "idle", notifications: [], + zapConfig: { + signer: this.services.nostrClient, + }, onSelectConversation: (conversation) => { void this.handleDmConversationSelect(conversation); }, @@ -4443,6 +4449,12 @@ export class ProfileModalController { onMarkAllRead: () => { void this.handleDmMarkAllConversationsRead(); }, + onToggleReadReceipts: (enabled) => { + this.handleReadReceiptsToggle(enabled); + }, + onToggleTypingIndicators: (enabled) => { + this.handleTypingIndicatorsToggle(enabled); + }, }); } catch (error) { this.dmAppShell = null; @@ -5160,23 +5172,7 @@ export class ProfileModalController { }); } - if (this.profileMessagesReadReceiptsToggle instanceof HTMLElement) { - this.profileMessagesReadReceiptsToggle.addEventListener("change", (event) => { - const toggle = event.currentTarget; - if (toggle instanceof HTMLInputElement) { - this.handleReadReceiptsToggle(toggle.checked); - } - }); - } - - if (this.profileMessagesTypingToggle instanceof HTMLElement) { - this.profileMessagesTypingToggle.addEventListener("change", (event) => { - const toggle = event.currentTarget; - if (toggle instanceof HTMLInputElement) { - this.handleTypingIndicatorsToggle(toggle.checked); - } - }); - } + // Legacy toggles removed - handled by DMPrivacySettings in AppShell if (this.profileLinkPreviewAutoToggle instanceof HTMLElement) { this.profileLinkPreviewAutoToggle.addEventListener("change", (event) => {