mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Merge pull request #1475 from PR0M3TH3AN/dm-zap-button-relocation-9070595879886835969
Relocate DM Zap Button to Composer
This commit is contained in:
6 files changed
+245
-183
No files matched your search
+32
-2
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
Reference in new issue
Block a user