diff --git a/js/ui/dm/AppShell.js b/js/ui/dm/AppShell.js index e465d772..7ab36971 100644 --- a/js/ui/dm/AppShell.js +++ b/js/ui/dm/AppShell.js @@ -4,6 +4,7 @@ import { MessageThread } from "./MessageThread.js"; import { NotificationCenter } from "./NotificationCenter.js"; import { DMPrivacySettings } from "./DMPrivacySettings.js"; import { aggregateZapTotals } from "./zapHelpers.js"; +import { createZapInterface } from "./ZapInterface.js"; function createElement(doc, tag, className, text) { const element = doc.createElement(tag); @@ -233,6 +234,32 @@ export class AppShell { zapConfig, }), ); + const handleZapTrigger = () => { + const existingInterface = main.querySelector(".dm-zap-interface"); + if (existingInterface) { + existingInterface.remove(); + return; + } + + const zapInterface = createZapInterface(doc, { + contact: activeConversation, + zapReceipts: activeZapReceipts, + onSendZap, + zapConfig + }); + + // Position it relative to the composer or just absolute in main + // The extracted createZapInterface uses 'absolute bottom-16 right-4' which positions it nicely + // relative to the container if the container is relative. + // main usually has relative positioning or we can append to root. + // AppShell root has absolute positioning context maybe? + // If main is the container, we append there. + main.appendChild(zapInterface); + }; + + const conversationZapTotalSats = totalsByConversation.get(activeConversation.id) || 0; + const profileZapTotalSats = totalsByProfile.get(activeConversation.pubkey) || 0; + main.appendChild( Composer({ document: doc, @@ -242,8 +269,11 @@ export class AppShell { zapRecipient: activeConversation, zapConfig, onSend: onSendMessage, - // Zap button moved to MessageThread, so onSendZap here might be redundant if triggered from thread - // But Composer still handles text message sending. + onZap: handleZapTrigger, + zapStats: { + conversationTotal: conversationZapTotalSats, + profileTotal: profileZapTotalSats + } }), ); main.appendChild( diff --git a/js/ui/dm/Composer.js b/js/ui/dm/Composer.js index f9f2a1d5..1fa39090 100644 --- a/js/ui/dm/Composer.js +++ b/js/ui/dm/Composer.js @@ -9,6 +9,8 @@ function createElement(doc, tag, className, text) { return element; } +import { formatZapAmount } from "./zapHelpers.js"; + export function Composer({ document: doc, placeholder = "Write a message…", @@ -18,6 +20,8 @@ export function Composer({ zapRecipient = null, zapConfig = null, onSend, + onZap, + zapStats = null, } = {}) { if (!doc) { throw new Error("Composer requires a document reference."); @@ -105,7 +109,65 @@ export function Composer({ button.setAttribute("aria-label", "Send message"); actions.appendChild(tools); - actions.appendChild(button); + + // Zap Button + // We use dm-composer__send as a base for layout/size compatibility, but tweak styles. + // Using accent-action-button to apply accent color on hover, and px-2 for icon squareness. + const zapBtn = createElement(doc, "button", "dm-composer__send accent-action-button px-2"); + zapBtn.type = "button"; + zapBtn.setAttribute("aria-label", "Zap"); + zapBtn.innerHTML = ``; + + if (typeof onZap === "function") { + zapBtn.addEventListener("click", (e) => { + e.preventDefault(); + onZap(); + }); + + const buttonGroup = createElement(doc, "div", "flex items-center gap-2"); + + // Zap Stats Pill + if (zapStats && typeof zapStats === "object") { + // Reuse class from MessageThread for consistent styling + const zapSummary = createElement(doc, "div", "dm-message-thread__zap-summary"); + + // Zaps Label + const labelSpan = createElement(doc, "span", "dm-message-thread__zap-label", "Zaps"); + zapSummary.appendChild(labelSpan); + + // Conversation Total + // We need formatZapAmount. Since we can't import dynamically easily here (without changing module type or build), + // we assume formatZapAmount is available or we pass formatted strings. + // BUT, looking at file structure, Composer is a module. I should import it at the top. + // I will add the import in a separate block. + // Assuming formatZapAmount is imported. + + const conversationTotal = createElement( + doc, + "span", + "dm-message-thread__zap-total", + formatZapAmount(zapStats.conversationTotal || 0) + ); + zapSummary.appendChild(conversationTotal); + + // Profile Total + const profileTotal = createElement( + doc, + "span", + "dm-message-thread__zap-profile", + `Profile: ${formatZapAmount(zapStats.profileTotal || 0)}` + ); + zapSummary.appendChild(profileTotal); + + buttonGroup.appendChild(zapSummary); + } + + buttonGroup.appendChild(zapBtn); + buttonGroup.appendChild(button); + actions.appendChild(buttonGroup); + } else { + actions.appendChild(button); + } const status = createElement(doc, "div", "dm-composer__status"); if (state === "error") { diff --git a/js/ui/dm/MessageThread.js b/js/ui/dm/MessageThread.js index f7cd01f0..beb593b0 100644 --- a/js/ui/dm/MessageThread.js +++ b/js/ui/dm/MessageThread.js @@ -1,9 +1,5 @@ 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); @@ -79,43 +75,6 @@ export function MessageThread({ privacyBadge.title = privacyHint; header.appendChild(privacyBadge); - const zapSummary = createElement(doc, "div", "dm-message-thread__zap-summary"); - zapSummary.appendChild( - createElement(doc, "span", "dm-message-thread__zap-label", "Zaps"), - ); - zapSummary.appendChild( - createElement( - doc, - "span", - "dm-message-thread__zap-total", - formatZapAmount(conversationZapTotalSats), - ), - ); - zapSummary.appendChild( - createElement( - doc, - "span", - "dm-message-thread__zap-profile", - `Profile: ${formatZapAmount(profileZapTotalSats)}`, - ), - ); - 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"); @@ -188,108 +147,3 @@ export function MessageThread({ 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 recent zaps.", - }), - ); - - return container; -} diff --git a/js/ui/dm/ZapInterface.js b/js/ui/dm/ZapInterface.js new file mode 100644 index 00000000..eb3a3ed6 --- /dev/null +++ b/js/ui/dm/ZapInterface.js @@ -0,0 +1,118 @@ +import { ZapReceiptList } from "./ZapReceiptList.js"; +import { createAndPublishZapRequest, resolveZapRecipient } from "../../payments/zapRequests.js"; + +function createElement(doc, tag, className, text) { + const element = doc.createElement(tag); + if (className) { + element.className = className; + } + if (typeof text === "string") { + element.textContent = text; + } + return element; +} + +export 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 recent zaps.", + }), + ); + + return container; +} diff --git a/verification_profile_messages.png b/verification_profile_messages.png index 37ec55cb..b0f96650 100644 Binary files a/verification_profile_messages.png and b/verification_profile_messages.png differ diff --git a/verify_profile.py b/verify_profile.py index 4cd015d5..12a5c34b 100644 --- a/verify_profile.py +++ b/verify_profile.py @@ -1,53 +1,51 @@ - from playwright.sync_api import sync_playwright -def verify_profile_modal(): +def verify_profile_messages(): with sync_playwright() as p: browser = p.chromium.launch(headless=True) - context = browser.new_context(viewport={'width': 1280, 'height': 720}) - page = context.new_page() - - # Navigate to the app + page = browser.new_page() page.goto("http://localhost:8000") - # Wait for the page to load - page.wait_for_load_state("networkidle") + # 1. Close disclaimer modal if present + # The logs say #disclaimerModal intercepts events. + try: + page.wait_for_selector("#closeDisclaimerModal", timeout=5000) + page.click("#closeDisclaimerModal") + except: + print("Disclaimer modal not found or not visible") - # Make the profile modal visible - page.evaluate("""() => { + # Also could be #disclaimerAgreeBtn + try: + page.wait_for_selector("#disclaimerAgreeBtn", timeout=2000) + page.click("#disclaimerAgreeBtn") + except: + pass + + # 2. Open Profile Modal via JS + page.evaluate(""" const modal = document.getElementById('profileModal'); if (modal) { modal.classList.remove('hidden'); modal.setAttribute('aria-hidden', 'false'); + document.body.classList.add('modal-open'); + + // Ensure profile modal is on top if multiple modals + modal.style.zIndex = '9999'; } - }""") + """) - # Force the Messages pane to be visible - page.evaluate("""() => { - const pane = document.getElementById('profilePaneMessages'); - if (pane) { - pane.classList.remove('hidden'); - pane.removeAttribute('hidden'); - // Ensure other panes are hidden if needed, but not strictly necessary for content check - } - }""") + # 3. Click Messages tab + # Wait for animation/visibility + page.wait_for_timeout(500) + page.click("#profileNavMessages") - # Wait for the pane to become active/visible - page.wait_for_selector("#profilePaneMessages:not(.hidden)") + # 4. Wait for AppShell + page.wait_for_selector("#dmAppShellMount") + page.wait_for_timeout(2000) - # Take a screenshot + # 5. Take screenshot page.screenshot(path="verification_profile_messages.png") - - content = page.content() - if "Direct message privacy" in content: - print("FAILURE: 'Direct message privacy' text found in the page source.") - else: - print("SUCCESS: 'Direct message privacy' text NOT found.") - - if "Read receipts" in content and "Direct message privacy" in content: - print("FAILURE: 'Read receipts' text found.") - browser.close() if __name__ == "__main__": - verify_profile_modal() + verify_profile_messages()