mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-15 01:46:53 +02:00
Merge remote-tracking branch 'origin/main' into claude/add-donate-cta-VRkqO
This commit is contained in:
88
CLAUDE.md
88
CLAUDE.md
@@ -144,6 +144,94 @@ const text = getHighlightText(event);
|
||||
- ❌ Direct calls to applesauce helpers (they cache internally)
|
||||
- ❌ Grimoire helpers that wrap `getTagValue` (caching propagates)
|
||||
|
||||
## Major Hooks
|
||||
|
||||
Grimoire provides custom React hooks for common Nostr operations. All hooks handle cleanup automatically.
|
||||
|
||||
### Account & Authentication
|
||||
|
||||
**`useAccount()`** (`src/hooks/useAccount.ts`):
|
||||
- Access active account with signing capability detection
|
||||
- Returns: `{ account, pubkey, canSign, signer, isLoggedIn }`
|
||||
- **Critical**: Always check `canSign` before signing operations
|
||||
- Read-only accounts have `canSign: false` and no `signer`
|
||||
|
||||
```typescript
|
||||
const { canSign, signer, pubkey } = useAccount();
|
||||
if (canSign) {
|
||||
// Can sign and publish events
|
||||
await signer.signEvent(event);
|
||||
} else {
|
||||
// Show "log in to post" message
|
||||
}
|
||||
```
|
||||
|
||||
### Nostr Data Fetching
|
||||
|
||||
**`useProfile(pubkey, relayHints?)`** (`src/hooks/useProfile.ts`):
|
||||
- Fetch and cache user profile metadata (kind 0)
|
||||
- Loads from IndexedDB first (fast), then network
|
||||
- Uses AbortController to prevent race conditions
|
||||
- Returns: `ProfileContent | undefined`
|
||||
|
||||
**`useNostrEvent(pointer, context?)`** (`src/hooks/useNostrEvent.ts`):
|
||||
- Unified hook for fetching events by ID, EventPointer, or AddressPointer
|
||||
- Supports relay hints via context (pubkey string or full event)
|
||||
- Auto-loads missing events using smart relay selection
|
||||
- Returns: `NostrEvent | undefined`
|
||||
|
||||
**`useTimeline(id, filters, relays, options?)`** (`src/hooks/useTimeline.ts`):
|
||||
- Subscribe to timeline of events matching filters
|
||||
- Uses applesauce loaders for efficient caching
|
||||
- Returns: `{ events, loading, error }`
|
||||
- The `id` parameter is for caching (use stable string)
|
||||
|
||||
### Relay Management
|
||||
|
||||
**`useRelayState()`** (`src/hooks/useRelayState.ts`):
|
||||
- Access global relay state and auth management
|
||||
- Returns relay connection states, pending auth challenges, preferences
|
||||
- Methods: `authenticateRelay()`, `rejectAuth()`, `setAuthPreference()`
|
||||
- Automatically subscribes to relay state updates
|
||||
|
||||
**`useRelayInfo(relayUrl)`** (`src/hooks/useRelayInfo.ts`):
|
||||
- Fetch NIP-11 relay information document
|
||||
- Cached in IndexedDB with 24-hour TTL
|
||||
- Returns: `RelayInfo | undefined`
|
||||
|
||||
**`useOutboxRelays(pubkey)`** (`src/hooks/useOutboxRelays.ts`):
|
||||
- Get user's outbox relays from kind 10002 relay list
|
||||
- Cached via RelayListCache for performance
|
||||
- Returns: `string[] | undefined`
|
||||
|
||||
### Advanced Hooks
|
||||
|
||||
**`useReqTimelineEnhanced(filter, relays, options)`** (`src/hooks/useReqTimelineEnhanced.ts`):
|
||||
- Enhanced timeline with accurate state tracking
|
||||
- Tracks per-relay EOSE and connection state
|
||||
- Returns: `{ events, state, relayStates, stats }`
|
||||
- Use for REQ viewer and advanced timeline UIs
|
||||
|
||||
**`useNip05(nip05Address)`** (`src/hooks/useNip05.ts`):
|
||||
- Resolve NIP-05 identifier to pubkey
|
||||
- Cached with 1-hour TTL
|
||||
- Returns: `{ pubkey, relays, loading, error }`
|
||||
|
||||
**`useNip19Decode(nip19String)`** (`src/hooks/useNip19Decode.ts`):
|
||||
- Decode nprofile, nevent, naddr, note, npub strings
|
||||
- Returns: `{ type, data, error }`
|
||||
|
||||
### Utility Hooks
|
||||
|
||||
**`useStableValue(value)`** / **`useStableArray(array)`** (`src/hooks/useStable.ts`):
|
||||
- Prevent unnecessary re-renders from deep equality
|
||||
- Use for filters, options, relay arrays
|
||||
- Returns stable reference when deep-equal
|
||||
|
||||
**`useCopy()`** (`src/hooks/useCopy.ts`):
|
||||
- Copy text to clipboard with toast feedback
|
||||
- Returns: `{ copy, copied }` function and state
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- **Path Alias**: `@/` = `./src/`
|
||||
|
||||
@@ -414,6 +414,7 @@ const MessageItem = memo(function MessageItem({
|
||||
onReply={canReply && onReply ? () => onReply(message.id) : undefined}
|
||||
conversation={conversation}
|
||||
adapter={adapter}
|
||||
message={message}
|
||||
>
|
||||
{messageContent}
|
||||
</ChatMessageContextMenu>
|
||||
|
||||
@@ -23,9 +23,7 @@ import {
|
||||
import { getEventDisplayTitle } from "@/lib/event-title";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { getTagValues } from "@/lib/nostr-utils";
|
||||
import { getLiveHost } from "@/lib/live-activity";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { getZapSender } from "applesauce-common/helpers/zap";
|
||||
import { getSemanticAuthor } from "@/lib/semantic-author";
|
||||
// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon
|
||||
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
|
||||
import type { ChatProtocol, ProtocolIdentifier } from "@/types/chat";
|
||||
@@ -37,31 +35,6 @@ export interface WindowTitleData {
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the semantic author of an event based on kind-specific logic
|
||||
* Returns the pubkey that should be displayed as the "author" for UI purposes
|
||||
*
|
||||
* Examples:
|
||||
* - Zaps (9735): Returns the zapper (P tag), not the lightning service pubkey
|
||||
* - Live activities (30311): Returns the host (first p tag with "Host" role)
|
||||
* - Regular events: Returns event.pubkey
|
||||
*/
|
||||
function getSemanticAuthor(event: NostrEvent): string {
|
||||
switch (event.kind) {
|
||||
case 9735: {
|
||||
// Zap: show the zapper, not the lightning service pubkey
|
||||
const zapSender = getZapSender(event);
|
||||
return zapSender || event.pubkey;
|
||||
}
|
||||
case 30311: {
|
||||
// Live activity: show the host
|
||||
return getLiveHost(event);
|
||||
}
|
||||
default:
|
||||
return event.pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format profile names with prefix, handling $me and $contacts aliases
|
||||
* @param prefix - Prefix to use (e.g., 'by ', '@ ')
|
||||
@@ -474,8 +447,21 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
const countHashtags =
|
||||
appId === "count" && props.filter?.["#t"] ? props.filter["#t"] : [];
|
||||
|
||||
// Zap titles
|
||||
const zapRecipientPubkey = appId === "zap" ? props.recipientPubkey : null;
|
||||
// Zap titles - load event to derive recipient if needed
|
||||
const zapEventPointer: EventPointer | AddressPointer | undefined =
|
||||
appId === "zap" ? props.eventPointer : undefined;
|
||||
const zapEvent = useNostrEvent(zapEventPointer);
|
||||
|
||||
// Derive recipient: use explicit pubkey or semantic author from event
|
||||
const zapRecipientPubkey = useMemo(() => {
|
||||
if (appId !== "zap") return null;
|
||||
// If explicit recipient provided, use it
|
||||
if (props.recipientPubkey) return props.recipientPubkey;
|
||||
// Otherwise derive from event's semantic author
|
||||
if (zapEvent) return getSemanticAuthor(zapEvent);
|
||||
return null;
|
||||
}, [appId, props.recipientPubkey, zapEvent]);
|
||||
|
||||
const zapRecipientProfile = useProfile(zapRecipientPubkey || "");
|
||||
const zapTitle = useMemo(() => {
|
||||
if (appId !== "zap" || !zapRecipientPubkey) return null;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ChevronRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { useWallet } from "@/hooks/useWallet";
|
||||
@@ -52,7 +53,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import ConnectWalletDialog from "./ConnectWalletDialog";
|
||||
import { RelayLink } from "@/components/nostr/RelayLink";
|
||||
import { parseZapRequest } from "@/lib/wallet-utils";
|
||||
import { parseZapRequest, getInvoiceDescription } from "@/lib/wallet-utils";
|
||||
import { Zap } from "lucide-react";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { KindRenderer } from "./nostr/kinds";
|
||||
@@ -96,6 +97,62 @@ interface InvoiceDetails {
|
||||
const BATCH_SIZE = 20;
|
||||
const PAYMENT_CHECK_INTERVAL = 5000; // Check every 5 seconds
|
||||
|
||||
/**
|
||||
* Helper: Detect if a transaction is a Bitcoin on-chain transaction
|
||||
* Bitcoin transactions have invoice field containing a Bitcoin address instead of a Lightning invoice
|
||||
* Bitcoin address formats:
|
||||
* - Legacy (P2PKH): starts with 1
|
||||
* - P2SH: starts with 3
|
||||
* - Bech32 (native segwit): starts with bc1
|
||||
* - Bech32m (taproot): starts with bc1p
|
||||
*/
|
||||
function isBitcoinTransaction(transaction: Transaction): boolean {
|
||||
if (!transaction.invoice) return false;
|
||||
|
||||
const invoice = transaction.invoice.trim();
|
||||
|
||||
// Lightning invoices start with "ln" (lnbc, lntb, lnbcrt, etc.)
|
||||
if (invoice.toLowerCase().startsWith("ln")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it looks like a Bitcoin address
|
||||
// Legacy: 1... (26-35 chars)
|
||||
// P2SH: 3... (26-35 chars)
|
||||
// Bech32: bc1... (42-62 chars for bc1q, 62 chars for bc1p)
|
||||
// Testnet: tb1..., 2..., m/n...
|
||||
const isBitcoinAddress =
|
||||
/^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(invoice) || // Legacy or P2SH
|
||||
/^bc1[a-z0-9]{39,59}$/i.test(invoice) || // Mainnet bech32/bech32m
|
||||
/^tb1[a-z0-9]{39,59}$/i.test(invoice) || // Testnet bech32
|
||||
/^[2mn][a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(invoice); // Testnet legacy
|
||||
|
||||
return isBitcoinAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Extract txid from preimage field
|
||||
* Bitcoin preimage format: "txid" or "txid:outputIndex"
|
||||
* We only need the txid part for mempool.space
|
||||
*/
|
||||
function extractTxid(preimage: string): string {
|
||||
// Remove output index if present (e.g., "txid:0" -> "txid")
|
||||
return preimage.split(":")[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get mempool.space URL for a Bitcoin transaction
|
||||
*/
|
||||
function getMempoolUrl(txid: string, network?: string): string {
|
||||
const baseUrl =
|
||||
network === "testnet"
|
||||
? "https://mempool.space/testnet"
|
||||
: network === "signet"
|
||||
? "https://mempool.space/signet"
|
||||
: "https://mempool.space";
|
||||
return `${baseUrl}/tx/${txid}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Format timestamp as a readable day marker
|
||||
*/
|
||||
@@ -312,14 +369,14 @@ function ZapTransactionDetail({ transaction }: { transaction: Transaction }) {
|
||||
function TransactionLabel({ transaction }: { transaction: Transaction }) {
|
||||
const zapInfo = parseZapRequest(transaction);
|
||||
|
||||
// Not a zap - use original description or default label
|
||||
// Not a zap - use original description, invoice description, or default label
|
||||
if (!zapInfo) {
|
||||
return (
|
||||
<span className="text-sm truncate">
|
||||
{transaction.description ||
|
||||
(transaction.type === "incoming" ? "Received" : "Payment")}
|
||||
</span>
|
||||
);
|
||||
const description =
|
||||
transaction.description ||
|
||||
getInvoiceDescription(transaction) ||
|
||||
(transaction.type === "incoming" ? "Received" : "Payment");
|
||||
|
||||
return <span className="text-sm truncate">{description}</span>;
|
||||
}
|
||||
|
||||
// It's a zap! Show username + message on one line
|
||||
@@ -1104,93 +1161,99 @@ export default function WalletViewer() {
|
||||
)}
|
||||
|
||||
{/* Transaction History */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{walletInfo?.methods.includes("list_transactions") ? (
|
||||
loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<RefreshCw className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : txLoadFailed ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Failed to load transaction history
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={reloadTransactions}>
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : transactionsWithMarkers.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No transactions found
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Virtuoso
|
||||
data={transactionsWithMarkers}
|
||||
endReached={loadMoreTransactions}
|
||||
itemContent={(index, item) => {
|
||||
if (item.type === "day-marker") {
|
||||
<div className="flex-1 overflow-hidden flex justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
{walletInfo?.methods.includes("list_transactions") ? (
|
||||
loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<RefreshCw className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : txLoadFailed ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Failed to load transaction history
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={reloadTransactions}
|
||||
>
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : transactionsWithMarkers.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No transactions found
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Virtuoso
|
||||
data={transactionsWithMarkers}
|
||||
endReached={loadMoreTransactions}
|
||||
itemContent={(index, item) => {
|
||||
if (item.type === "day-marker") {
|
||||
return (
|
||||
<div
|
||||
className="flex justify-center py-2"
|
||||
key={`marker-${item.timestamp}`}
|
||||
>
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
{item.data}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tx = item.data;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-center py-2"
|
||||
key={`marker-${item.timestamp}`}
|
||||
key={tx.payment_hash || index}
|
||||
className="flex items-center justify-between border-b border-border px-4 py-2.5 hover:bg-muted/50 transition-colors flex-shrink-0 cursor-pointer"
|
||||
onClick={() => handleTransactionClick(tx)}
|
||||
>
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
{item.data}
|
||||
</Label>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{tx.type === "incoming" ? (
|
||||
<ArrowDownLeft className="size-4 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<ArrowUpRight className="size-4 text-red-500 flex-shrink-0" />
|
||||
)}
|
||||
<TransactionLabel transaction={tx} />
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
<p className="text-sm font-semibold font-mono">
|
||||
{state.walletBalancesBlurred
|
||||
? "✦✦✦✦"
|
||||
: formatSats(tx.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tx = item.data;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tx.payment_hash || index}
|
||||
className="flex items-center justify-between border-b border-border px-4 py-2.5 hover:bg-muted/50 transition-colors flex-shrink-0 cursor-pointer"
|
||||
onClick={() => handleTransactionClick(tx)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{tx.type === "incoming" ? (
|
||||
<ArrowDownLeft className="size-4 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<ArrowUpRight className="size-4 text-red-500 flex-shrink-0" />
|
||||
)}
|
||||
<TransactionLabel transaction={tx} />
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
<p className="text-sm font-semibold font-mono">
|
||||
{state.walletBalancesBlurred
|
||||
? "✦✦✦✦"
|
||||
: formatSats(tx.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
components={{
|
||||
Footer: () =>
|
||||
loadingMore ? (
|
||||
<div className="flex justify-center py-4 border-b border-border">
|
||||
<RefreshCw className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !hasMore && transactions.length > 0 ? (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground border-b border-border">
|
||||
No more transactions
|
||||
</div>
|
||||
) : null,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Transaction history not available
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
}}
|
||||
components={{
|
||||
Footer: () =>
|
||||
loadingMore ? (
|
||||
<div className="flex justify-center py-4 border-b border-border">
|
||||
<RefreshCw className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !hasMore && transactions.length > 0 ? (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground border-b border-border">
|
||||
No more transactions
|
||||
</div>
|
||||
) : null,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Transaction history not available
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disconnect Confirmation Dialog */}
|
||||
@@ -1231,7 +1294,7 @@ export default function WalletViewer() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[70vh] flex flex-col">
|
||||
<DialogContent className="max-w-md max-h-[70vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Transaction Details</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -1260,17 +1323,24 @@ export default function WalletViewer() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{selectedTransaction.description &&
|
||||
!parseZapRequest(selectedTransaction) && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Description
|
||||
</Label>
|
||||
<p className="text-sm">
|
||||
{selectedTransaction.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const description =
|
||||
selectedTransaction.description ||
|
||||
getInvoiceDescription(selectedTransaction);
|
||||
const isZap = parseZapRequest(selectedTransaction);
|
||||
|
||||
return (
|
||||
description &&
|
||||
!isZap && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Description
|
||||
</Label>
|
||||
<p className="text-sm">{description}</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
@@ -1295,27 +1365,68 @@ export default function WalletViewer() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTransaction.payment_hash && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Payment Hash
|
||||
</Label>
|
||||
<p className="text-xs font-mono break-all bg-muted p-2 rounded">
|
||||
{selectedTransaction.payment_hash}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const isBitcoin = isBitcoinTransaction(selectedTransaction);
|
||||
|
||||
{selectedTransaction.preimage && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Preimage
|
||||
</Label>
|
||||
<p className="text-xs font-mono break-all bg-muted p-2 rounded">
|
||||
{selectedTransaction.preimage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
if (isBitcoin) {
|
||||
// Bitcoin on-chain transaction - show Transaction ID with mempool.space link
|
||||
// For Bitcoin txs, preimage contains the txid (possibly with :outputIndex)
|
||||
if (!selectedTransaction.preimage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const txid = extractTxid(selectedTransaction.preimage);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Transaction ID
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-mono break-all bg-muted p-2 rounded flex-1">
|
||||
{txid}
|
||||
</p>
|
||||
<a
|
||||
href={getMempoolUrl(txid, walletInfo?.network)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary/80 transition-colors flex-shrink-0"
|
||||
title="View on mempool.space"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Lightning transaction - show payment hash and preimage
|
||||
return (
|
||||
<>
|
||||
{selectedTransaction.payment_hash && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Payment Hash
|
||||
</Label>
|
||||
<p className="text-xs font-mono break-all bg-muted p-2 rounded">
|
||||
{selectedTransaction.payment_hash}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTransaction.preimage && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Preimage
|
||||
</Label>
|
||||
<p className="text-xs font-mono break-all bg-muted p-2 rounded">
|
||||
{selectedTransaction.preimage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Zap Details (if this is a zap payment) */}
|
||||
|
||||
@@ -234,6 +234,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
<ZapWindow
|
||||
recipientPubkey={window.props.recipientPubkey}
|
||||
eventPointer={window.props.eventPointer}
|
||||
addressPointer={window.props.addressPointer}
|
||||
customTags={window.props.customTags}
|
||||
relays={window.props.relays}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -21,10 +21,14 @@ import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
LogIn,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { PrivateKeySigner } from "applesauce-signers";
|
||||
import { generateSecretKey } from "nostr-tools";
|
||||
import QRCode from "qrcode";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
@@ -49,14 +53,24 @@ import {
|
||||
} from "@/lib/create-zap-request";
|
||||
import { fetchInvoiceFromCallback } from "@/lib/lnurl";
|
||||
import { useLnurlCache } from "@/hooks/useLnurlCache";
|
||||
import { getSemanticAuthor } from "@/lib/semantic-author";
|
||||
|
||||
export interface ZapWindowProps {
|
||||
/** Recipient pubkey (who receives the zap) */
|
||||
recipientPubkey: string;
|
||||
/** Optional event being zapped (adds context) */
|
||||
eventPointer?: EventPointer | AddressPointer;
|
||||
/** Optional event being zapped (adds e-tag for context) */
|
||||
eventPointer?: EventPointer;
|
||||
/** Optional addressable event context (adds a-tag, e.g., live activity) */
|
||||
addressPointer?: AddressPointer;
|
||||
/** Callback to close the window */
|
||||
onClose?: () => void;
|
||||
/**
|
||||
* Custom tags to include in the zap request
|
||||
* Used for protocol-specific tagging like NIP-53 live activity references
|
||||
*/
|
||||
customTags?: string[][];
|
||||
/** Relays where the zap receipt should be published */
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
// Default preset amounts in sats
|
||||
@@ -82,24 +96,21 @@ function formatAmount(amount: number): string {
|
||||
export function ZapWindow({
|
||||
recipientPubkey: initialRecipientPubkey,
|
||||
eventPointer,
|
||||
addressPointer,
|
||||
onClose,
|
||||
customTags,
|
||||
relays: propsRelays,
|
||||
}: ZapWindowProps) {
|
||||
// Load event if we have a pointer and no recipient pubkey (derive from event author)
|
||||
// Load event if we have an eventPointer and no recipient pubkey (derive from event author)
|
||||
const event = use$(() => {
|
||||
if (!eventPointer) return undefined;
|
||||
if ("id" in eventPointer) {
|
||||
return eventStore.event(eventPointer.id);
|
||||
}
|
||||
// AddressPointer
|
||||
return eventStore.replaceable(
|
||||
eventPointer.kind,
|
||||
eventPointer.pubkey,
|
||||
eventPointer.identifier,
|
||||
);
|
||||
return eventStore.event(eventPointer.id);
|
||||
}, [eventPointer]);
|
||||
|
||||
// Resolve recipient: use provided pubkey or derive from event author
|
||||
const recipientPubkey = initialRecipientPubkey || event?.pubkey || "";
|
||||
// Resolve recipient: use provided pubkey or derive from semantic author
|
||||
// For zaps, this returns the zapper; for streams, returns the host; etc.
|
||||
const recipientPubkey =
|
||||
initialRecipientPubkey || (event ? getSemanticAuthor(event) : "");
|
||||
|
||||
const recipientProfile = useProfile(recipientPubkey);
|
||||
|
||||
@@ -131,6 +142,7 @@ export function ZapWindow({
|
||||
const [showQrDialog, setShowQrDialog] = useState(false);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [paymentTimedOut, setPaymentTimedOut] = useState(false);
|
||||
const [zapAnonymously, setZapAnonymously] = useState(false);
|
||||
|
||||
// Editor ref and search functions
|
||||
const editorRef = useRef<MentionEditorHandle>(null);
|
||||
@@ -349,13 +361,24 @@ export function ZapWindow({
|
||||
}
|
||||
|
||||
// Step 3: Create and sign zap request event (kind 9734)
|
||||
// If zapping anonymously, create a throwaway signer
|
||||
let anonymousSigner;
|
||||
if (zapAnonymously) {
|
||||
const throwawayKey = generateSecretKey();
|
||||
anonymousSigner = new PrivateKeySigner(throwawayKey);
|
||||
}
|
||||
|
||||
const zapRequest = await createZapRequest({
|
||||
recipientPubkey,
|
||||
amountMillisats,
|
||||
comment,
|
||||
eventPointer,
|
||||
addressPointer,
|
||||
relays: propsRelays,
|
||||
lnurl: lud16 || undefined,
|
||||
emojiTags,
|
||||
customTags,
|
||||
signer: anonymousSigner,
|
||||
});
|
||||
|
||||
const serializedZapRequest = serializeZapRequest(zapRequest);
|
||||
@@ -647,6 +670,26 @@ export function ZapWindow({
|
||||
className="rounded-md border border-input bg-background px-3 py-1 text-base md:text-sm min-h-9"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Anonymous zap checkbox */}
|
||||
{hasLightningAddress && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="zap-anonymously"
|
||||
checked={zapAnonymously}
|
||||
onCheckedChange={(checked) =>
|
||||
setZapAnonymously(checked === true)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="zap-anonymously"
|
||||
className="text-sm text-muted-foreground cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<EyeOff className="size-3.5" />
|
||||
Zap anonymously
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No Lightning Address Warning */}
|
||||
@@ -657,7 +700,7 @@ export function ZapWindow({
|
||||
)}
|
||||
|
||||
{/* Payment Button */}
|
||||
{!canSign ? (
|
||||
{!canSign && !zapAnonymously ? (
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
className="w-full"
|
||||
@@ -703,6 +746,12 @@ export function ZapWindow({
|
||||
Pay with Wallet (
|
||||
{selectedAmount || parseInt(customAmount) || 0} sats)
|
||||
</>
|
||||
) : zapAnonymously ? (
|
||||
<>
|
||||
<EyeOff className="size-4 mr-2" />
|
||||
Zap Anonymously (
|
||||
{selectedAmount || parseInt(customAmount) || 0} sats)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="size-4 mr-2" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
import type { Conversation } from "@/types/chat";
|
||||
import type { Conversation, Message } from "@/types/chat";
|
||||
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Reply,
|
||||
MessageSquare,
|
||||
Smile,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
@@ -37,6 +38,8 @@ interface ChatMessageContextMenuProps {
|
||||
onReply?: () => void;
|
||||
conversation?: Conversation;
|
||||
adapter?: ChatProtocolAdapter;
|
||||
/** Message object for protocol-specific actions like zapping */
|
||||
message?: Message;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +57,7 @@ export function ChatMessageContextMenu({
|
||||
onReply,
|
||||
conversation,
|
||||
adapter,
|
||||
message,
|
||||
}: ChatMessageContextMenuProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const { copy, copied } = useCopy();
|
||||
@@ -63,6 +67,12 @@ export function ChatMessageContextMenu({
|
||||
// Extract context emojis from the conversation
|
||||
const contextEmojis = getEmojiTags(event);
|
||||
|
||||
// Get zap configuration from adapter
|
||||
const zapConfig = useMemo(() => {
|
||||
if (!adapter || !message || !conversation) return null;
|
||||
return adapter.getZapConfig(message, conversation);
|
||||
}, [adapter, message, conversation]);
|
||||
|
||||
const openEventDetail = () => {
|
||||
let pointer;
|
||||
// For replaceable/parameterized replaceable events, use AddressPointer
|
||||
@@ -138,6 +148,18 @@ export function ChatMessageContextMenu({
|
||||
}
|
||||
};
|
||||
|
||||
const openZapWindow = () => {
|
||||
if (!zapConfig || !zapConfig.supported) return;
|
||||
|
||||
addWindow("zap", {
|
||||
recipientPubkey: zapConfig.recipientPubkey,
|
||||
eventPointer: zapConfig.eventPointer,
|
||||
addressPointer: zapConfig.addressPointer,
|
||||
customTags: zapConfig.customTags,
|
||||
relays: zapConfig.relays,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
@@ -170,6 +192,12 @@ export function ChatMessageContextMenu({
|
||||
<Smile className="size-4 mr-2" />
|
||||
React
|
||||
</ContextMenuItem>
|
||||
{zapConfig?.supported && (
|
||||
<ContextMenuItem onClick={openZapWindow}>
|
||||
<Zap className="size-4 mr-2" />
|
||||
Zap
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getZapEventPointer,
|
||||
getZapAddressPointer,
|
||||
getZapRequest,
|
||||
getZapRecipient,
|
||||
} from "applesauce-common/helpers/zap";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { UserName } from "../UserName";
|
||||
@@ -13,17 +14,12 @@ import { RichText } from "../RichText";
|
||||
|
||||
/**
|
||||
* Compact preview for Kind 9735 (Zap Receipt)
|
||||
* Layout: [amount] [zap message] [target pubkey] [preview]
|
||||
* Layout: [amount] [recipient] [zap message] [preview]
|
||||
*/
|
||||
export function ZapCompactPreview({ event }: { event: NostrEvent }) {
|
||||
const zapAmount = useMemo(() => getZapAmount(event), [event]);
|
||||
const zapRequest = useMemo(() => getZapRequest(event), [event]);
|
||||
|
||||
// Get zap comment from request
|
||||
const zapMessage = useMemo(() => {
|
||||
if (!zapRequest) return null;
|
||||
return zapRequest.content || null;
|
||||
}, [zapRequest]);
|
||||
const zapRecipient = useMemo(() => getZapRecipient(event), [event]);
|
||||
|
||||
// Get zapped content pointers
|
||||
const eventPointer = useMemo(() => getZapEventPointer(event), [event]);
|
||||
@@ -46,26 +42,24 @@ export function ZapCompactPreview({ event }: { event: NostrEvent }) {
|
||||
<span className="text-yellow-500 font-medium shrink-0">
|
||||
{amountInSats.toLocaleString("en", { notation: "compact" })}
|
||||
</span>
|
||||
{zapMessage && (
|
||||
{zapRecipient && <UserName pubkey={zapRecipient} />}
|
||||
{zapRequest?.content && (
|
||||
<span className="truncate line-clamp-1 flex-shrink-0">
|
||||
<RichText
|
||||
content={zapMessage}
|
||||
event={zapRequest}
|
||||
className="inline text-sm leading-none"
|
||||
options={{ showMedia: false, showEventEmbeds: false }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{zappedEvent && (
|
||||
<>
|
||||
<UserName pubkey={zappedEvent.pubkey} />
|
||||
<span className="text-muted-foreground truncate line-clamp-1">
|
||||
<RichText
|
||||
event={zappedEvent}
|
||||
className="inline text-sm leading-none"
|
||||
options={{ showMedia: false, showEventEmbeds: false }}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
<span className="text-muted-foreground truncate line-clamp-1">
|
||||
<RichText
|
||||
event={zappedEvent}
|
||||
className="inline text-sm leading-none"
|
||||
options={{ showMedia: false, showEventEmbeds: false }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getSeenRelays } from "applesauce-core/helpers/relays";
|
||||
import { EventFooter } from "@/components/EventFooter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isAddressableKind } from "@/lib/nostr-kinds";
|
||||
import { getSemanticAuthor } from "@/lib/semantic-author";
|
||||
|
||||
/**
|
||||
* Universal event properties and utilities shared across all kind renderers
|
||||
@@ -173,9 +174,12 @@ export function EventMenu({ event }: { event: NostrEvent }) {
|
||||
};
|
||||
}
|
||||
|
||||
// Get semantic author (e.g., zapper for zaps, host for streams)
|
||||
const recipientPubkey = getSemanticAuthor(event);
|
||||
|
||||
// Open zap window with event context
|
||||
addWindow("zap", {
|
||||
recipientPubkey: event.pubkey,
|
||||
recipientPubkey,
|
||||
eventPointer,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
getZapEventPointer,
|
||||
getZapAddressPointer,
|
||||
getZapSender,
|
||||
getZapRecipient,
|
||||
isValidZap,
|
||||
} from "applesauce-common/helpers/zap";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { KindRenderer } from "./index";
|
||||
import { RichText } from "../RichText";
|
||||
import { EventCardSkeleton } from "@/components/ui/skeleton";
|
||||
import { UserName } from "../UserName";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 9735 - Zap Receipts
|
||||
@@ -25,6 +27,7 @@ export function Kind9735Renderer({ event }: BaseEventProps) {
|
||||
|
||||
// Get zap details using applesauce helpers
|
||||
const zapSender = useMemo(() => getZapSender(event), [event]);
|
||||
const zapRecipient = useMemo(() => getZapRecipient(event), [event]);
|
||||
const zapAmount = useMemo(() => getZapAmount(event), [event]);
|
||||
const zapRequest = useMemo(() => getZapRequest(event), [event]);
|
||||
|
||||
@@ -36,12 +39,6 @@ export function Kind9735Renderer({ event }: BaseEventProps) {
|
||||
const zappedEvent = useNostrEvent(eventPointer || undefined);
|
||||
const zappedAddress = useNostrEvent(addressPointer || undefined);
|
||||
|
||||
// Get zap comment from request
|
||||
const zapComment = useMemo(() => {
|
||||
if (!zapRequest) return null;
|
||||
return zapRequest.content || null;
|
||||
}, [zapRequest]);
|
||||
|
||||
// Format amount (convert from msats to sats)
|
||||
const amountInSats = useMemo(() => {
|
||||
if (!zapAmount) return 0;
|
||||
@@ -73,12 +70,13 @@ export function Kind9735Renderer({ event }: BaseEventProps) {
|
||||
})}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">sats</span>
|
||||
{zapRecipient && <UserName pubkey={zapRecipient} />}
|
||||
</div>
|
||||
|
||||
{/* Zap comment */}
|
||||
{zapComment && (
|
||||
{zapRequest && zapRequest.content && (
|
||||
<div className="text-sm">
|
||||
<RichText content={zapComment} />
|
||||
<RichText event={zapRequest} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,6 +2,19 @@ import { useMemo } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import accounts from "@/services/accounts";
|
||||
|
||||
/**
|
||||
* Check if an account can sign events
|
||||
* Read-only accounts cannot sign and should not be prompted for auth
|
||||
*
|
||||
* @param account - The account to check (can be undefined)
|
||||
* @returns true if the account can sign, false otherwise
|
||||
*/
|
||||
export function canAccountSign(account: typeof accounts.active): boolean {
|
||||
if (!account) return false;
|
||||
const accountType = account.constructor.name;
|
||||
return accountType !== "ReadonlyAccount";
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the active account with signing capability detection
|
||||
*
|
||||
@@ -45,18 +58,8 @@ export function useAccount() {
|
||||
|
||||
// Check if the account has a functional signer
|
||||
// Read-only accounts have a signer that throws errors on sign operations
|
||||
// We detect this by checking for the ReadonlySigner type or checking signer methods
|
||||
const signer = account.signer;
|
||||
let canSign = false;
|
||||
|
||||
if (signer) {
|
||||
// ReadonlyAccount from applesauce-accounts has a ReadonlySigner
|
||||
// that throws on signEvent, nip04, nip44 operations
|
||||
// We can detect it by checking if it's an instance with the expected methods
|
||||
// but we'll use a safer approach: check the account type name
|
||||
const accountType = account.constructor.name;
|
||||
canSign = accountType !== "ReadonlyAccount";
|
||||
}
|
||||
const canSign = canAccountSign(account);
|
||||
|
||||
return {
|
||||
account,
|
||||
|
||||
@@ -17,6 +17,36 @@ import type {
|
||||
GetActionsOptions,
|
||||
} from "@/types/chat-actions";
|
||||
|
||||
/**
|
||||
* Zap configuration for chat messages
|
||||
* Defines how zap requests should be constructed for protocol-specific tagging
|
||||
*/
|
||||
export interface ZapConfig {
|
||||
/** Whether zapping is supported for this message/conversation */
|
||||
supported: boolean;
|
||||
/** Reason why zapping is not supported (if supported=false) */
|
||||
unsupportedReason?: string;
|
||||
/** Recipient pubkey (who receives the sats) */
|
||||
recipientPubkey: string;
|
||||
/** Event being zapped for e-tag (e.g., chat message) */
|
||||
eventPointer?: {
|
||||
id: string;
|
||||
author?: string;
|
||||
relays?: string[];
|
||||
};
|
||||
/** Addressable event context for a-tag (e.g., live activity) */
|
||||
addressPointer?: {
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
relays?: string[];
|
||||
};
|
||||
/** Custom tags to include in the zap request (beyond standard p/amount/relays) */
|
||||
customTags?: string[][];
|
||||
/** Relays where the zap receipt should be published */
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Blob attachment metadata for imeta tags (NIP-92)
|
||||
*/
|
||||
@@ -180,6 +210,26 @@ export abstract class ChatProtocolAdapter {
|
||||
*/
|
||||
leaveConversation?(conversation: Conversation): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get zap configuration for a message
|
||||
* Returns configuration for how zap requests should be constructed,
|
||||
* including protocol-specific tagging (e.g., a-tag for live activities)
|
||||
*
|
||||
* Default implementation returns unsupported.
|
||||
* Override in adapters that support zapping.
|
||||
*
|
||||
* @param message - The message being zapped
|
||||
* @param conversation - The conversation context
|
||||
* @returns ZapConfig with supported=true and tagging info, or supported=false with reason
|
||||
*/
|
||||
getZapConfig(_message: Message, _conversation: Conversation): ZapConfig {
|
||||
return {
|
||||
supported: false,
|
||||
unsupportedReason: "Zaps are not supported for this protocol",
|
||||
recipientPubkey: "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available actions for this protocol
|
||||
* Actions are protocol-specific slash commands like /join, /leave, etc.
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Observable, firstValueFrom } from "rxjs";
|
||||
import { map, first, toArray } from "rxjs/operators";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
|
||||
import {
|
||||
ChatProtocolAdapter,
|
||||
type SendMessageOptions,
|
||||
type ZapConfig,
|
||||
} from "./base-adapter";
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
@@ -214,6 +218,7 @@ export class Nip53Adapter extends ChatProtocolAdapter {
|
||||
totalParticipants: activity.totalParticipants,
|
||||
hashtags: activity.hashtags,
|
||||
relays: chatRelays,
|
||||
goal: activity.goal,
|
||||
},
|
||||
},
|
||||
unreadCount: 0,
|
||||
@@ -549,6 +554,66 @@ export class Nip53Adapter extends ChatProtocolAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get zap configuration for a message in a live activity
|
||||
*
|
||||
* NIP-53 zap tagging rules:
|
||||
* - p-tag: message author (recipient)
|
||||
* - e-tag: message event being zapped
|
||||
* - a-tag: live activity context
|
||||
*/
|
||||
getZapConfig(message: Message, conversation: Conversation): ZapConfig {
|
||||
const activityAddress = conversation.metadata?.activityAddress;
|
||||
const liveActivity = conversation.metadata?.liveActivity as
|
||||
| {
|
||||
relays?: string[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!activityAddress) {
|
||||
return {
|
||||
supported: false,
|
||||
unsupportedReason: "Missing activity address",
|
||||
recipientPubkey: "",
|
||||
};
|
||||
}
|
||||
|
||||
const { pubkey: activityPubkey, identifier } = activityAddress;
|
||||
|
||||
// Get relays
|
||||
const relays =
|
||||
liveActivity?.relays && liveActivity.relays.length > 0
|
||||
? liveActivity.relays
|
||||
: conversation.metadata?.relayUrl
|
||||
? [conversation.metadata.relayUrl]
|
||||
: [];
|
||||
|
||||
// Build eventPointer for the message being zapped (e-tag)
|
||||
const eventPointer = {
|
||||
id: message.id,
|
||||
author: message.author,
|
||||
relays,
|
||||
};
|
||||
|
||||
// Build addressPointer for the live activity (a-tag)
|
||||
const addressPointer = {
|
||||
kind: 30311,
|
||||
pubkey: activityPubkey,
|
||||
identifier,
|
||||
relays,
|
||||
};
|
||||
|
||||
// Don't pass top-level relays - let createZapRequest collect outbox relays
|
||||
// from both eventPointer.author (recipient) and addressPointer.pubkey (stream host)
|
||||
// The relay hints in the pointers will also be included
|
||||
return {
|
||||
supported: true,
|
||||
recipientPubkey: message.author,
|
||||
eventPointer,
|
||||
addressPointer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a replied-to message
|
||||
* First checks EventStore, then fetches from relays if needed
|
||||
|
||||
@@ -96,6 +96,74 @@ export function reconstructCommand(window: WindowInstance): string {
|
||||
case "debug":
|
||||
return "debug";
|
||||
|
||||
case "zap": {
|
||||
// Reconstruct zap command from props
|
||||
const parts: string[] = ["zap"];
|
||||
|
||||
// Add recipient pubkey (encode as npub for readability)
|
||||
if (props.recipientPubkey) {
|
||||
try {
|
||||
const npub = nip19.npubEncode(props.recipientPubkey);
|
||||
parts.push(npub);
|
||||
} catch {
|
||||
parts.push(props.recipientPubkey);
|
||||
}
|
||||
}
|
||||
|
||||
// Add event pointer if present (e-tag context)
|
||||
if (props.eventPointer) {
|
||||
const pointer = props.eventPointer;
|
||||
try {
|
||||
const nevent = nip19.neventEncode({
|
||||
id: pointer.id,
|
||||
relays: pointer.relays,
|
||||
author: pointer.author,
|
||||
kind: pointer.kind,
|
||||
});
|
||||
parts.push(nevent);
|
||||
} catch {
|
||||
// Fallback to raw ID
|
||||
parts.push(pointer.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add address pointer if present (a-tag context, e.g., live activity)
|
||||
if (props.addressPointer) {
|
||||
const pointer = props.addressPointer;
|
||||
// Use -T a to add the a-tag as coordinate
|
||||
parts.push(
|
||||
"-T",
|
||||
"a",
|
||||
`${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`,
|
||||
);
|
||||
if (pointer.relays?.[0]) {
|
||||
parts.push(pointer.relays[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom tags
|
||||
if (props.customTags && props.customTags.length > 0) {
|
||||
for (const tag of props.customTags) {
|
||||
if (tag.length >= 2) {
|
||||
parts.push("-T", tag[0], tag[1]);
|
||||
// Add relay hint if present
|
||||
if (tag[2]) {
|
||||
parts.push(tag[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add relays
|
||||
if (props.relays && props.relays.length > 0) {
|
||||
for (const relay of props.relays) {
|
||||
parts.push("-r", relay);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
case "chat": {
|
||||
// Reconstruct chat command from protocol and identifier
|
||||
const { protocol, identifier } = props;
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
*/
|
||||
|
||||
import { EventFactory } from "applesauce-core/event-factory";
|
||||
import type { ISigner } from "applesauce-signers";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { EventPointer, AddressPointer } from "./open-parser";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { relayListCache } from "@/services/relay-list-cache";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
import { selectZapRelays } from "./zap-relay-selection";
|
||||
|
||||
export interface EmojiTag {
|
||||
shortcode: string;
|
||||
@@ -21,42 +21,65 @@ export interface ZapRequestParams {
|
||||
amountMillisats: number;
|
||||
/** Optional comment/message */
|
||||
comment?: string;
|
||||
/** Optional event being zapped */
|
||||
eventPointer?: EventPointer | AddressPointer;
|
||||
/** Optional event being zapped (adds e-tag) */
|
||||
eventPointer?: EventPointer;
|
||||
/** Optional addressable event context (adds a-tag, e.g., live activity) */
|
||||
addressPointer?: AddressPointer;
|
||||
/** Relays where zap receipt should be published */
|
||||
relays?: string[];
|
||||
/** LNURL for the recipient */
|
||||
lnurl?: string;
|
||||
/** NIP-30 custom emoji tags */
|
||||
emojiTags?: EmojiTag[];
|
||||
/**
|
||||
* Custom tags to include in the zap request (beyond standard p/amount/relays)
|
||||
* Used for additional protocol-specific tagging
|
||||
*/
|
||||
customTags?: string[][];
|
||||
/** Optional signer for anonymous zaps (overrides account signer) */
|
||||
signer?: ISigner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and sign a zap request event (kind 9734)
|
||||
* This event is NOT published to relays - it's sent to the LNURL callback
|
||||
*
|
||||
* @param params.signer - Optional signer for anonymous zaps. When provided,
|
||||
* uses this signer instead of the active account's signer.
|
||||
*/
|
||||
export async function createZapRequest(
|
||||
params: ZapRequestParams,
|
||||
): Promise<NostrEvent> {
|
||||
const account = accountManager.active;
|
||||
// Use provided signer (for anonymous zaps) or fall back to account signer
|
||||
let signer = params.signer;
|
||||
let senderPubkey: string | undefined;
|
||||
|
||||
if (!account) {
|
||||
throw new Error("No active account. Please log in to send zaps.");
|
||||
}
|
||||
if (signer) {
|
||||
// Anonymous zap - use provided signer
|
||||
senderPubkey = await signer.getPublicKey();
|
||||
} else {
|
||||
// Normal zap - use account signer
|
||||
const account = accountManager.active;
|
||||
|
||||
const signer = account.signer;
|
||||
if (!signer) {
|
||||
throw new Error("No signer available for active account");
|
||||
if (!account) {
|
||||
throw new Error("No active account. Please log in to send zaps.");
|
||||
}
|
||||
|
||||
signer = account.signer;
|
||||
if (!signer) {
|
||||
throw new Error("No signer available for active account");
|
||||
}
|
||||
senderPubkey = account.pubkey;
|
||||
}
|
||||
|
||||
// Get relays for zap receipt publication
|
||||
let relays = params.relays;
|
||||
if (!relays || relays.length === 0) {
|
||||
// Use sender's read relays (where they want to receive zap receipts)
|
||||
const senderReadRelays =
|
||||
(await relayListCache.getInboxRelays(account.pubkey)) || [];
|
||||
relays = senderReadRelays.length > 0 ? senderReadRelays : AGGREGATOR_RELAYS;
|
||||
}
|
||||
// Priority: explicit relays > recipient's inbox > sender's inbox > fallback aggregators
|
||||
const zapRelayResult = await selectZapRelays({
|
||||
recipientPubkey: params.recipientPubkey,
|
||||
senderPubkey,
|
||||
explicitRelays: params.relays,
|
||||
});
|
||||
const relays = zapRelayResult.relays;
|
||||
|
||||
// Build tags
|
||||
const tags: string[][] = [
|
||||
@@ -70,27 +93,31 @@ export async function createZapRequest(
|
||||
tags.push(["lnurl", params.lnurl]);
|
||||
}
|
||||
|
||||
// Add event reference if zapping an event
|
||||
// Add event reference if zapping an event (e-tag)
|
||||
if (params.eventPointer) {
|
||||
if ("id" in params.eventPointer) {
|
||||
// Regular event (e tag)
|
||||
tags.push(["e", params.eventPointer.id]);
|
||||
// Include author if available
|
||||
if (params.eventPointer.author) {
|
||||
tags.push(["p", params.eventPointer.author]);
|
||||
}
|
||||
// Include relay hints
|
||||
if (params.eventPointer.relays && params.eventPointer.relays.length > 0) {
|
||||
tags.push(["e", params.eventPointer.id, params.eventPointer.relays[0]]);
|
||||
}
|
||||
const relayHint = params.eventPointer.relays?.[0] || "";
|
||||
if (relayHint) {
|
||||
tags.push(["e", params.eventPointer.id, relayHint]);
|
||||
} else {
|
||||
tags.push(["e", params.eventPointer.id]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add addressable event reference (a-tag) - for NIP-53 live activities, etc.
|
||||
if (params.addressPointer) {
|
||||
const coordinate = `${params.addressPointer.kind}:${params.addressPointer.pubkey}:${params.addressPointer.identifier}`;
|
||||
const relayHint = params.addressPointer.relays?.[0] || "";
|
||||
if (relayHint) {
|
||||
tags.push(["a", coordinate, relayHint]);
|
||||
} else {
|
||||
// Addressable event (a tag)
|
||||
const coordinate = `${params.eventPointer.kind}:${params.eventPointer.pubkey}:${params.eventPointer.identifier}`;
|
||||
tags.push(["a", coordinate]);
|
||||
// Include relay hint if available
|
||||
if (params.eventPointer.relays && params.eventPointer.relays.length > 0) {
|
||||
tags.push(["a", coordinate, params.eventPointer.relays[0]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom tags (protocol-specific like NIP-53 live activity references)
|
||||
if (params.customTags) {
|
||||
for (const tag of params.customTags) {
|
||||
tags.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export function parseLiveActivity(event: NostrEvent): ParsedLiveActivity {
|
||||
participants,
|
||||
hashtags: getTagValues(event, "t"),
|
||||
relays: getTagValues(event, "relays"),
|
||||
goal: getTagValue(event, "goal"),
|
||||
lastUpdate: event.created_at || Date.now() / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
41
src/lib/semantic-author.ts
Normal file
41
src/lib/semantic-author.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Semantic Author Utilities
|
||||
*
|
||||
* Determines the "semantic author" of an event based on kind-specific logic.
|
||||
* For most events, this is event.pubkey, but for certain event types the
|
||||
* semantic author may be different (e.g., zapper for zaps, host for streams).
|
||||
*/
|
||||
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { getZapSender } from "applesauce-common/helpers/zap";
|
||||
import { getLiveHost } from "@/lib/live-activity";
|
||||
|
||||
/**
|
||||
* Get the semantic author of an event based on kind-specific logic
|
||||
* Returns the pubkey that should be displayed as the "author" for UI purposes
|
||||
*
|
||||
* Examples:
|
||||
* - Zaps (9735): Returns the zapper (P tag), not the lightning service pubkey
|
||||
* - Live activities (30311): Returns the host (first p tag with "Host" role)
|
||||
* - Regular events: Returns event.pubkey
|
||||
*
|
||||
* This function should be used when determining:
|
||||
* - Who to display as the author in UI
|
||||
* - Who to zap when zapping an event
|
||||
* - Who the "owner" of the event is semantically
|
||||
*/
|
||||
export function getSemanticAuthor(event: NostrEvent): string {
|
||||
switch (event.kind) {
|
||||
case 9735: {
|
||||
// Zap: show the zapper, not the lightning service pubkey
|
||||
const zapSender = getZapSender(event);
|
||||
return zapSender || event.pubkey;
|
||||
}
|
||||
case 30311: {
|
||||
// Live activity: show the host
|
||||
return getLiveHost(event);
|
||||
}
|
||||
default:
|
||||
return event.pubkey;
|
||||
}
|
||||
}
|
||||
@@ -119,3 +119,37 @@ export function parseZapRequest(transaction: {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
// Symbol for caching invoice description on transaction objects
|
||||
const InvoiceDescriptionSymbol = Symbol("invoiceDescription");
|
||||
|
||||
/**
|
||||
* Extract the description from a BOLT11 invoice
|
||||
* Results are cached on the transaction object using applesauce pattern
|
||||
*
|
||||
* @param transaction - The transaction object with invoice field
|
||||
* @returns The invoice description string, or undefined if not available
|
||||
*/
|
||||
export function getInvoiceDescription(transaction: {
|
||||
invoice?: string;
|
||||
}): string | undefined {
|
||||
// Use applesauce caching pattern - cache result on transaction object
|
||||
return getOrComputeCachedValue(transaction, InvoiceDescriptionSymbol, () => {
|
||||
if (!transaction.invoice) return undefined;
|
||||
|
||||
try {
|
||||
const decoded = decodeBolt11(transaction.invoice);
|
||||
const descSection = decoded.sections.find(
|
||||
(s) => s.name === "description",
|
||||
);
|
||||
|
||||
if (descSection && "value" in descSection) {
|
||||
return String(descSection.value);
|
||||
}
|
||||
} catch {
|
||||
// Invoice decoding failed, ignore
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
207
src/lib/zap-parser.test.ts
Normal file
207
src/lib/zap-parser.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseZapCommand } from "./zap-parser";
|
||||
|
||||
describe("parseZapCommand", () => {
|
||||
describe("positional arguments", () => {
|
||||
it("should parse npub as recipient", async () => {
|
||||
const result = await parseZapCommand([
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
]);
|
||||
// npub decodes to this hex pubkey
|
||||
expect(result.recipientPubkey).toBe(
|
||||
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
|
||||
);
|
||||
});
|
||||
|
||||
it("should parse $me alias with active account", async () => {
|
||||
const activePubkey = "abc123def456";
|
||||
const result = await parseZapCommand(["$me"], activePubkey);
|
||||
expect(result.recipientPubkey).toBe(activePubkey);
|
||||
});
|
||||
|
||||
it("should throw when $me used without active account", async () => {
|
||||
await expect(parseZapCommand(["$me"])).rejects.toThrow(
|
||||
"No active account",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for empty arguments", async () => {
|
||||
await expect(parseZapCommand([])).rejects.toThrow(
|
||||
"Recipient or event required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom tags (-T, --tag)", () => {
|
||||
it("should parse single custom tag with -T", async () => {
|
||||
const result = await parseZapCommand([
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
"-T",
|
||||
"a",
|
||||
"30311:pubkey:identifier",
|
||||
]);
|
||||
expect(result.customTags).toEqual([["a", "30311:pubkey:identifier"]]);
|
||||
});
|
||||
|
||||
it("should parse custom tag with --tag", async () => {
|
||||
const result = await parseZapCommand([
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
"--tag",
|
||||
"e",
|
||||
"abc123",
|
||||
]);
|
||||
expect(result.customTags).toEqual([["e", "abc123"]]);
|
||||
});
|
||||
|
||||
it("should parse custom tag with relay hint", async () => {
|
||||
const result = await parseZapCommand([
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
"-T",
|
||||
"a",
|
||||
"30311:pubkey:identifier",
|
||||
"wss://relay.example.com",
|
||||
]);
|
||||
expect(result.customTags).toEqual([
|
||||
["a", "30311:pubkey:identifier", "wss://relay.example.com/"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("should parse multiple custom tags", async () => {
|
||||
const result = await parseZapCommand([
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
"-T",
|
||||
"a",
|
||||
"30311:pubkey:identifier",
|
||||
"-T",
|
||||
"e",
|
||||
"goal123",
|
||||
]);
|
||||
expect(result.customTags).toEqual([
|
||||
["a", "30311:pubkey:identifier"],
|
||||
["e", "goal123"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw for incomplete tag", async () => {
|
||||
await expect(
|
||||
parseZapCommand([
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
"-T",
|
||||
"a",
|
||||
]),
|
||||
).rejects.toThrow("Tag requires at least 2 arguments");
|
||||
});
|
||||
|
||||
it("should not include customTags when none provided", async () => {
|
||||
const result = await parseZapCommand([
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
]);
|
||||
expect(result.customTags).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("relays (-r, --relay)", () => {
|
||||
it("should parse single relay with -r", async () => {
|
||||
const result = await parseZapCommand([
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
"-r",
|
||||
"wss://relay1.example.com",
|
||||
]);
|
||||
expect(result.relays).toEqual(["wss://relay1.example.com/"]);
|
||||
});
|
||||
|
||||
it("should parse relay with --relay", async () => {
|
||||
const result = await parseZapCommand([
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
"--relay",
|
||||
"wss://relay.example.com",
|
||||
]);
|
||||
expect(result.relays).toEqual(["wss://relay.example.com/"]);
|
||||
});
|
||||
|
||||
it("should parse multiple relays", async () => {
|
||||
const result = await parseZapCommand([
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
"-r",
|
||||
"wss://relay1.example.com",
|
||||
"-r",
|
||||
"wss://relay2.example.com",
|
||||
]);
|
||||
expect(result.relays).toEqual([
|
||||
"wss://relay1.example.com/",
|
||||
"wss://relay2.example.com/",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw for missing relay URL", async () => {
|
||||
await expect(
|
||||
parseZapCommand([
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
"-r",
|
||||
]),
|
||||
).rejects.toThrow("Relay option requires a URL");
|
||||
});
|
||||
|
||||
it("should normalize relay URLs", async () => {
|
||||
// normalizeRelayURL is liberal - it normalizes most inputs
|
||||
const result = await parseZapCommand([
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
"-r",
|
||||
"relay.example.com",
|
||||
]);
|
||||
expect(result.relays).toEqual(["wss://relay.example.com/"]);
|
||||
});
|
||||
|
||||
it("should not include relays when none provided", async () => {
|
||||
const result = await parseZapCommand([
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
]);
|
||||
expect(result.relays).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined flags", () => {
|
||||
it("should parse tags and relays together", async () => {
|
||||
const result = await parseZapCommand([
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
"-T",
|
||||
"a",
|
||||
"30311:pubkey:identifier",
|
||||
"-r",
|
||||
"wss://relay.example.com",
|
||||
"-T",
|
||||
"e",
|
||||
"goalid",
|
||||
"wss://relay.example.com",
|
||||
]);
|
||||
expect(result.customTags).toEqual([
|
||||
["a", "30311:pubkey:identifier"],
|
||||
["e", "goalid", "wss://relay.example.com/"],
|
||||
]);
|
||||
expect(result.relays).toEqual(["wss://relay.example.com/"]);
|
||||
});
|
||||
|
||||
it("should handle flags before positional args", async () => {
|
||||
const result = await parseZapCommand([
|
||||
"-r",
|
||||
"wss://relay.example.com",
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
]);
|
||||
expect(result.recipientPubkey).toBe(
|
||||
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
|
||||
);
|
||||
expect(result.relays).toEqual(["wss://relay.example.com/"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown options", () => {
|
||||
it("should throw for unknown flags", async () => {
|
||||
await expect(
|
||||
parseZapCommand([
|
||||
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
|
||||
"--unknown",
|
||||
]),
|
||||
).rejects.toThrow("Unknown option: --unknown");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,17 @@ import type { EventPointer, AddressPointer } from "./open-parser";
|
||||
export interface ParsedZapCommand {
|
||||
/** Recipient pubkey (who receives the zap) */
|
||||
recipientPubkey: string;
|
||||
/** Optional event being zapped (adds context to the zap) */
|
||||
eventPointer?: EventPointer | AddressPointer;
|
||||
/** Optional event being zapped - regular events (e-tag) */
|
||||
eventPointer?: EventPointer;
|
||||
/** Optional addressable event being zapped - replaceable events (a-tag) */
|
||||
addressPointer?: AddressPointer;
|
||||
/**
|
||||
* Custom tags to include in the zap request
|
||||
* Used for protocol-specific tagging like NIP-53 live activity references
|
||||
*/
|
||||
customTags?: string[][];
|
||||
/** Relays where the zap receipt should be published */
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,6 +32,10 @@ export interface ParsedZapCommand {
|
||||
* - `zap <event>` - Zap an event (recipient derived from event author)
|
||||
* - `zap <profile> <event>` - Zap a specific person for a specific event
|
||||
*
|
||||
* Options:
|
||||
* - `-T, --tag <type> <value> [relay]` - Add custom tag (can be repeated)
|
||||
* - `-r, --relay <url>` - Add relay for zap receipt publication (can be repeated)
|
||||
*
|
||||
* Profile formats: npub, nprofile, hex pubkey, user@domain.com, $me
|
||||
* Event formats: note, nevent, naddr, hex event ID
|
||||
*/
|
||||
@@ -36,31 +49,117 @@ export async function parseZapCommand(
|
||||
);
|
||||
}
|
||||
|
||||
const firstArg = args[0];
|
||||
const secondArg = args[1];
|
||||
// Parse flags and positional args
|
||||
const positionalArgs: string[] = [];
|
||||
const customTags: string[][] = [];
|
||||
const relays: string[] = [];
|
||||
|
||||
// Case 1: Two arguments - zap <profile> <event>
|
||||
if (secondArg) {
|
||||
const recipientPubkey = await parseProfile(firstArg, activeAccountPubkey);
|
||||
const eventPointer = parseEventPointer(secondArg);
|
||||
return { recipientPubkey, eventPointer };
|
||||
let i = 0;
|
||||
while (i < args.length) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === "-T" || arg === "--tag") {
|
||||
// Parse tag: -T <type> <value> [relay-hint]
|
||||
// Minimum 2 values after -T (type and value), optional relay hint
|
||||
const tagType = args[i + 1];
|
||||
const tagValue = args[i + 2];
|
||||
|
||||
if (!tagType || !tagValue) {
|
||||
throw new Error(
|
||||
"Tag requires at least 2 arguments: -T <type> <value> [relay-hint]",
|
||||
);
|
||||
}
|
||||
|
||||
// Build tag array
|
||||
const tag = [tagType, tagValue];
|
||||
|
||||
// Check if next arg is a relay hint (starts with ws:// or wss://)
|
||||
const potentialRelay = args[i + 3];
|
||||
if (
|
||||
potentialRelay &&
|
||||
(potentialRelay.startsWith("ws://") ||
|
||||
potentialRelay.startsWith("wss://"))
|
||||
) {
|
||||
try {
|
||||
tag.push(normalizeRelayURL(potentialRelay));
|
||||
i += 4;
|
||||
} catch {
|
||||
// Not a valid relay, don't include
|
||||
i += 3;
|
||||
}
|
||||
} else {
|
||||
i += 3;
|
||||
}
|
||||
|
||||
customTags.push(tag);
|
||||
} else if (arg === "-r" || arg === "--relay") {
|
||||
// Parse relay: -r <url>
|
||||
const relayUrl = args[i + 1];
|
||||
if (!relayUrl) {
|
||||
throw new Error("Relay option requires a URL: -r <url>");
|
||||
}
|
||||
|
||||
try {
|
||||
relays.push(normalizeRelayURL(relayUrl));
|
||||
} catch {
|
||||
throw new Error(`Invalid relay URL: ${relayUrl}`);
|
||||
}
|
||||
i += 2;
|
||||
} else if (arg.startsWith("-")) {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
} else {
|
||||
positionalArgs.push(arg);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: One argument - try event first, then profile
|
||||
if (positionalArgs.length === 0) {
|
||||
throw new Error(
|
||||
"Recipient or event required. Usage: zap <profile> or zap <event> or zap <profile> <event>",
|
||||
);
|
||||
}
|
||||
|
||||
const firstArg = positionalArgs[0];
|
||||
const secondArg = positionalArgs[1];
|
||||
|
||||
// Build result with optional custom tags and relays
|
||||
const buildResult = (
|
||||
recipientPubkey: string,
|
||||
pointer?: EventPointer | AddressPointer,
|
||||
): ParsedZapCommand => {
|
||||
const result: ParsedZapCommand = { recipientPubkey };
|
||||
// Separate EventPointer from AddressPointer based on presence of 'id' vs 'kind'
|
||||
if (pointer) {
|
||||
if ("id" in pointer) {
|
||||
result.eventPointer = pointer;
|
||||
} else if ("kind" in pointer) {
|
||||
result.addressPointer = pointer;
|
||||
}
|
||||
}
|
||||
if (customTags.length > 0) result.customTags = customTags;
|
||||
if (relays.length > 0) result.relays = relays;
|
||||
return result;
|
||||
};
|
||||
|
||||
// Case 1: Two positional arguments - zap <profile> <event>
|
||||
if (secondArg) {
|
||||
const recipientPubkey = await parseProfile(firstArg, activeAccountPubkey);
|
||||
const pointer = parseEventPointer(secondArg);
|
||||
return buildResult(recipientPubkey, pointer);
|
||||
}
|
||||
|
||||
// Case 2: One positional argument - try event first, then profile
|
||||
// Events have more specific patterns (nevent, naddr, note)
|
||||
const eventPointer = tryParseEventPointer(firstArg);
|
||||
if (eventPointer) {
|
||||
const pointer = tryParseEventPointer(firstArg);
|
||||
if (pointer) {
|
||||
// For events, we'll need to fetch the event to get the author
|
||||
// For now, we'll return a placeholder and let the component fetch it
|
||||
return {
|
||||
recipientPubkey: "", // Will be filled in by component from event author
|
||||
eventPointer,
|
||||
};
|
||||
return buildResult("", pointer);
|
||||
}
|
||||
|
||||
// Must be a profile
|
||||
const recipientPubkey = await parseProfile(firstArg, activeAccountPubkey);
|
||||
return { recipientPubkey };
|
||||
return buildResult(recipientPubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
266
src/lib/zap-relay-selection.test.ts
Normal file
266
src/lib/zap-relay-selection.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { selectZapRelays, getZapRelays } from "./zap-relay-selection";
|
||||
|
||||
// Mock the relay list cache
|
||||
vi.mock("@/services/relay-list-cache", () => ({
|
||||
relayListCache: {
|
||||
getInboxRelays: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the loaders for AGGREGATOR_RELAYS
|
||||
vi.mock("@/services/loaders", () => ({
|
||||
AGGREGATOR_RELAYS: [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.nostr.band",
|
||||
],
|
||||
}));
|
||||
|
||||
import { relayListCache } from "@/services/relay-list-cache";
|
||||
|
||||
describe("selectZapRelays", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("explicit relays", () => {
|
||||
it("should use explicit relays when provided", async () => {
|
||||
const explicitRelays = ["wss://explicit1.com", "wss://explicit2.com"];
|
||||
|
||||
const result = await selectZapRelays({
|
||||
recipientPubkey: "recipient123",
|
||||
senderPubkey: "sender456",
|
||||
explicitRelays,
|
||||
});
|
||||
|
||||
expect(result.relays).toEqual(explicitRelays);
|
||||
expect(result.sources.recipientInbox).toEqual([]);
|
||||
expect(result.sources.senderInbox).toEqual([]);
|
||||
expect(result.sources.fallback).toEqual([]);
|
||||
// Should not call cache when explicit relays provided
|
||||
expect(relayListCache.getInboxRelays).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should limit explicit relays to 10", async () => {
|
||||
const explicitRelays = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) => `wss://relay${i}.com`,
|
||||
);
|
||||
|
||||
const result = await selectZapRelays({
|
||||
recipientPubkey: "recipient123",
|
||||
explicitRelays,
|
||||
});
|
||||
|
||||
expect(result.relays.length).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recipient inbox priority", () => {
|
||||
it("should prioritize recipient's inbox relays", async () => {
|
||||
const recipientRelays = [
|
||||
"wss://recipient1.com",
|
||||
"wss://recipient2.com",
|
||||
"wss://recipient3.com",
|
||||
];
|
||||
const senderRelays = [
|
||||
"wss://sender1.com",
|
||||
"wss://sender2.com",
|
||||
"wss://sender3.com",
|
||||
];
|
||||
|
||||
vi.mocked(relayListCache.getInboxRelays).mockImplementation(
|
||||
async (pubkey) => {
|
||||
if (pubkey === "recipient123") return recipientRelays;
|
||||
if (pubkey === "sender456") return senderRelays;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await selectZapRelays({
|
||||
recipientPubkey: "recipient123",
|
||||
senderPubkey: "sender456",
|
||||
});
|
||||
|
||||
// Recipient relays should come first
|
||||
expect(result.relays.slice(0, 3)).toEqual(recipientRelays);
|
||||
expect(result.sources.recipientInbox).toEqual(recipientRelays);
|
||||
expect(result.sources.senderInbox).toEqual(senderRelays);
|
||||
});
|
||||
|
||||
it("should use only recipient relays when sender is anonymous", async () => {
|
||||
const recipientRelays = ["wss://recipient1.com", "wss://recipient2.com"];
|
||||
|
||||
vi.mocked(relayListCache.getInboxRelays).mockResolvedValue(
|
||||
recipientRelays,
|
||||
);
|
||||
|
||||
const result = await selectZapRelays({
|
||||
recipientPubkey: "recipient123",
|
||||
// No senderPubkey - anonymous zap
|
||||
});
|
||||
|
||||
expect(result.relays).toEqual(recipientRelays);
|
||||
expect(result.sources.recipientInbox).toEqual(recipientRelays);
|
||||
expect(result.sources.senderInbox).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relay deduplication", () => {
|
||||
it("should deduplicate relays shared by recipient and sender", async () => {
|
||||
const sharedRelay = "wss://shared.com";
|
||||
const recipientRelays = [sharedRelay, "wss://recipient-only.com"];
|
||||
const senderRelays = [sharedRelay, "wss://sender-only.com"];
|
||||
|
||||
vi.mocked(relayListCache.getInboxRelays).mockImplementation(
|
||||
async (pubkey) => {
|
||||
if (pubkey === "recipient123") return recipientRelays;
|
||||
if (pubkey === "sender456") return senderRelays;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await selectZapRelays({
|
||||
recipientPubkey: "recipient123",
|
||||
senderPubkey: "sender456",
|
||||
});
|
||||
|
||||
// Count occurrences of shared relay
|
||||
const sharedCount = result.relays.filter((r) => r === sharedRelay).length;
|
||||
expect(sharedCount).toBe(1);
|
||||
|
||||
// Should have all unique relays
|
||||
expect(result.relays).toContain(sharedRelay);
|
||||
expect(result.relays).toContain("wss://recipient-only.com");
|
||||
expect(result.relays).toContain("wss://sender-only.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback relays", () => {
|
||||
it("should use fallback relays when neither party has preferences", async () => {
|
||||
vi.mocked(relayListCache.getInboxRelays).mockResolvedValue(null);
|
||||
|
||||
const result = await selectZapRelays({
|
||||
recipientPubkey: "recipient123",
|
||||
senderPubkey: "sender456",
|
||||
});
|
||||
|
||||
expect(result.relays.length).toBeGreaterThan(0);
|
||||
expect(result.sources.fallback.length).toBeGreaterThan(0);
|
||||
expect(result.relays).toContain("wss://relay.damus.io");
|
||||
});
|
||||
|
||||
it("should use fallback when recipient has empty relay list", async () => {
|
||||
vi.mocked(relayListCache.getInboxRelays).mockResolvedValue([]);
|
||||
|
||||
const result = await selectZapRelays({
|
||||
recipientPubkey: "recipient123",
|
||||
});
|
||||
|
||||
expect(result.relays.length).toBeGreaterThan(0);
|
||||
expect(result.sources.fallback.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relay limits", () => {
|
||||
it("should limit total relays to 10", async () => {
|
||||
const recipientRelays = Array.from(
|
||||
{ length: 8 },
|
||||
(_, i) => `wss://recipient${i}.com`,
|
||||
);
|
||||
const senderRelays = Array.from(
|
||||
{ length: 8 },
|
||||
(_, i) => `wss://sender${i}.com`,
|
||||
);
|
||||
|
||||
vi.mocked(relayListCache.getInboxRelays).mockImplementation(
|
||||
async (pubkey) => {
|
||||
if (pubkey === "recipient123") return recipientRelays;
|
||||
if (pubkey === "sender456") return senderRelays;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await selectZapRelays({
|
||||
recipientPubkey: "recipient123",
|
||||
senderPubkey: "sender456",
|
||||
});
|
||||
|
||||
expect(result.relays.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it("should ensure minimum relays per party when possible", async () => {
|
||||
const recipientRelays = [
|
||||
"wss://r1.com",
|
||||
"wss://r2.com",
|
||||
"wss://r3.com",
|
||||
"wss://r4.com",
|
||||
"wss://r5.com",
|
||||
];
|
||||
const senderRelays = [
|
||||
"wss://s1.com",
|
||||
"wss://s2.com",
|
||||
"wss://s3.com",
|
||||
"wss://s4.com",
|
||||
"wss://s5.com",
|
||||
];
|
||||
|
||||
vi.mocked(relayListCache.getInboxRelays).mockImplementation(
|
||||
async (pubkey) => {
|
||||
if (pubkey === "recipient123") return recipientRelays;
|
||||
if (pubkey === "sender456") return senderRelays;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await selectZapRelays({
|
||||
recipientPubkey: "recipient123",
|
||||
senderPubkey: "sender456",
|
||||
});
|
||||
|
||||
// Should have at least 3 recipient relays (MIN_RELAYS_PER_PARTY)
|
||||
const recipientCount = result.relays.filter((r) =>
|
||||
r.startsWith("wss://r"),
|
||||
).length;
|
||||
expect(recipientCount).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Should have at least 3 sender relays
|
||||
const senderCount = result.relays.filter((r) =>
|
||||
r.startsWith("wss://s"),
|
||||
).length;
|
||||
expect(senderCount).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getZapRelays", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return just the relay URLs", async () => {
|
||||
const recipientRelays = ["wss://recipient1.com", "wss://recipient2.com"];
|
||||
|
||||
vi.mocked(relayListCache.getInboxRelays).mockResolvedValue(recipientRelays);
|
||||
|
||||
const relays = await getZapRelays("recipient123", "sender456");
|
||||
|
||||
expect(Array.isArray(relays)).toBe(true);
|
||||
expect(relays).toEqual(recipientRelays);
|
||||
});
|
||||
|
||||
it("should work without sender pubkey (anonymous)", async () => {
|
||||
const recipientRelays = ["wss://recipient1.com"];
|
||||
|
||||
vi.mocked(relayListCache.getInboxRelays).mockResolvedValue(recipientRelays);
|
||||
|
||||
const relays = await getZapRelays("recipient123");
|
||||
|
||||
expect(relays).toEqual(recipientRelays);
|
||||
});
|
||||
});
|
||||
138
src/lib/zap-relay-selection.ts
Normal file
138
src/lib/zap-relay-selection.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Zap Relay Selection Utilities
|
||||
*
|
||||
* Provides optimal relay selection for zap receipts (kind 9735).
|
||||
* The relays tag in a zap request specifies where the zap receipt should be published.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Recipient's inbox (read) relays - so recipient sees the zap
|
||||
* 2. Sender's inbox (read) relays - so sender can verify the zap receipt
|
||||
* 3. Fallback aggregator relays - if neither party has relay preferences
|
||||
*/
|
||||
|
||||
import { relayListCache } from "@/services/relay-list-cache";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
|
||||
/** Maximum number of relays to include in zap request */
|
||||
const MAX_ZAP_RELAYS = 10;
|
||||
|
||||
/** Minimum relays to ensure good coverage */
|
||||
const MIN_RELAYS_PER_PARTY = 3;
|
||||
|
||||
export interface ZapRelaySelectionParams {
|
||||
/** Pubkey of the zap recipient */
|
||||
recipientPubkey: string;
|
||||
/** Pubkey of the zap sender (undefined for anonymous zaps) */
|
||||
senderPubkey?: string;
|
||||
/** Explicit relays to use (overrides automatic selection) */
|
||||
explicitRelays?: string[];
|
||||
}
|
||||
|
||||
export interface ZapRelaySelectionResult {
|
||||
/** Selected relays for zap receipt publication */
|
||||
relays: string[];
|
||||
/** Debug info about relay sources */
|
||||
sources: {
|
||||
recipientInbox: string[];
|
||||
senderInbox: string[];
|
||||
fallback: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select optimal relays for zap receipt publication
|
||||
*
|
||||
* Strategy:
|
||||
* - Prioritize recipient's inbox relays (they need to see the zap)
|
||||
* - Add sender's inbox relays (they want to verify/see the receipt)
|
||||
* - Use fallback aggregators if neither has preferences
|
||||
* - Deduplicate and limit to MAX_ZAP_RELAYS
|
||||
*/
|
||||
export async function selectZapRelays(
|
||||
params: ZapRelaySelectionParams,
|
||||
): Promise<ZapRelaySelectionResult> {
|
||||
const { recipientPubkey, senderPubkey, explicitRelays } = params;
|
||||
|
||||
// If explicit relays provided, use them directly
|
||||
if (explicitRelays && explicitRelays.length > 0) {
|
||||
return {
|
||||
relays: explicitRelays.slice(0, MAX_ZAP_RELAYS),
|
||||
sources: {
|
||||
recipientInbox: [],
|
||||
senderInbox: [],
|
||||
fallback: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const sources = {
|
||||
recipientInbox: [] as string[],
|
||||
senderInbox: [] as string[],
|
||||
fallback: [] as string[],
|
||||
};
|
||||
|
||||
// Fetch relays in parallel
|
||||
const [recipientInbox, senderInbox] = await Promise.all([
|
||||
relayListCache.getInboxRelays(recipientPubkey),
|
||||
senderPubkey ? relayListCache.getInboxRelays(senderPubkey) : null,
|
||||
]);
|
||||
|
||||
if (recipientInbox && recipientInbox.length > 0) {
|
||||
sources.recipientInbox = recipientInbox;
|
||||
}
|
||||
|
||||
if (senderInbox && senderInbox.length > 0) {
|
||||
sources.senderInbox = senderInbox;
|
||||
}
|
||||
|
||||
// Build relay list with priority ordering
|
||||
const relaySet = new Set<string>();
|
||||
|
||||
// Priority 1: Recipient's inbox relays (take up to MIN_RELAYS_PER_PARTY first)
|
||||
for (const relay of sources.recipientInbox.slice(0, MIN_RELAYS_PER_PARTY)) {
|
||||
relaySet.add(relay);
|
||||
}
|
||||
|
||||
// Priority 2: Sender's inbox relays (take up to MIN_RELAYS_PER_PARTY)
|
||||
for (const relay of sources.senderInbox.slice(0, MIN_RELAYS_PER_PARTY)) {
|
||||
relaySet.add(relay);
|
||||
}
|
||||
|
||||
// Add remaining recipient relays
|
||||
for (const relay of sources.recipientInbox.slice(MIN_RELAYS_PER_PARTY)) {
|
||||
if (relaySet.size >= MAX_ZAP_RELAYS) break;
|
||||
relaySet.add(relay);
|
||||
}
|
||||
|
||||
// Add remaining sender relays
|
||||
for (const relay of sources.senderInbox.slice(MIN_RELAYS_PER_PARTY)) {
|
||||
if (relaySet.size >= MAX_ZAP_RELAYS) break;
|
||||
relaySet.add(relay);
|
||||
}
|
||||
|
||||
// Fallback to aggregator relays if we don't have enough
|
||||
if (relaySet.size === 0) {
|
||||
sources.fallback = [...AGGREGATOR_RELAYS];
|
||||
for (const relay of AGGREGATOR_RELAYS) {
|
||||
if (relaySet.size >= MAX_ZAP_RELAYS) break;
|
||||
relaySet.add(relay);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
relays: Array.from(relaySet),
|
||||
sources,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a simple list of relays for zap receipt publication
|
||||
* Convenience wrapper that just returns the relay URLs
|
||||
*/
|
||||
export async function getZapRelays(
|
||||
recipientPubkey: string,
|
||||
senderPubkey?: string,
|
||||
): Promise<string[]> {
|
||||
const result = await selectZapRelays({ recipientPubkey, senderPubkey });
|
||||
return result.relays;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import { transitionAuthState, type AuthEvent } from "@/lib/auth-state-machine";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { normalizeRelayURL } from "@/lib/relay-url";
|
||||
import { canAccountSign } from "@/hooks/useAccount";
|
||||
import pool from "./relay-pool";
|
||||
import accountManager from "./accounts";
|
||||
import db from "./db";
|
||||
@@ -381,6 +382,11 @@ class RelayStateManager {
|
||||
throw new Error("No active account to authenticate with");
|
||||
}
|
||||
|
||||
// Check if account can sign (read-only accounts cannot authenticate)
|
||||
if (!canAccountSign(account)) {
|
||||
throw new Error("Active account cannot sign events (read-only account)");
|
||||
}
|
||||
|
||||
// Update status to authenticating
|
||||
state.authStatus = "authenticating";
|
||||
state.stats.authAttemptsCount++;
|
||||
@@ -491,8 +497,9 @@ class RelayStateManager {
|
||||
try {
|
||||
const normalizedUrl = normalizeRelayURL(relayUrl);
|
||||
|
||||
// Don't prompt if there's no active account
|
||||
if (!accountManager.active) return false;
|
||||
// Don't prompt if there's no active account or account can't sign
|
||||
const account = accountManager.active;
|
||||
if (!account || !canAccountSign(account)) return false;
|
||||
|
||||
// Check permanent preferences
|
||||
const pref = this.authPreferences.get(normalizedUrl);
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface LiveActivityMetadata {
|
||||
totalParticipants?: number;
|
||||
hashtags: string[];
|
||||
relays: string[];
|
||||
goal?: string; // Event ID of a kind 9041 zap goal
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface ParsedLiveActivity {
|
||||
// Additional
|
||||
hashtags: string[]; // 't' tags
|
||||
relays: string[]; // 'relays' tag values
|
||||
goal?: string; // Event ID of a kind 9041 zap goal
|
||||
|
||||
// Computed
|
||||
lastUpdate: number; // event.created_at
|
||||
|
||||
@@ -618,9 +618,10 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
zap: {
|
||||
name: "zap",
|
||||
section: "1",
|
||||
synopsis: "zap <profile|event> [event]",
|
||||
synopsis:
|
||||
"zap <profile|event> [event] [-T <type> <value> [relay]] [-r <relay>]",
|
||||
description:
|
||||
"Send a Lightning zap (NIP-57) to a Nostr user or event. Zaps are Lightning payments with proof published to Nostr. Supports zapping profiles directly or events with context. Requires the recipient to have a Lightning address (lud16/lud06) configured in their profile.",
|
||||
"Send a Lightning zap (NIP-57) to a Nostr user or event. Zaps are Lightning payments with proof published to Nostr. Supports zapping profiles directly or events with context. Custom tags can be added for protocol-specific tagging (e.g., NIP-53 live activities). Requires the recipient to have a Lightning address (lud16/lud06) configured in their profile.",
|
||||
options: [
|
||||
{
|
||||
flag: "<profile>",
|
||||
@@ -631,6 +632,16 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
flag: "<event>",
|
||||
description: "Event to zap: note, nevent, naddr, hex ID (optional)",
|
||||
},
|
||||
{
|
||||
flag: "-T, --tag <type> <value> [relay]",
|
||||
description:
|
||||
"Add custom tag to zap request (can be repeated). Used for protocol-specific tagging like NIP-53 a-tags",
|
||||
},
|
||||
{
|
||||
flag: "-r, --relay <url>",
|
||||
description:
|
||||
"Relay where zap receipt should be published (can be repeated)",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"zap fiatjaf.com Zap a user by NIP-05",
|
||||
@@ -638,6 +649,8 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
"zap nevent1... Zap an event (recipient = event author)",
|
||||
"zap npub1... nevent1... Zap a specific user for a specific event",
|
||||
"zap alice@domain.com naddr1... Zap with event context",
|
||||
"zap npub1... -T a 30311:pk:id wss://relay.example.com Zap with live activity a-tag",
|
||||
"zap npub1... -r wss://relay1.com -r wss://relay2.com Zap with custom relays",
|
||||
],
|
||||
seeAlso: ["profile", "open", "wallet"],
|
||||
appId: "zap",
|
||||
|
||||
Reference in New Issue
Block a user