Files
grimoire/src/lib/live-activity.ts
Alejandro 3f811ed072 feat: zap action for chat (#151)
* feat: add configurable zap tagging for chat messages

Implements a protocol adapter interface for configuring how zap
requests should be tagged for chat messages. This enables proper
NIP-53 live activity zapping with appropriate a-tag and goal
e-tag support.

Changes:
- Add ZapConfig interface to base-adapter for protocol-specific zap configuration
- Add getZapConfig() method to ChatProtocolAdapter (default: unsupported)
- Implement getZapConfig() in NIP-53 adapter with proper tagging:
  - Always a-tag the live activity (kind 30311)
  - If zapping host with goal, also e-tag the goal event
- Add goal tag parsing to live-activity.ts and types
- Update createZapRequest to accept custom tags parameter
- Add Zap action to ChatMessageContextMenu (shown when supported)
- Update ZapWindow to pass custom tags through to zap request
- NIP-29 groups inherit default (unsupported) behavior

* feat: add custom tags and relays to zap command

Extends the zap command to support custom tags and relay specification,
enabling full translation from chat zap config to zap command.

Changes:
- Add -T/--tag flag to specify custom tags (type, value, optional relay hint)
- Add -r/--relay flag to specify where zap receipt should be published
- Update ZapWindow to accept and pass through relays prop
- Update ChatMessageContextMenu to pass relays from zapConfig
- Update man page with new options and examples
- Add comprehensive tests for zap parser flag handling

Example usage:
  zap npub... -T a 30311:pk:id wss://relay.example.com
  zap npub... -r wss://relay1.com -r wss://relay2.com

* fix: include event pointer when zapping chat messages

Pass the message event as eventPointer when opening ZapWindow from
chat context menu. This enables:
- Event preview in the zap window
- Proper window title showing "Zap [username]"

* feat: add zap command reconstruction for Edit feature

Add zap case to command-reconstructor.ts so that clicking "Edit" on
a zap window title shows a complete command with:
- Recipient as npub
- Event pointer as nevent/naddr
- Custom tags with -T flags
- Relays with -r flags

This enables users to see and modify the full zap configuration.

* fix: separate eventPointer and addressPointer for proper zap tagging

- Refactor createZapRequest to use separate eventPointer (for e-tag)
  and addressPointer (for a-tag) instead of a union type
- Remove duplicate p-tag issue (only tag recipient, not event author)
- Remove duplicate e-tag issue (only one e-tag with relay hint if available)
- Update ZapConfig interface to include addressPointer field
- Update NIP-53 adapter to return addressPointer for live activity context
- Update ChatMessageContextMenu to pass addressPointer from zapConfig
- Update command-reconstructor to properly serialize addressPointer as -T a
- Update ZapWindow to pass addressPointer to createZapRequest

This ensures proper NIP-53 zap tagging: message author gets p-tag,
live activity gets a-tag, and message event gets e-tag (all separate).

* refactor: move eventPointer to ZapConfig for NIP-53 adapter

- Add eventPointer field to ZapConfig interface for message e-tag
- NIP-53 adapter now returns eventPointer from getZapConfig
- ChatMessageContextMenu uses eventPointer from zapConfig directly
- Remove goal logic from NIP-53 zap config (simplify for now)

This gives the adapter full control over zap configuration, including
which event to reference in the e-tag.

* fix: update zap-parser to return separate eventPointer and addressPointer

The ParsedZapCommand interface now properly separates:
- eventPointer: for regular events (nevent, note, hex ID) → e-tag
- addressPointer: for addressable events (naddr) → a-tag

This aligns with ZapWindowProps which expects separate fields,
fixing the issue where addressPointer from naddr was being
passed as eventPointer and ignored.

* feat: improve relay selection for zap requests with e+a tags

When both eventPointer and addressPointer are provided:
- Collect outbox relays from both semantic authors
- Include relay hints from both pointers
- Deduplicate and use combined relay set

Priority order:
1. Explicit params.relays (respects CLI -r flags)
2. Semantic author outbox relays + pointer relay hints
3. Sender read relays (fallback)
4. Aggregator relays (final fallback)

* fix: pass all zap props from WindowRenderer to ZapWindow

WindowRenderer was only passing recipientPubkey and eventPointer,
dropping addressPointer, customTags, and relays. This caused
CLI flags like -T (custom tags) and -r (relays) to be ignored.

Now all parsed zap command props flow through to ZapWindow
and subsequently to createZapRequest.

* refactor: let createZapRequest collect relays from both authors

Remove top-level relays from NIP-53 zapConfig so createZapRequest
can automatically collect outbox relays from both:
- eventPointer.author (message author / zap recipient)
- addressPointer.pubkey (stream host)

The relay hints in the pointers are still included via the
existing logic in createZapRequest.

* fix: deduplicate explicit relays in createZapRequest

Ensure params.relays is deduplicated before use, not just
the automatically collected relays. This handles cases where
CLI -r flags might specify duplicate relay URLs.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-19 12:16:51 +01:00

144 lines
3.9 KiB
TypeScript

import type { NostrEvent } from "@/types/nostr";
import type {
ParsedLiveActivity,
LiveParticipant,
LiveStatus,
} from "@/types/live-activity";
import { getTagValue } from "applesauce-core/helpers";
/**
* Helper to get all values for a given tag name
*/
function getTagValues(event: NostrEvent, tagName: string): string[] {
return event.tags.filter((t) => t[0] === tagName).map((t) => t[1] || "");
}
/**
* Parse a kind:30311 live activity event
*/
export function parseLiveActivity(event: NostrEvent): ParsedLiveActivity {
// Parse participants (p tags: [pubkey, relay?, role?, proof?])
const participants: LiveParticipant[] = event.tags
.filter((t) => t[0] === "p")
.map((t) => ({
pubkey: t[1],
relay: t[2] || undefined,
role: t[3] || "Participant",
proof: t[4] || undefined,
}));
// Parse numeric fields
const parseNum = (val?: string): number | undefined => {
return val ? parseInt(val, 10) : undefined;
};
return {
event,
identifier: getTagValue(event, "d") || "",
title: getTagValue(event, "title"),
summary: getTagValue(event, "summary"),
image: getTagValue(event, "image"),
streaming: getTagValue(event, "streaming"),
recording: getTagValue(event, "recording"),
starts: parseNum(getTagValue(event, "starts")),
ends: parseNum(getTagValue(event, "ends")),
status: getTagValue(event, "status") as LiveStatus | undefined,
currentParticipants: parseNum(getTagValue(event, "current_participants")),
totalParticipants: parseNum(getTagValue(event, "total_participants")),
participants,
hashtags: getTagValues(event, "t"),
relays: getTagValues(event, "relays"),
goal: getTagValue(event, "goal"),
lastUpdate: event.created_at || Date.now() / 1000,
};
}
/**
* Get live status with optional timeout detection
* Events without updates for 1hr may be considered ended
*/
export function getLiveStatus(
event: NostrEvent,
considerTimeout = true,
): LiveStatus {
const parsed = parseLiveActivity(event);
// Explicit status from tags
if (parsed.status) {
// If status is 'live' but hasn't been updated in 1hr, consider ended
if (parsed.status === "live" && considerTimeout) {
const now = Date.now() / 1000;
const oneHourAgo = now - 3600;
if (parsed.lastUpdate < oneHourAgo) {
return "ended";
}
}
return parsed.status;
}
// Infer status from timestamps
const now = Date.now() / 1000;
if (parsed.ends && now > parsed.ends) {
return "ended";
}
if (parsed.starts && now > parsed.starts) {
return "live";
}
return "planned";
}
/**
* Get the host of a live activity
* Returns the first participant with "Host" role, or event author as fallback
*/
export function getLiveHost(event: NostrEvent): string {
const parsed = parseLiveActivity(event);
const host = parsed.participants.find((p) => p.role.toLowerCase() === "host");
return host?.pubkey || event.pubkey;
}
/**
* Get streaming URL (if available)
*/
export function getStreamingUrl(event: NostrEvent): string | undefined {
return parseLiveActivity(event).streaming;
}
/**
* Get recording URL (if available)
*/
export function getRecordingUrl(event: NostrEvent): string | undefined {
return parseLiveActivity(event).recording;
}
/**
* Format start time as relative or absolute
*/
export function formatStartTime(
starts?: number,
status?: LiveStatus,
): string | undefined {
if (!starts) return undefined;
const now = Date.now() / 1000;
const diff = starts - now;
if (status === "planned" && diff > 0) {
// Future event - show countdown
const hours = Math.floor(diff / 3600);
const days = Math.floor(hours / 24);
if (days > 0) {
return `in ${days}d`;
} else if (hours > 0) {
return `in ${hours}h`;
} else {
const minutes = Math.floor(diff / 60);
return `in ${minutes}m`;
}
}
// Past event - show date
return new Date(starts * 1000).toLocaleDateString();
}