mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-08 06:27:17 +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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -549,3 +549,69 @@ export async function selectRelaysForFilter(
|
||||
isOptimized: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Maximum number of relays for interactions */
|
||||
const MAX_INTERACTION_RELAYS = 10;
|
||||
|
||||
/** Minimum relays per party for redundancy */
|
||||
const MIN_RELAYS_PER_PARTY = 3;
|
||||
|
||||
/**
|
||||
* Selects optimal relays for publishing an interaction event (reaction, reply, etc.)
|
||||
*
|
||||
* Strategy per NIP-65:
|
||||
* - Author's outbox relays: where we publish our content
|
||||
* - Target's inbox relays: where the target reads mentions/interactions
|
||||
* - Fallback aggregators if neither has preferences
|
||||
*
|
||||
* @param authorPubkey - Pubkey of the interaction author (person reacting/replying)
|
||||
* @param targetPubkey - Pubkey of the target (person being reacted to/replied to)
|
||||
* @returns Promise resolving to array of relay URLs
|
||||
*/
|
||||
export async function selectRelaysForInteraction(
|
||||
authorPubkey: string,
|
||||
targetPubkey: string,
|
||||
): Promise<string[]> {
|
||||
// Fetch relays in parallel
|
||||
const [authorOutbox, targetInbox] = await Promise.all([
|
||||
relayListCache.getOutboxRelays(authorPubkey),
|
||||
relayListCache.getInboxRelays(targetPubkey),
|
||||
]);
|
||||
|
||||
// Build relay list with priority ordering
|
||||
const relaySet = new Set<string>();
|
||||
|
||||
// Priority 1: Author's outbox relays (where we publish)
|
||||
const outboxRelays = authorOutbox || [];
|
||||
for (const relay of outboxRelays.slice(0, MIN_RELAYS_PER_PARTY)) {
|
||||
relaySet.add(relay);
|
||||
}
|
||||
|
||||
// Priority 2: Target's inbox relays (so they see it)
|
||||
const inboxRelays = targetInbox || [];
|
||||
for (const relay of inboxRelays.slice(0, MIN_RELAYS_PER_PARTY)) {
|
||||
relaySet.add(relay);
|
||||
}
|
||||
|
||||
// Add remaining author outbox relays
|
||||
for (const relay of outboxRelays.slice(MIN_RELAYS_PER_PARTY)) {
|
||||
if (relaySet.size >= MAX_INTERACTION_RELAYS) break;
|
||||
relaySet.add(relay);
|
||||
}
|
||||
|
||||
// Add remaining target inbox relays
|
||||
for (const relay of inboxRelays.slice(MIN_RELAYS_PER_PARTY)) {
|
||||
if (relaySet.size >= MAX_INTERACTION_RELAYS) break;
|
||||
relaySet.add(relay);
|
||||
}
|
||||
|
||||
// Fallback to aggregator relays if we don't have any
|
||||
if (relaySet.size === 0) {
|
||||
for (const relay of AGGREGATOR_RELAYS) {
|
||||
if (relaySet.size >= MAX_INTERACTION_RELAYS) break;
|
||||
relaySet.add(relay);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(relaySet);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user