Add DM zap composer and zap request support

This commit is contained in:
thePR0M3TH3AN committed 2026-01-19 11:29:55 -05:00
1 parent 26486ca57a
commit d082df8e41
11 files changed
+1005 -3

No files matched your search

+238
View File
@@ -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;
+2
View File
@@ -163,6 +163,8 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
| Storage challenge (`NOTE_TYPES.STORAGE_CHALLENGE`) | `22242` | `['challenge', <hex>]`, `['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', <pubkey>]` tags for blocked/muted users | Optional content (often encrypted) |
| Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', <recipient pubkey>]` plus optional `['e', <event id>]`, `['a', <coordinate>]`, `['amount', <msats>]`, `['lnurl', <bech32>]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note |
| Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', <invoice>]`, `['description', <zap request JSON>]`, `['p', <recipient pubkey>]` plus optional `['e', <event id>]` and `['a', <coordinate>]` | 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`
+98
View File
@@ -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,
+200
View File
@@ -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,
});
+70 -2
View File
@@ -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: "[email protected]",
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: "[email protected]",
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,
}),
);
+182
View File
@@ -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();
+20 -1
View File
@@ -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(
+35
View File
@@ -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;
}
+100
View File
@@ -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;
}
+2
View File
@@ -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";
+58
View File
@@ -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;
}