Merge pull request #1475 from PR0M3TH3AN/dm-zap-button-relocation-9070595879886835969

Relocate DM Zap Button to Composer
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-21 16:45:02 -05:00
commit 802b2b191e
6 files changed
+245 -183

No files matched your search

+32 -2
View File
@@ -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(
+63 -1
View File
@@ -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 = `<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"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`;
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") {
-146
View File
@@ -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 = `<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"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`;
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;
}
+118
View File
@@ -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;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 80 KiB

+32 -34
View File
@@ -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()