diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 84903422..5ebb3ce4 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -5095,6 +5095,12 @@ gap: var(--space-xs); } + .dm-contact-row__meta { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + } + .dm-contact-row__name { font-weight: 600; } @@ -5170,6 +5176,32 @@ color: var(--text-muted); } + .dm-message-thread__zap-summary { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-2xs) var(--space-xs); + border-radius: var(--radius-full); + border: var(--border-width-hairline) solid var(--border-subtle); + background: var(--surface-sunken); + color: var(--text-muted); + font-size: var(--font-size-2xs); + } + + .dm-message-thread__zap-label { + text-transform: uppercase; + letter-spacing: var(--tracking-wide); + } + + .dm-message-thread__zap-total { + font-weight: 600; + color: var(--text-primary); + } + + .dm-message-thread__zap-profile { + color: var(--text-muted); + } + .dm-message-thread__timeline { display: flex; flex-direction: column; @@ -5497,6 +5529,29 @@ cursor: pointer; } + .dm-composer__zap-toggle { + border-radius: var(--radius-md); + border: var(--border-width-hairline) solid var(--border-default); + background: var(--surface-base); + color: var(--text-muted); + padding: var(--space-2xs) var(--space-sm); + font-size: var(--font-size-2xs); + cursor: pointer; + } + + .dm-composer__zap-toggle[aria-pressed="true"] { + border-color: var(--border-strong); + background: var(--surface-overlay-panel); + color: var(--text-primary); + } + + .dm-composer__zap-toggle:hover, + .dm-composer__zap-toggle:focus-visible { + background: var(--surface-overlay-panel-soft); + color: var(--text-primary); + outline: none; + } + .dm-composer__attach:hover, .dm-composer__attach:focus-visible, .dm-composer__privacy:hover, @@ -5522,12 +5577,195 @@ outline: none; } + .dm-composer__zap-panel { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-sm); + border-radius: var(--radius-md); + border: var(--border-width-hairline) solid var(--border-subtle); + background: var(--surface-sunken); + } + + .dm-composer__zap-recipient { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--text-primary); + } + + .dm-composer__zap-address { + font-size: var(--font-size-2xs); + color: var(--text-muted); + } + + .dm-composer__zap-label { + font-size: var(--font-size-2xs); + text-transform: uppercase; + letter-spacing: var(--tracking-wide); + color: var(--text-muted); + } + + .dm-composer__zap-input { + border-radius: var(--radius-md); + border: var(--border-width-hairline) solid var(--border-default); + background: var(--surface-base); + color: var(--text-primary); + padding: var(--space-2xs) var(--space-sm); + font-size: var(--font-size-xs); + font-family: inherit; + } + + .dm-composer__zap-input:focus-visible { + outline: var(--size-2px) solid var(--color-accent); + outline-offset: var(--size-2px); + } + + .dm-composer__zap-actions { + display: flex; + justify-content: flex-end; + } + + .dm-composer__zap-send { + border-radius: var(--radius-md); + border: var(--border-width-hairline) solid var(--border-strong); + background: var(--surface-overlay-panel); + color: var(--text-primary); + padding: var(--space-2xs) var(--space-md); + font-size: var(--font-size-xs); + font-weight: 600; + cursor: pointer; + } + + .dm-composer__zap-send:hover, + .dm-composer__zap-send:focus-visible { + background: var(--surface-overlay-panel-soft); + outline: none; + } + + .dm-composer__zap-status { + font-size: var(--font-size-2xs); + color: var(--text-muted); + min-height: var(--space-md); + } + + .dm-composer__zap-status[data-state="error"] { + color: var(--status-danger); + } + + .dm-composer__zap-status[data-state="success"] { + color: var(--status-success); + } + .dm-composer__status { font-size: var(--font-size-2xs); color: var(--status-danger); min-height: var(--space-md); } + .dm-contact-row__zap { + display: inline-flex; + align-items: center; + gap: var(--space-2xs); + padding: var(--space-3xs) var(--space-xs); + border-radius: var(--radius-full); + border: var(--border-width-hairline) solid var(--border-subtle); + background: var(--surface-sunken); + color: var(--text-muted); + font-size: var(--font-size-3xs); + } + + .dm-zap-receipts { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: 0 var(--space-sm) var(--space-sm); + } + + .dm-zap-receipts__header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .dm-zap-receipts__title { + font-size: var(--font-size-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: var(--tracking-wide); + color: var(--text-muted); + } + + .dm-zap-receipts__list { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: 0; + margin: 0; + list-style: none; + } + + .dm-zap-receipts__item { + display: grid; + grid-template-columns: auto 1fr auto; + gap: var(--space-sm); + align-items: center; + padding: var(--space-xs); + border-radius: var(--radius-md); + border: var(--border-width-hairline) solid var(--border-subtle); + background: var(--surface-sunken); + } + + .dm-zap-receipts__amount { + font-weight: 600; + color: var(--text-primary); + font-size: var(--font-size-xs); + white-space: nowrap; + } + + .dm-zap-receipts__details { + display: flex; + flex-direction: column; + gap: var(--space-3xs); + min-width: 0; + } + + .dm-zap-receipts__sender { + font-size: var(--font-size-xs); + color: var(--text-primary); + } + + .dm-zap-receipts__note { + font-size: var(--font-size-2xs); + color: var(--text-muted); + } + + .dm-zap-receipts__meta { + display: flex; + flex-direction: column; + gap: var(--space-3xs); + align-items: flex-end; + font-size: var(--font-size-2xs); + color: var(--text-muted); + white-space: nowrap; + } + + .dm-zap-receipts__status[data-status="confirmed"] { + color: var(--status-success); + } + + .dm-zap-receipts__status[data-status="pending"] { + color: var(--status-warning); + } + + .dm-zap-receipts__status[data-status="failed"] { + color: var(--status-danger); + } + + .dm-zap-receipts__empty { + font-size: var(--font-size-xs); + color: var(--text-muted); + } + @media (min-width: theme("screens.md")) { .dm-app-shell { flex-direction: row; diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 5562d708..31a2e913 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -163,6 +163,8 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca | Storage challenge (`NOTE_TYPES.STORAGE_CHALLENGE`) | `22242` | `['challenge', ]`, `['purpose', 'bitvid-storage-key']` | Empty content. This event is **ephemeral** and never published to relays. It serves as a challenge for the signer; the deterministic signature produced is used to derive the local encryption Master Key for the Storage Service. | | Profile metadata (`NOTE_TYPES.PROFILE_METADATA`) | `0` | `['d', ...]` is not used | JSON payload with NIP-01 fields (`name`, `about`, `picture`, `nip05`, etc.) | | Mute list (`NOTE_TYPES.MUTE_LIST`) | `10000` | Repeated `['p', ]` tags for blocked/muted users | Optional content (often encrypted) | +| Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', ]` plus optional `['e', ]`, `['a', ]`, `['amount', ]`, `['lnurl', ]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note | +| Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', ]`, `['description', ]`, `['p', ]` plus optional `['e', ]` and `['a', ]` | Empty content; receipts are published by the recipient's LNURL server | Subscription lists therefore match the [NIP-51 follow-set specification](./nips/51.md#sets) by emitting kind `30000` diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 724c8e95..9077aac6 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -20,6 +20,8 @@ export const NOTE_TYPES = Object.freeze({ VIEW_EVENT: "viewEvent", VIDEO_REACTION: "videoReaction", VIDEO_COMMENT: "videoComment", + ZAP_REQUEST: "zapRequest", + ZAP_RECEIPT: "zapReceipt", WATCH_HISTORY: "watchHistory", SUBSCRIPTION_LIST: "subscriptionList", USER_BLOCK_LIST: "userBlockList", @@ -362,6 +364,28 @@ const BASE_SCHEMAS = { description: "Plain text comment body sanitized for UTF-8 compatibility.", }, }, + [NOTE_TYPES.ZAP_REQUEST]: { + type: NOTE_TYPES.ZAP_REQUEST, + label: "Zap request", + kind: 9734, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "text", + description: + "Optional zap message included in the zap request sent to LNURL pay callbacks.", + }, + }, + [NOTE_TYPES.ZAP_RECEIPT]: { + type: NOTE_TYPES.ZAP_RECEIPT, + label: "Zap receipt", + kind: 9735, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "text", + description: + "Zap receipts are published by lightning wallets to confirm zap requests.", + }, + }, [NOTE_TYPES.WATCH_HISTORY]: { type: NOTE_TYPES.WATCH_HISTORY, label: "Watch history month", @@ -1124,6 +1148,80 @@ export function buildViewEvent(params) { }; } +export function buildZapRequestEvent(params) { + const { + pubkey, + created_at, + recipientPubkey, + relays = [], + amountSats, + lnurl, + eventId, + coordinate, + additionalTags = [], + content = "", + } = params || {}; + const schema = getNostrEventSchema(NOTE_TYPES.ZAP_REQUEST); + const tags = []; + + const normalizedRecipient = normalizePointerIdentifier(recipientPubkey); + if (normalizedRecipient) { + tags.push(["p", normalizedRecipient]); + } + + const normalizedEventId = normalizePointerIdentifier(eventId); + if (normalizedEventId) { + tags.push(["e", normalizedEventId]); + } + + if (typeof coordinate === "string" && coordinate.trim()) { + tags.push(["a", coordinate.trim()]); + } + + if (typeof lnurl === "string" && lnurl.trim()) { + tags.push(["lnurl", lnurl.trim()]); + } + + if (Number.isFinite(amountSats)) { + tags.push(["amount", String(Math.max(0, Math.round(amountSats)) * 1000)]); + } + + const relayList = Array.isArray(relays) ? relays : []; + const relaySeen = new Set(); + const normalizedRelays = []; + relayList.forEach((relay) => { + if (typeof relay !== "string") { + return; + } + const trimmed = relay.trim(); + if (!trimmed || relaySeen.has(trimmed)) { + return; + } + relaySeen.add(trimmed); + normalizedRelays.push(trimmed); + }); + if (normalizedRelays.length) { + tags.push(["relays", ...normalizedRelays]); + } + + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); + if (sanitizedAdditionalTags.length) { + tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); + } + + appendSchemaTags(tags, schema); + + const resolvedContent = ensureValidUtf8Content(content); + + return { + kind: schema?.kind ?? 9734, + pubkey, + created_at, + tags, + content: resolvedContent, + }; +} + export function buildReactionEvent(params) { const { pubkey, diff --git a/js/payments/zapRequests.js b/js/payments/zapRequests.js new file mode 100644 index 00000000..56688d93 --- /dev/null +++ b/js/payments/zapRequests.js @@ -0,0 +1,200 @@ +// js/payments/zapRequests.js + +import { + resolveLightningAddress, + fetchPayServiceData, + encodeLnurlBech32, + validateInvoiceAmount, + requestInvoice, +} from "./lnurl.js"; +import { buildZapRequestEvent, NOTE_TYPES } from "../nostrEventSchemas.js"; +import { publishEventToRelays, assertAnyRelayAccepted } from "../nostrPublish.js"; +import { queueSignEvent } from "../nostr/signRequestQueue.js"; +import { RELAY_URLS } from "../nostr/toolkit.js"; +import { devLogger } from "../utils/logger.js"; + +const DEFAULT_ZAP_RELAYS = Array.from(RELAY_URLS); + +function normalizeRelayList(relays) { + const list = Array.isArray(relays) ? relays : []; + const seen = new Set(); + const normalized = []; + list.forEach((relay) => { + if (typeof relay !== "string") { + return; + } + const trimmed = relay.trim(); + if (!trimmed || seen.has(trimmed)) { + return; + } + seen.add(trimmed); + normalized.push(trimmed); + }); + return normalized; +} + +async function resolveSignerPubkey(signer) { + if (!signer || typeof signer !== "object") { + return ""; + } + if (typeof signer.getPublicKey === "function") { + return signer.getPublicKey(); + } + if (typeof signer.getPubkey === "function") { + return signer.getPubkey(); + } + if (typeof signer.pubkey === "string") { + return signer.pubkey; + } + return ""; +} + +function resolveLnurlTag(resolved) { + const candidate = typeof resolved?.address === "string" ? resolved.address.trim() : ""; + if (candidate && candidate.toLowerCase().startsWith("lnurl")) { + return candidate.toLowerCase(); + } + + const url = typeof resolved?.url === "string" ? resolved.url.trim() : ""; + if (!url) { + return ""; + } + + return encodeLnurlBech32(url); +} + +export async function resolveZapRecipient(address, { fetcher } = {}) { + const resolved = resolveLightningAddress(address); + const metadata = await fetchPayServiceData(resolved.url, { fetcher }); + const lnurl = resolveLnurlTag(resolved); + return { resolved, metadata, lnurl }; +} + +export function buildZapRequestPayload({ + senderPubkey, + recipientPubkey, + relays, + amountSats, + comment, + lnurl, + eventId, + coordinate, + createdAt, + additionalTags = [], +} = {}) { + const created_at = Number.isFinite(createdAt) + ? Math.max(0, Math.floor(createdAt)) + : Math.floor(Date.now() / 1000); + const event = buildZapRequestEvent({ + pubkey: senderPubkey, + created_at, + recipientPubkey, + relays, + amountSats, + content: comment || "", + lnurl, + eventId, + coordinate, + additionalTags, + }); + return event; +} + +export async function signZapRequest(event, signer, options = {}) { + if (!signer || typeof signer.signEvent !== "function") { + throw new Error("A Nostr signer is required to create zap requests."); + } + const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : undefined; + return queueSignEvent(signer, event, { timeoutMs }); +} + +export async function publishZapRequest(signedEvent, { relays, pool } = {}) { + const targetRelays = normalizeRelayList(relays); + const publishTargets = targetRelays.length ? targetRelays : DEFAULT_ZAP_RELAYS; + const publishResults = await publishEventToRelays(pool, publishTargets, signedEvent); + const summary = assertAnyRelayAccepted(publishResults, { context: "zap request" }); + return { + relays: publishTargets, + summary, + results: publishResults, + }; +} + +export async function createAndPublishZapRequest({ + address, + recipientPubkey, + relays, + amountSats, + comment, + eventId, + coordinate, + signer, + pool, + fetcher, + additionalTags = [], +} = {}) { + const { resolved, metadata, lnurl } = await resolveZapRecipient(address, { fetcher }); + + if (!metadata?.allowsNostr && !metadata?.nostrPubkey) { + throw new Error("Recipient LNURL endpoint does not support Nostr zaps."); + } + + const resolvedRecipient = recipientPubkey || metadata?.nostrPubkey || ""; + if (!resolvedRecipient) { + throw new Error("Recipient pubkey is required to send a zap request."); + } + + const normalizedRelays = normalizeRelayList(relays); + const senderPubkey = await resolveSignerPubkey(signer); + if (!senderPubkey) { + throw new Error("Unable to resolve sender pubkey for zap request."); + } + + const zapRequestEvent = buildZapRequestPayload({ + senderPubkey, + recipientPubkey: resolvedRecipient, + relays: normalizedRelays, + amountSats, + comment, + lnurl, + eventId, + coordinate, + additionalTags, + }); + + const signedEvent = await signZapRequest(zapRequestEvent, signer); + + const publishResult = await publishZapRequest(signedEvent, { + relays: normalizedRelays, + pool, + }); + + let invoice = null; + try { + validateInvoiceAmount(metadata, amountSats); + invoice = await requestInvoice(metadata, { + amountSats, + comment, + zapRequest: JSON.stringify(signedEvent), + lnurl, + fetcher, + }); + } catch (error) { + devLogger.warn("[zapRequests] Failed to request invoice for zap request.", error); + } + + return { + kind: NOTE_TYPES.ZAP_REQUEST, + resolved, + metadata, + lnurl, + zapRequest: signedEvent, + publishResult, + invoice, + }; +} + +export const __TESTING__ = Object.freeze({ + normalizeRelayList, + resolveLnurlTag, +}); diff --git a/js/ui/dm/AppShell.js b/js/ui/dm/AppShell.js index 968d0fa2..08afcf05 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 { aggregateZapTotals } from "./zapHelpers.js"; function createElement(doc, tag, className, text) { const element = doc.createElement(tag); @@ -23,6 +24,9 @@ const DEFAULT_CONVERSATIONS = [ unreadCount: 2, status: "Online", avatarSrc: "", + pubkey: "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", + lightningAddress: "nia@zap.lol", + relayHints: ["wss://relay.damus.io", "wss://nostr.wine"], }, { id: "conv-2", @@ -32,6 +36,9 @@ const DEFAULT_CONVERSATIONS = [ unreadCount: 0, status: "Away", avatarSrc: "", + pubkey: "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", + lightningAddress: "ops@getalby.com", + relayHints: ["wss://relay.primal.net"], }, ]; @@ -66,6 +73,42 @@ const DEFAULT_MESSAGES = [ }, ]; +const DEFAULT_ZAP_RECEIPTS = [ + { + id: "zap-1", + kind: 9735, + conversationId: "conv-1", + profileId: "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", + senderName: "You", + amountSats: 120, + note: "For the quick relay fix!", + timestamp: "09:20", + status: "confirmed", + }, + { + id: "zap-2", + kind: 9735, + conversationId: "conv-1", + profileId: "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", + senderName: "Nia Nova", + amountSats: 42, + note: "Thanks for testing the DM flow.", + timestamp: "09:22", + status: "confirmed", + }, + { + id: "zap-3", + kind: 9735, + conversationId: "conv-2", + profileId: "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", + senderName: "You", + amountSats: 250, + note: "Ops support fund.", + timestamp: "Yesterday", + status: "pending", + }, +]; + export class AppShell { constructor({ document: doc, @@ -73,14 +116,17 @@ export class AppShell { activeConversationId = DEFAULT_CONVERSATIONS[0]?.id || "", conversationState = "idle", messages = DEFAULT_MESSAGES, + zapReceipts = DEFAULT_ZAP_RECEIPTS, threadState = "idle", threadErrorType = "", composerState = "idle", privacyMode = "nip04", notifications = [], signingAdapter = null, + zapConfig = null, onSelectConversation, onSendMessage, + onSendZap, } = {}) { if (!doc) { throw new Error("AppShell requires a document reference."); @@ -89,6 +135,21 @@ export class AppShell { this.document = doc; this.root = createElement(doc, "div", "dm-app-shell"); + const { totalsByConversation, totalsByProfile } = aggregateZapTotals(zapReceipts); + const enrichedConversations = conversations.map((conversation) => ({ + ...conversation, + zapTotalSats: totalsByConversation.get(conversation.id) || 0, + profileZapTotalSats: totalsByProfile.get(conversation.pubkey) || 0, + })); + + const activeConversation = + enrichedConversations.find((conversation) => conversation.id === activeConversationId) || + enrichedConversations[0] || + {}; + const activeZapReceipts = zapReceipts.filter( + (receipt) => receipt.conversationId === activeConversation.id, + ); + const sidebar = createElement(doc, "aside", "dm-app-shell__sidebar"); const sidebarTitle = createElement( doc, @@ -100,7 +161,7 @@ export class AppShell { sidebar.appendChild( ConversationList({ document: doc, - conversations, + conversations: enrichedConversations, activeId: activeConversationId, state: conversationState, onSelect: onSelectConversation, @@ -117,11 +178,15 @@ export class AppShell { main.appendChild( MessageThread({ document: doc, - contact: conversations.find((conversation) => conversation.id === activeConversationId) || {}, + contact: activeConversation, messages, state: threadState, errorType: threadErrorType, privacyMode, + zapReceipts: activeZapReceipts, + conversationZapTotalSats: + totalsByConversation.get(activeConversation.id) || 0, + profileZapTotalSats: totalsByProfile.get(activeConversation.pubkey) || 0, }), ); main.appendChild( @@ -130,7 +195,10 @@ export class AppShell { state: composerState, privacyMode, signingAdapter, + zapRecipient: activeConversation, + zapConfig, onSend: onSendMessage, + onSendZap, }), ); diff --git a/js/ui/dm/Composer.js b/js/ui/dm/Composer.js index b7d35fa5..070dce6f 100644 --- a/js/ui/dm/Composer.js +++ b/js/ui/dm/Composer.js @@ -1,3 +1,6 @@ +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) { @@ -15,7 +18,10 @@ export function Composer({ state = "idle", privacyMode: initialPrivacyMode = "nip04", signingAdapter = null, + zapRecipient = null, + zapConfig = null, onSend, + onSendZap, } = {}) { if (!doc) { throw new Error("Composer requires a document reference."); @@ -66,8 +72,15 @@ export function Composer({ privacyToggle.setAttribute("aria-pressed", "false"); privacyToggle.setAttribute("aria-label", "Toggle privacy mode"); + const zapToggle = createElement(doc, "button", "dm-composer__zap-toggle", "Zap"); + zapToggle.type = "button"; + zapToggle.setAttribute("aria-expanded", "false"); + zapToggle.setAttribute("aria-label", "Open zap composer"); + zapToggle.setAttribute("aria-pressed", "false"); + tools.appendChild(attachButton); tools.appendChild(privacyToggle); + tools.appendChild(zapToggle); tools.appendChild(hint); const button = createElement(doc, "button", "dm-composer__send", "Send"); @@ -84,8 +97,64 @@ 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); @@ -115,6 +184,48 @@ 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(); @@ -141,6 +252,77 @@ 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/ContactRow.js b/js/ui/dm/ContactRow.js index 4c2be75e..9af1740a 100644 --- a/js/ui/dm/ContactRow.js +++ b/js/ui/dm/ContactRow.js @@ -1,4 +1,5 @@ import { Avatar } from "./Avatar.js"; +import { formatZapAmount } from "./zapHelpers.js"; function createElement(doc, tag, className, text) { const element = doc.createElement(tag); @@ -30,6 +31,7 @@ export function ContactRow({ unreadCount = 0, avatarSrc = "", status = "", + zapTotalSats = null, } = contact; const button = createElement(doc, "button", "dm-contact-row"); @@ -56,8 +58,25 @@ export function ContactRow({ const nameEl = createElement(doc, "span", "dm-contact-row__name", name); header.appendChild(nameEl); + const meta = createElement(doc, "span", "dm-contact-row__meta"); const timeEl = createElement(doc, "span", "dm-contact-row__time", timestamp); - header.appendChild(timeEl); + meta.appendChild(timeEl); + + if (Number.isFinite(zapTotalSats)) { + const zapTotal = createElement( + doc, + "span", + "dm-contact-row__zap", + formatZapAmount(zapTotalSats, { compact: true }), + ); + zapTotal.setAttribute( + "aria-label", + `Total zaps ${formatZapAmount(zapTotalSats)}`, + ); + meta.appendChild(zapTotal); + } + + header.appendChild(meta); content.appendChild(header); const previewEl = createElement( diff --git a/js/ui/dm/MessageThread.js b/js/ui/dm/MessageThread.js index e79b3e62..553af7dc 100644 --- a/js/ui/dm/MessageThread.js +++ b/js/ui/dm/MessageThread.js @@ -1,5 +1,7 @@ import { DayDivider } from "./DayDivider.js"; import { MessageBubble } from "./MessageBubble.js"; +import { ZapReceiptList } from "./ZapReceiptList.js"; +import { formatZapAmount } from "./zapHelpers.js"; function createElement(doc, tag, className, text) { const element = doc.createElement(tag); @@ -19,6 +21,9 @@ export function MessageThread({ state = "idle", errorType = "", privacyMode = "nip04", + zapReceipts = [], + conversationZapTotalSats = 0, + profileZapTotalSats = 0, } = {}) { if (!doc) { throw new Error("MessageThread requires a document reference."); @@ -55,6 +60,28 @@ 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); root.appendChild(header); const timeline = createElement(doc, "div", "dm-message-thread__timeline"); @@ -102,5 +129,13 @@ export function MessageThread({ } root.appendChild(timeline); + + root.appendChild( + ZapReceiptList({ + document: doc, + receipts: zapReceipts, + emptyLabel: "No zap receipts published yet.", + }), + ); return root; } diff --git a/js/ui/dm/ZapReceiptList.js b/js/ui/dm/ZapReceiptList.js new file mode 100644 index 00000000..f8691dfa --- /dev/null +++ b/js/ui/dm/ZapReceiptList.js @@ -0,0 +1,100 @@ +import { formatZapAmount, normalizeZapReceipt } from "./zapHelpers.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; +} + +function formatStatus(status) { + const normalized = typeof status === "string" ? status.trim().toLowerCase() : ""; + if (!normalized) { + return "Unverified"; + } + if (normalized === "confirmed" || normalized === "paid") { + return "Confirmed"; + } + if (normalized === "pending") { + return "Pending"; + } + if (normalized === "failed") { + return "Failed"; + } + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + +export function ZapReceiptList({ + document: doc, + receipts = [], + emptyLabel = "No zap receipts yet.", +} = {}) { + if (!doc) { + throw new Error("ZapReceiptList requires a document reference."); + } + + const root = createElement(doc, "section", "dm-zap-receipts"); + const header = createElement(doc, "div", "dm-zap-receipts__header"); + header.appendChild(createElement(doc, "h4", "dm-zap-receipts__title", "Zap receipts")); + root.appendChild(header); + + const list = createElement(doc, "ul", "dm-zap-receipts__list"); + const normalizedReceipts = Array.isArray(receipts) ? receipts.map(normalizeZapReceipt) : []; + + if (!normalizedReceipts.length) { + const empty = createElement(doc, "p", "dm-zap-receipts__empty", emptyLabel); + root.appendChild(empty); + return root; + } + + normalizedReceipts.forEach((receipt) => { + const item = createElement(doc, "li", "dm-zap-receipts__item"); + item.dataset.kind = String(receipt?.kind || 9735); + + const amount = createElement( + doc, + "span", + "dm-zap-receipts__amount", + formatZapAmount(receipt.amountSats), + ); + item.appendChild(amount); + + const details = createElement(doc, "div", "dm-zap-receipts__details"); + const senderName = receipt?.senderName || receipt?.sender || "Unknown sender"; + details.appendChild( + createElement(doc, "span", "dm-zap-receipts__sender", senderName), + ); + + if (receipt?.note) { + details.appendChild(createElement(doc, "span", "dm-zap-receipts__note", receipt.note)); + } + + item.appendChild(details); + + const meta = createElement(doc, "div", "dm-zap-receipts__meta"); + if (receipt?.timestamp) { + meta.appendChild( + createElement(doc, "span", "dm-zap-receipts__time", receipt.timestamp), + ); + } + + const status = createElement( + doc, + "span", + "dm-zap-receipts__status", + formatStatus(receipt?.status), + ); + status.dataset.status = receipt?.status || "unverified"; + meta.appendChild(status); + + item.appendChild(meta); + list.appendChild(item); + }); + + root.appendChild(list); + return root; +} diff --git a/js/ui/dm/index.js b/js/ui/dm/index.js index 9bada81d..ef5640c3 100644 --- a/js/ui/dm/index.js +++ b/js/ui/dm/index.js @@ -7,3 +7,5 @@ export { MessageBubble } from "./MessageBubble.js"; export { Composer } from "./Composer.js"; export { NotificationCenter } from "./NotificationCenter.js"; export { DayDivider } from "./DayDivider.js"; +export { ZapReceiptList } from "./ZapReceiptList.js"; +export { aggregateZapTotals, formatZapAmount, normalizeZapReceipt } from "./zapHelpers.js"; diff --git a/js/ui/dm/zapHelpers.js b/js/ui/dm/zapHelpers.js new file mode 100644 index 00000000..3530f3a9 --- /dev/null +++ b/js/ui/dm/zapHelpers.js @@ -0,0 +1,58 @@ +function normalizeAmount(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return 0; + } + return Math.max(0, Math.round(numeric)); +} + +export function formatZapAmount(amountSats, options = {}) { + const normalized = normalizeAmount(amountSats); + const { compact = false } = options || {}; + const formatter = new Intl.NumberFormat("en-US", { + notation: compact ? "compact" : "standard", + maximumFractionDigits: 0, + }); + return `${formatter.format(normalized)} sats`; +} + +export function aggregateZapTotals(receipts = []) { + const totalsByConversation = new Map(); + const totalsByProfile = new Map(); + let overallSats = 0; + + const list = Array.isArray(receipts) ? receipts : []; + list.forEach((receipt) => { + const amount = normalizeAmount(receipt?.amountSats ?? receipt?.amount ?? 0); + if (!amount) { + return; + } + + overallSats += amount; + + const conversationId = typeof receipt?.conversationId === "string" ? receipt.conversationId : ""; + if (conversationId) { + totalsByConversation.set( + conversationId, + (totalsByConversation.get(conversationId) || 0) + amount, + ); + } + + const profileId = typeof receipt?.profileId === "string" ? receipt.profileId : ""; + if (profileId) { + totalsByProfile.set(profileId, (totalsByProfile.get(profileId) || 0) + amount); + } + }); + + return { + overallSats, + totalsByConversation, + totalsByProfile, + }; +} + +export function normalizeZapReceipt(receipt = {}) { + const normalized = { ...receipt }; + normalized.amountSats = normalizeAmount(receipt?.amountSats ?? receipt?.amount ?? 0); + return normalized; +}