Files
grimoire/src/components/WindowRenderer.tsx
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

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>
);
}