diff --git a/CLAUDE.md b/CLAUDE.md
index c5d0910..b68749b 100644
--- a/CLAUDE.md
+++ b/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/`
diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx
index f76f83c..2808c46 100644
--- a/src/components/ChatViewer.tsx
+++ b/src/components/ChatViewer.tsx
@@ -414,6 +414,7 @@ const MessageItem = memo(function MessageItem({
onReply={canReply && onReply ? () => onReply(message.id) : undefined}
conversation={conversation}
adapter={adapter}
+ message={message}
>
{messageContent}
diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx
index 834bed7..0ab9d65 100644
--- a/src/components/DynamicWindowTitle.tsx
+++ b/src/components/DynamicWindowTitle.tsx
@@ -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;
diff --git a/src/components/WalletViewer.tsx b/src/components/WalletViewer.tsx
index 23e7e8e..55e5db7 100644
--- a/src/components/WalletViewer.tsx
+++ b/src/components/WalletViewer.tsx
@@ -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 (
-
- {transaction.description ||
- (transaction.type === "incoming" ? "Received" : "Payment")}
-
- );
+ const description =
+ transaction.description ||
+ getInvoiceDescription(transaction) ||
+ (transaction.type === "incoming" ? "Received" : "Payment");
+
+ return {description};
}
// It's a zap! Show username + message on one line
@@ -1104,93 +1161,99 @@ export default function WalletViewer() {
)}
{/* Transaction History */}
-
- {walletInfo?.methods.includes("list_transactions") ? (
- loading ? (
-
-
-
- ) : txLoadFailed ? (
-
-
- Failed to load transaction history
-
-
-
- ) : transactionsWithMarkers.length === 0 ? (
-
-
- No transactions found
-
-
- ) : (
-
{
- if (item.type === "day-marker") {
+
+
+ {walletInfo?.methods.includes("list_transactions") ? (
+ loading ? (
+
+
+
+ ) : txLoadFailed ? (
+
+
+ Failed to load transaction history
+
+
+
+ ) : transactionsWithMarkers.length === 0 ? (
+
+
+ No transactions found
+
+
+ ) : (
+
{
+ if (item.type === "day-marker") {
+ return (
+
+
+
+ );
+ }
+
+ const tx = item.data;
+
return (
handleTransactionClick(tx)}
>
-
+
+ {tx.type === "incoming" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {state.walletBalancesBlurred
+ ? "✦✦✦✦"
+ : formatSats(tx.amount)}
+
+
);
- }
-
- const tx = item.data;
-
- return (
- handleTransactionClick(tx)}
- >
-
- {tx.type === "incoming" ? (
-
- ) : (
-
- )}
-
-
-
-
- {state.walletBalancesBlurred
- ? "✦✦✦✦"
- : formatSats(tx.amount)}
-
-
-
- );
- }}
- components={{
- Footer: () =>
- loadingMore ? (
-
-
-
- ) : !hasMore && transactions.length > 0 ? (
-
- No more transactions
-
- ) : null,
- }}
- />
- )
- ) : (
-
-
- Transaction history not available
-
-
- )}
+ }}
+ components={{
+ Footer: () =>
+ loadingMore ? (
+
+
+
+ ) : !hasMore && transactions.length > 0 ? (
+
+ No more transactions
+
+ ) : null,
+ }}
+ />
+ )
+ ) : (
+
+
+ Transaction history not available
+
+
+ )}
+
{/* Disconnect Confirmation Dialog */}
@@ -1231,7 +1294,7 @@ export default function WalletViewer() {
}
}}
>
-
+
Transaction Details
@@ -1260,17 +1323,24 @@ export default function WalletViewer() {
- {selectedTransaction.description &&
- !parseZapRequest(selectedTransaction) && (
-
-
-
- {selectedTransaction.description}
-
-
- )}
+ {(() => {
+ const description =
+ selectedTransaction.description ||
+ getInvoiceDescription(selectedTransaction);
+ const isZap = parseZapRequest(selectedTransaction);
+
+ return (
+ description &&
+ !isZap && (
+
+
+
{description}
+
+ )
+ );
+ })()}
)}
- {selectedTransaction.payment_hash && (
-
-
-
- {selectedTransaction.payment_hash}
-
-
- )}
+ {(() => {
+ const isBitcoin = isBitcoinTransaction(selectedTransaction);
- {selectedTransaction.preimage && (
-
-
-
- {selectedTransaction.preimage}
-
-
- )}
+ 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 (
+
+ );
+ }
+
+ // Lightning transaction - show payment hash and preimage
+ return (
+ <>
+ {selectedTransaction.payment_hash && (
+
+
+
+ {selectedTransaction.payment_hash}
+
+
+ )}
+
+ {selectedTransaction.preimage && (
+
+
+
+ {selectedTransaction.preimage}
+
+
+ )}
+ >
+ );
+ })()}
{/* Zap Details (if this is a zap payment) */}
diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx
index 22378c6..0ad2c71 100644
--- a/src/components/WindowRenderer.tsx
+++ b/src/components/WindowRenderer.tsx
@@ -234,6 +234,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
);
diff --git a/src/components/ZapWindow.tsx b/src/components/ZapWindow.tsx
index 2580eff..b4eb3c4 100644
--- a/src/components/ZapWindow.tsx
+++ b/src/components/ZapWindow.tsx
@@ -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(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 && (
+
+
+ setZapAnonymously(checked === true)
+ }
+ />
+
+
+ )}
{/* No Lightning Address Warning */}
@@ -657,7 +700,7 @@ export function ZapWindow({
)}
{/* Payment Button */}
- {!canSign ? (
+ {!canSign && !zapAnonymously ? (
+ ) : zapAnonymously ? (
+ <>
+
+ Zap Anonymously (
+ {selectedAmount || parseInt(customAmount) || 0} sats)
+ >
) : (
<>
diff --git a/src/components/chat/ChatMessageContextMenu.tsx b/src/components/chat/ChatMessageContextMenu.tsx
index 5042d8e..6c23fca 100644
--- a/src/components/chat/ChatMessageContextMenu.tsx
+++ b/src/components/chat/ChatMessageContextMenu.tsx
@@ -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 (
<>
@@ -170,6 +192,12 @@ export function ChatMessageContextMenu({
React
+ {zapConfig?.supported && (
+
+
+ Zap
+
+ )}
>
)}
diff --git a/src/components/nostr/compact/ZapCompactPreview.tsx b/src/components/nostr/compact/ZapCompactPreview.tsx
index 81f6628..991c521 100644
--- a/src/components/nostr/compact/ZapCompactPreview.tsx
+++ b/src/components/nostr/compact/ZapCompactPreview.tsx
@@ -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 }) {
{amountInSats.toLocaleString("en", { notation: "compact" })}
- {zapMessage && (
+ {zapRecipient && }
+ {zapRequest?.content && (
)}
{zappedEvent && (
- <>
-
-
-
-
- >
+
+
+
)}
);
diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx
index 9b4986a..1c43ca3 100644
--- a/src/components/nostr/kinds/BaseEventRenderer.tsx
+++ b/src/components/nostr/kinds/BaseEventRenderer.tsx
@@ -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,
});
};
diff --git a/src/components/nostr/kinds/ZapReceiptRenderer.tsx b/src/components/nostr/kinds/ZapReceiptRenderer.tsx
index 395b655..de22a99 100644
--- a/src/components/nostr/kinds/ZapReceiptRenderer.tsx
+++ b/src/components/nostr/kinds/ZapReceiptRenderer.tsx
@@ -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) {
})}
sats
+ {zapRecipient && }
{/* Zap comment */}
- {zapComment && (
+ {zapRequest && zapRequest.content && (
-
+
)}
diff --git a/src/hooks/useAccount.ts b/src/hooks/useAccount.ts
index bd30c51..4e25797 100644
--- a/src/hooks/useAccount.ts
+++ b/src/hooks/useAccount.ts
@@ -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,
diff --git a/src/lib/chat/adapters/base-adapter.ts b/src/lib/chat/adapters/base-adapter.ts
index d0aca1c..4747f32 100644
--- a/src/lib/chat/adapters/base-adapter.ts
+++ b/src/lib/chat/adapters/base-adapter.ts
@@ -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;
+ /**
+ * 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.
diff --git a/src/lib/chat/adapters/nip-53-adapter.ts b/src/lib/chat/adapters/nip-53-adapter.ts
index 32f1d12..4f71468 100644
--- a/src/lib/chat/adapters/nip-53-adapter.ts
+++ b/src/lib/chat/adapters/nip-53-adapter.ts
@@ -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
diff --git a/src/lib/command-reconstructor.ts b/src/lib/command-reconstructor.ts
index 627c8d3..890de6f 100644
--- a/src/lib/command-reconstructor.ts
+++ b/src/lib/command-reconstructor.ts
@@ -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;
diff --git a/src/lib/create-zap-request.ts b/src/lib/create-zap-request.ts
index 34d1ea6..2381564 100644
--- a/src/lib/create-zap-request.ts
+++ b/src/lib/create-zap-request.ts
@@ -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 {
- 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);
}
}
diff --git a/src/lib/live-activity.ts b/src/lib/live-activity.ts
index 18d4cc2..28c1cd8 100644
--- a/src/lib/live-activity.ts
+++ b/src/lib/live-activity.ts
@@ -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,
};
}
diff --git a/src/lib/semantic-author.ts b/src/lib/semantic-author.ts
new file mode 100644
index 0000000..b28a2b3
--- /dev/null
+++ b/src/lib/semantic-author.ts
@@ -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;
+ }
+}
diff --git a/src/lib/wallet-utils.ts b/src/lib/wallet-utils.ts
index 8873e24..a0b8b8c 100644
--- a/src/lib/wallet-utils.ts
+++ b/src/lib/wallet-utils.ts
@@ -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;
+ });
+}
diff --git a/src/lib/zap-parser.test.ts b/src/lib/zap-parser.test.ts
new file mode 100644
index 0000000..b8b2ff5
--- /dev/null
+++ b/src/lib/zap-parser.test.ts
@@ -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");
+ });
+ });
+});
diff --git a/src/lib/zap-parser.ts b/src/lib/zap-parser.ts
index 060ada7..515a02d 100644
--- a/src/lib/zap-parser.ts
+++ b/src/lib/zap-parser.ts
@@ -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 ` - Zap an event (recipient derived from event author)
* - `zap ` - Zap a specific person for a specific event
*
+ * Options:
+ * - `-T, --tag [relay]` - Add custom tag (can be repeated)
+ * - `-r, --relay ` - 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
- 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 [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 [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
+ const relayUrl = args[i + 1];
+ if (!relayUrl) {
+ throw new Error("Relay option requires a URL: -r ");
+ }
+
+ 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 or zap or zap ",
+ );
+ }
+
+ 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
+ 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);
}
/**
diff --git a/src/lib/zap-relay-selection.test.ts b/src/lib/zap-relay-selection.test.ts
new file mode 100644
index 0000000..ce9bf09
--- /dev/null
+++ b/src/lib/zap-relay-selection.test.ts
@@ -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);
+ });
+});
diff --git a/src/lib/zap-relay-selection.ts b/src/lib/zap-relay-selection.ts
new file mode 100644
index 0000000..dba9e76
--- /dev/null
+++ b/src/lib/zap-relay-selection.ts
@@ -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 {
+ 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();
+
+ // 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 {
+ const result = await selectZapRelays({ recipientPubkey, senderPubkey });
+ return result.relays;
+}
diff --git a/src/services/relay-state-manager.ts b/src/services/relay-state-manager.ts
index 8d1631f..9cb247d 100644
--- a/src/services/relay-state-manager.ts
+++ b/src/services/relay-state-manager.ts
@@ -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);
diff --git a/src/types/chat.ts b/src/types/chat.ts
index f81b518..6030e08 100644
--- a/src/types/chat.ts
+++ b/src/types/chat.ts
@@ -49,6 +49,7 @@ export interface LiveActivityMetadata {
totalParticipants?: number;
hashtags: string[];
relays: string[];
+ goal?: string; // Event ID of a kind 9041 zap goal
}
/**
diff --git a/src/types/live-activity.ts b/src/types/live-activity.ts
index ca947f1..c5e69c3 100644
--- a/src/types/live-activity.ts
+++ b/src/types/live-activity.ts
@@ -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
diff --git a/src/types/man.ts b/src/types/man.ts
index 230a292..9031732 100644
--- a/src/types/man.ts
+++ b/src/types/man.ts
@@ -618,9 +618,10 @@ export const manPages: Record = {
zap: {
name: "zap",
section: "1",
- synopsis: "zap [event]",
+ synopsis:
+ "zap [event] [-T [relay]] [-r ]",
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: "",
@@ -631,6 +632,16 @@ export const manPages: Record = {
flag: "",
description: "Event to zap: note, nevent, naddr, hex ID (optional)",
},
+ {
+ flag: "-T, --tag [relay]",
+ description:
+ "Add custom tag to zap request (can be repeated). Used for protocol-specific tagging like NIP-53 a-tags",
+ },
+ {
+ flag: "-r, --relay ",
+ 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 = {
"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",