mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 10:41:21 +02:00
Add React option to generic event menu (#189)
* feat: add React option to event menu for emoji reactions Add emoji reaction capability to the generic event menu (dropdown and right-click context menu). When logged in with a signing account, users can now react to any event with unicode or custom NIP-30 emoji. - Add React menu item after Chat in EventMenu and EventContextMenu - Integrate EmojiPickerDialog from chat components - Use ReactionBlueprint from applesauce-common for NIP-25 reactions - Publish reactions to user's outbox relays via publishEvent() - Hidden when user cannot sign (read-only or not logged in) * feat: use NIP-65 relay selection for reactions Add interaction relay selection utility following the NIP-65 outbox model: - Author's outbox (write) relays: where we publish our events - Target's inbox (read) relays: so the target sees the interaction This ensures reactions reach the intended recipient according to their relay preferences, similar to how zap relay selection works. New file: src/lib/interaction-relay-selection.ts - selectInteractionRelays() for full result with sources - getInteractionRelays() convenience wrapper * refactor: consolidate interaction relay selection into relay-selection service Move selectRelaysForInteraction() into the existing relay-selection.ts service to avoid fragmentation. The service already has the infrastructure for relay list caching, health filtering, and fallback logic. - Add selectRelaysForInteraction() to src/services/relay-selection.ts - Update BaseEventRenderer to import from consolidated location - Remove separate src/lib/interaction-relay-selection.ts file * fix: use semantic author for reaction relay selection Use getSemanticAuthor() to determine the target pubkey for relay selection. This ensures reactions to zaps notify the zapper (not the lightning service) and reactions to streams notify the host. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -23,10 +23,13 @@ import {
|
||||
ExternalLink,
|
||||
Zap,
|
||||
MessageSquare,
|
||||
SmilePlus,
|
||||
} from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import { JsonViewer } from "@/components/JsonViewer";
|
||||
import { EmojiPickerDialog } from "@/components/chat/EmojiPickerDialog";
|
||||
import { formatTimestamp } from "@/hooks/useLocale";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
@@ -36,6 +39,11 @@ import { EventFooter } from "@/components/EventFooter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isAddressableKind } from "@/lib/nostr-kinds";
|
||||
import { getSemanticAuthor } from "@/lib/semantic-author";
|
||||
import { EventFactory } from "applesauce-core/event-factory";
|
||||
import { ReactionBlueprint } from "applesauce-common/blueprints";
|
||||
import { publishEventToRelays } from "@/services/hub";
|
||||
import { selectRelaysForInteraction } from "@/services/relay-selection";
|
||||
import type { EmojiTag } from "@/lib/emoji-helpers";
|
||||
|
||||
/**
|
||||
* Universal event properties and utilities shared across all kind renderers
|
||||
@@ -115,7 +123,15 @@ function ReplyPreview({
|
||||
/**
|
||||
* Event menu - universal actions for any event
|
||||
*/
|
||||
export function EventMenu({ event }: { event: NostrEvent }) {
|
||||
export function EventMenu({
|
||||
event,
|
||||
onReactClick,
|
||||
canSign,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
onReactClick?: () => void;
|
||||
canSign?: boolean;
|
||||
}) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const { copy, copied } = useCopy();
|
||||
const [jsonDialogOpen, setJsonDialogOpen] = useState(false);
|
||||
@@ -241,6 +257,12 @@ export function EventMenu({ event }: { event: NostrEvent }) {
|
||||
Chat
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canSign && onReactClick && (
|
||||
<DropdownMenuItem onClick={onReactClick}>
|
||||
<SmilePlus className="size-4 mr-2" />
|
||||
React
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={copyEventId}>
|
||||
{copied ? (
|
||||
@@ -272,9 +294,13 @@ export function EventMenu({ event }: { event: NostrEvent }) {
|
||||
export function EventContextMenu({
|
||||
event,
|
||||
children,
|
||||
onReactClick,
|
||||
canSign,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
children: React.ReactNode;
|
||||
onReactClick?: () => void;
|
||||
canSign?: boolean;
|
||||
}) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const { copy, copied } = useCopy();
|
||||
@@ -397,6 +423,12 @@ export function EventContextMenu({
|
||||
Chat
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{canSign && onReactClick && (
|
||||
<ContextMenuItem onClick={onReactClick}>
|
||||
<SmilePlus className="size-4 mr-2" />
|
||||
React
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={copyEventId}>
|
||||
{copied ? (
|
||||
@@ -498,6 +530,36 @@ export function BaseEventContainer({
|
||||
};
|
||||
}) {
|
||||
const { locale, addWindow } = useGrimoire();
|
||||
const { canSign, signer, pubkey } = useAccount();
|
||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
|
||||
|
||||
const handleReactClick = () => {
|
||||
setEmojiPickerOpen(true);
|
||||
};
|
||||
|
||||
const handleEmojiSelect = async (emoji: string, customEmoji?: EmojiTag) => {
|
||||
if (!signer || !pubkey) return;
|
||||
|
||||
try {
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(signer);
|
||||
|
||||
const emojiArg = customEmoji
|
||||
? { shortcode: customEmoji.shortcode, url: customEmoji.url }
|
||||
: emoji;
|
||||
|
||||
const draft = await factory.create(ReactionBlueprint, event, emojiArg);
|
||||
const signed = await factory.sign(draft);
|
||||
|
||||
// Select relays per NIP-65: author's outbox + target's inbox
|
||||
// Use semantic author (e.g., zapper for zaps, host for streams)
|
||||
const targetPubkey = getSemanticAuthor(event);
|
||||
const relays = await selectRelaysForInteraction(pubkey, targetPubkey);
|
||||
await publishEventToRelays(signed, relays);
|
||||
} catch (err) {
|
||||
console.error("[BaseEventContainer] Failed to send reaction:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Format relative time for display
|
||||
const relativeTime = formatTimestamp(
|
||||
@@ -534,38 +596,53 @@ export function BaseEventContainer({
|
||||
};
|
||||
|
||||
return (
|
||||
<EventContextMenu event={event}>
|
||||
<div className="flex flex-col gap-2 p-3 border-b border-border/50 last:border-0">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-row gap-2 items-baseline">
|
||||
<EventAuthor pubkey={displayPubkey} />
|
||||
<span
|
||||
className="text-xs text-muted-foreground cursor-help"
|
||||
title={absoluteTime}
|
||||
>
|
||||
{relativeTime}
|
||||
</span>
|
||||
{clientName && (
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
via{" "}
|
||||
{clientAppPointer ? (
|
||||
<button
|
||||
onClick={handleClientClick}
|
||||
className="hover:underline hover:text-foreground cursor-crosshair"
|
||||
>
|
||||
{clientName}
|
||||
</button>
|
||||
) : (
|
||||
clientName
|
||||
)}
|
||||
<>
|
||||
<EventContextMenu
|
||||
event={event}
|
||||
onReactClick={handleReactClick}
|
||||
canSign={canSign}
|
||||
>
|
||||
<div className="flex flex-col gap-2 p-3 border-b border-border/50 last:border-0">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-row gap-2 items-baseline">
|
||||
<EventAuthor pubkey={displayPubkey} />
|
||||
<span
|
||||
className="text-xs text-muted-foreground cursor-help"
|
||||
title={absoluteTime}
|
||||
>
|
||||
{relativeTime}
|
||||
</span>
|
||||
)}
|
||||
{clientName && (
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
via{" "}
|
||||
{clientAppPointer ? (
|
||||
<button
|
||||
onClick={handleClientClick}
|
||||
className="hover:underline hover:text-foreground cursor-crosshair"
|
||||
>
|
||||
{clientName}
|
||||
</button>
|
||||
) : (
|
||||
clientName
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<EventMenu
|
||||
event={event}
|
||||
onReactClick={handleReactClick}
|
||||
canSign={canSign}
|
||||
/>
|
||||
</div>
|
||||
<EventMenu event={event} />
|
||||
{children}
|
||||
<EventFooter event={event} />
|
||||
</div>
|
||||
{children}
|
||||
<EventFooter event={event} />
|
||||
</div>
|
||||
</EventContextMenu>
|
||||
</EventContextMenu>
|
||||
<EmojiPickerDialog
|
||||
open={emojiPickerOpen}
|
||||
onOpenChange={setEmojiPickerOpen}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user