mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add DM zap composer and zap request support
This commit is contained in:
1 parent
26486ca57a
commit
d082df8e41
11 files changed
+1005
-3
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user