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>
284 lines
8.7 KiB
TypeScript
284 lines
8.7 KiB
TypeScript
import { Component, ReactNode, Suspense, lazy } from "react";
|
|
import { AlertCircle, Loader2 } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { WindowInstance } from "@/types/app";
|
|
|
|
// Lazy load all viewer components for better code splitting
|
|
const NipRenderer = lazy(() =>
|
|
import("./NipRenderer").then((m) => ({ default: m.NipRenderer })),
|
|
);
|
|
const ManPage = lazy(() => import("./ManPage"));
|
|
const ReqViewer = lazy(() => import("./ReqViewer"));
|
|
const EventDetailViewer = lazy(() =>
|
|
import("./EventDetailViewer").then((m) => ({ default: m.EventDetailViewer })),
|
|
);
|
|
const ProfileViewer = lazy(() =>
|
|
import("./ProfileViewer").then((m) => ({ default: m.ProfileViewer })),
|
|
);
|
|
const EncodeViewer = lazy(() => import("./EncodeViewer"));
|
|
const DecodeViewer = lazy(() => import("./DecodeViewer"));
|
|
const RelayViewer = lazy(() =>
|
|
import("./RelayViewer").then((m) => ({ default: m.RelayViewer })),
|
|
);
|
|
const KindRenderer = lazy(() => import("./KindRenderer"));
|
|
const KindsViewer = lazy(() => import("./KindsViewer"));
|
|
const NipsViewer = lazy(() => import("./NipsViewer"));
|
|
const DebugViewer = lazy(() =>
|
|
import("./DebugViewer").then((m) => ({ default: m.DebugViewer })),
|
|
);
|
|
const ConnViewer = lazy(() => import("./ConnViewer"));
|
|
const ChatViewer = lazy(() =>
|
|
import("./ChatViewer").then((m) => ({ default: m.ChatViewer })),
|
|
);
|
|
const GroupListViewer = lazy(() =>
|
|
import("./GroupListViewer").then((m) => ({ default: m.GroupListViewer })),
|
|
);
|
|
const SpellsViewer = lazy(() =>
|
|
import("./SpellsViewer").then((m) => ({ default: m.SpellsViewer })),
|
|
);
|
|
const SpellbooksViewer = lazy(() =>
|
|
import("./SpellbooksViewer").then((m) => ({ default: m.SpellbooksViewer })),
|
|
);
|
|
const BlossomViewer = lazy(() =>
|
|
import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })),
|
|
);
|
|
const WalletViewer = lazy(() => import("./WalletViewer"));
|
|
const ZapWindow = lazy(() =>
|
|
import("./ZapWindow").then((m) => ({ default: m.ZapWindow })),
|
|
);
|
|
const CountViewer = lazy(() => import("./CountViewer"));
|
|
|
|
// Loading fallback component
|
|
function ViewerLoading() {
|
|
return (
|
|
<div className="h-full w-full flex items-center justify-center">
|
|
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
|
<Loader2 className="h-8 w-8 animate-spin" />
|
|
<p className="text-sm">Loading...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface WindowRendererProps {
|
|
window: WindowInstance;
|
|
onClose: () => void;
|
|
}
|
|
|
|
interface WindowErrorBoundaryState {
|
|
hasError: boolean;
|
|
error?: Error;
|
|
}
|
|
|
|
class WindowErrorBoundary extends Component<
|
|
{ children: ReactNode; windowTitle: string; onClose: () => void },
|
|
WindowErrorBoundaryState
|
|
> {
|
|
constructor(props: {
|
|
children: ReactNode;
|
|
windowTitle: string;
|
|
onClose: () => void;
|
|
}) {
|
|
super(props);
|
|
this.state = { hasError: false };
|
|
}
|
|
|
|
static getDerivedStateFromError(error: Error): WindowErrorBoundaryState {
|
|
return { hasError: true, error };
|
|
}
|
|
|
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
|
console.error(
|
|
`Window "${this.props.windowTitle}" crashed:`,
|
|
error,
|
|
errorInfo,
|
|
);
|
|
}
|
|
|
|
render() {
|
|
if (this.state.hasError) {
|
|
return (
|
|
<div className="p-4">
|
|
<div className="border border-red-500 bg-red-50 dark:bg-red-950 rounded-md p-4">
|
|
<div className="flex items-start gap-3">
|
|
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
|
<div className="flex-1 space-y-2">
|
|
<h3 className="font-semibold text-red-900 dark:text-red-100">
|
|
Window Crashed
|
|
</h3>
|
|
<p className="text-sm text-red-800 dark:text-red-200">
|
|
{this.state.error?.message ||
|
|
"An unexpected error occurred in this window."}
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={this.props.onClose}
|
|
className="mt-2"
|
|
>
|
|
Close Window
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
|
let content: ReactNode;
|
|
|
|
try {
|
|
switch (window.appId) {
|
|
case "nip":
|
|
content = <NipRenderer nipId={window.props.number} />;
|
|
break;
|
|
case "kind":
|
|
content = <KindRenderer kind={parseInt(window.props.number)} />;
|
|
break;
|
|
case "kinds":
|
|
content = <KindsViewer />;
|
|
break;
|
|
case "nips":
|
|
content = <NipsViewer />;
|
|
break;
|
|
case "man":
|
|
content = <ManPage cmd={window.props.cmd} />;
|
|
break;
|
|
case "req":
|
|
content = (
|
|
<ReqViewer
|
|
filter={window.props.filter}
|
|
relays={window.props.relays}
|
|
closeOnEose={window.props.closeOnEose}
|
|
view={window.props.view}
|
|
nip05Authors={window.props.nip05Authors}
|
|
nip05PTags={window.props.nip05PTags}
|
|
domainAuthors={window.props.domainAuthors}
|
|
domainPTags={window.props.domainPTags}
|
|
needsAccount={window.props.needsAccount}
|
|
/>
|
|
);
|
|
break;
|
|
case "count":
|
|
content = (
|
|
<CountViewer
|
|
filter={window.props.filter}
|
|
relays={window.props.relays}
|
|
needsAccount={window.props.needsAccount}
|
|
/>
|
|
);
|
|
break;
|
|
case "open":
|
|
content = <EventDetailViewer pointer={window.props.pointer} />;
|
|
break;
|
|
case "profile":
|
|
content = <ProfileViewer pubkey={window.props.pubkey} />;
|
|
break;
|
|
case "encode":
|
|
content = <EncodeViewer args={window.props.args} />;
|
|
break;
|
|
case "decode":
|
|
content = <DecodeViewer args={window.props.args} />;
|
|
break;
|
|
case "relay":
|
|
content = <RelayViewer url={window.props.url} />;
|
|
break;
|
|
case "debug":
|
|
content = <DebugViewer />;
|
|
break;
|
|
case "conn":
|
|
content = <ConnViewer />;
|
|
break;
|
|
case "chat":
|
|
// Check if this is a group list (kind 10009) - render multi-room interface
|
|
if (window.props.identifier?.type === "group-list") {
|
|
content = <GroupListViewer identifier={window.props.identifier} />;
|
|
} else {
|
|
content = (
|
|
<ChatViewer
|
|
protocol={window.props.protocol}
|
|
identifier={window.props.identifier}
|
|
customTitle={window.customTitle}
|
|
/>
|
|
);
|
|
}
|
|
break;
|
|
case "spells":
|
|
content = <SpellsViewer />;
|
|
break;
|
|
case "spellbooks":
|
|
content = <SpellbooksViewer />;
|
|
break;
|
|
case "blossom":
|
|
content = (
|
|
<BlossomViewer
|
|
subcommand={window.props.subcommand}
|
|
serverUrl={window.props.serverUrl}
|
|
pubkey={window.props.pubkey}
|
|
sourceUrl={window.props.sourceUrl}
|
|
targetServer={window.props.targetServer}
|
|
sha256={window.props.sha256}
|
|
/>
|
|
);
|
|
break;
|
|
case "wallet":
|
|
content = <WalletViewer />;
|
|
break;
|
|
case "zap":
|
|
content = (
|
|
<ZapWindow
|
|
recipientPubkey={window.props.recipientPubkey}
|
|
eventPointer={window.props.eventPointer}
|
|
addressPointer={window.props.addressPointer}
|
|
customTags={window.props.customTags}
|
|
relays={window.props.relays}
|
|
onClose={onClose}
|
|
/>
|
|
);
|
|
break;
|
|
default:
|
|
content = (
|
|
<div className="p-4 text-muted-foreground">
|
|
Unknown app: {window.appId}
|
|
</div>
|
|
);
|
|
}
|
|
} catch (error) {
|
|
content = (
|
|
<div className="p-4">
|
|
<div className="border border-red-500 bg-red-50 dark:bg-red-950 rounded-md p-4">
|
|
<div className="flex items-start gap-3">
|
|
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
|
<div className="flex-1">
|
|
<h3 className="font-semibold text-red-900 dark:text-red-100">
|
|
Failed to render window
|
|
</h3>
|
|
<p className="text-sm text-red-800 dark:text-red-200 mt-1">
|
|
{error instanceof Error
|
|
? error.message
|
|
: "An unexpected error occurred"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<WindowErrorBoundary
|
|
windowTitle={window.title || window.appId.toUpperCase()}
|
|
onClose={onClose}
|
|
>
|
|
<Suspense fallback={<ViewerLoading />}>
|
|
<div className="h-full w-full overflow-auto">{content}</div>
|
|
</Suspense>
|
|
</WindowErrorBoundary>
|
|
);
|
|
}
|