mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
Merge remote-tracking branch 'origin/main' into claude/add-client-tag-events-Sh4AQ
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