mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 23:47:12 +02:00
* 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>
144 lines
3.9 KiB
TypeScript
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();
|
|
}
|