From a530898b0707302d1a6d4fe96005fae9cde56cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Fri, 3 Apr 2026 10:58:03 +0200 Subject: [PATCH] perf: deduplicate event menu hooks and memoize kind renderers Extract shared useEventActions hook so EventMenu and EventContextMenu share a single set of hook subscriptions instead of duplicating them. Wrap callbacks in useCallback and memoize KindRenderer/DetailKindRenderer to reduce unnecessary re-renders in event feeds. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../nostr/kinds/BaseEventRenderer.tsx | 496 +++++++----------- src/components/nostr/kinds/index.tsx | 27 +- 2 files changed, 210 insertions(+), 313 deletions(-) diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index d822428..791fdfd 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useCallback } from "react"; import { NostrEvent } from "@/types/nostr"; import { UserName } from "../UserName"; import { @@ -129,17 +129,10 @@ function ReplyPreview({ */ /** - * Event menu - universal actions for any event + * Shared event action state — used by both EventMenu and EventContextMenu + * to avoid duplicate hook subscriptions when both are rendered together. */ -export function EventMenu({ - event, - onReactClick, - canSign, -}: { - event: NostrEvent; - onReactClick?: () => void; - canSign?: boolean; -}) { +function useEventActions(event: NostrEvent) { const addWindow = useAddWindow(); const { copy, copied } = useCopy(); const [jsonDialogOpen, setJsonDialogOpen] = useState(false); @@ -149,11 +142,9 @@ export function EventMenu({ ); const favorited = favoriteConfig ? isFavorite(event) : false; - const openEventDetail = () => { + const openEventDetail = useCallback(() => { let pointer; - // For replaceable/parameterized replaceable events, use AddressPointer if (isAddressableKind(event.kind)) { - // Find d-tag for identifier const dTag = getTagValue(event, "d") || ""; pointer = { kind: event.kind, @@ -161,51 +152,43 @@ export function EventMenu({ identifier: dTag, }; } else { - // For regular events, use EventPointer - pointer = { - id: event.id, - }; + pointer = { id: event.id }; } - addWindow("open", { pointer }); - }; + }, [event, addWindow]); - const copyEventId = () => { - // Get relay hints from where the event has been seen + const copyEventId = useCallback(() => { const seenRelaysSet = getSeenRelays(event); const relays = seenRelaysSet ? Array.from(seenRelaysSet) : []; - // For replaceable/parameterized replaceable events, encode as naddr if (isAddressableKind(event.kind)) { - // Find d-tag for identifier const dTag = getTagValue(event, "d") || ""; - const naddr = nip19.naddrEncode({ - kind: event.kind, - pubkey: event.pubkey, - identifier: dTag, - relays: relays, - }); - copy(naddr); + copy( + nip19.naddrEncode({ + kind: event.kind, + pubkey: event.pubkey, + identifier: dTag, + relays, + }), + ); } else { - // For regular events, encode as nevent - const nevent = nip19.neventEncode({ - id: event.id, - author: event.pubkey, - relays: relays, - }); - copy(nevent); + copy( + nip19.neventEncode({ + id: event.id, + author: event.pubkey, + relays, + }), + ); } - }; + }, [event, copy]); - const viewEventJson = () => { + const viewEventJson = useCallback(() => { setJsonDialogOpen(true); - }; + }, []); - const zapEvent = () => { - // Get semantic author (e.g., zapper for zaps, host for streams) + const zapEvent = useCallback(() => { const recipientPubkey = getSemanticAuthor(event); - // For addressable events, use addressPointer; for regular events, use eventPointer if (isAddressableKind(event.kind)) { const dTag = getTagValue(event, "d") || ""; addWindow("zap", { @@ -223,15 +206,13 @@ export function EventMenu({ eventPointer: { id: event.id }, }); } - }; + }, [event, addWindow]); - const openChatWindow = () => { - // Only kind 1 notes support NIP-10 thread chat + const openChatWindow = useCallback(() => { if (event.kind === 1) { const seenRelaysSet = getSeenRelays(event); const relays = seenRelaysSet ? Array.from(seenRelaysSet) : []; - // Open chat with NIP-10 thread protocol addWindow("chat", { protocol: "nip-10", identifier: { @@ -246,8 +227,117 @@ export function EventMenu({ }, }); } - }; + }, [event, addWindow]); + const handleToggleFavorite = useCallback(() => { + toggleFavorite(event); + }, [toggleFavorite, event]); + + return { + openEventDetail, + copyEventId, + viewEventJson, + zapEvent, + openChatWindow, + handleToggleFavorite, + copied, + jsonDialogOpen, + setJsonDialogOpen, + favoriteConfig, + favorited, + isUpdating, + }; +} + +type EventActions = ReturnType; + +interface EventMenuItemsProps { + event: NostrEvent; + actions: EventActions; + onReactClick?: () => void; + canSign?: boolean; +} + +/** + * Shared menu items rendered as either DropdownMenuItems or ContextMenuItems + */ +function EventMenuItems({ + Item, + Separator, + event, + actions, + onReactClick, + canSign, +}: EventMenuItemsProps & { + Item: typeof DropdownMenuItem; + Separator: typeof DropdownMenuSeparator; +}) { + return ( + <> + + + Open + + + + Zap + + {event.kind === 1 && ( + + + Chat + + )} + {canSign && onReactClick && ( + + + React + + )} + {canSign && actions.favoriteConfig && ( + + {actions.isUpdating ? ( + + ) : ( + + )} + {actions.favorited ? "Unbookmark" : "Bookmark"} + + )} + + + {actions.copied ? ( + + ) : ( + + )} + {actions.copied ? "Copied!" : "Copy ID"} + + + + View JSON + + + ); +} + +/** + * Event menu - universal actions for any event (dropdown trigger) + */ +export function EventMenu({ + event, + actions, + onReactClick, + canSign, +}: EventMenuItemsProps) { return ( @@ -256,251 +346,42 @@ export function EventMenu({ - - - Open - - - - Zap - - {event.kind === 1 && ( - - - Chat - - )} - {canSign && onReactClick && ( - - - React - - )} - {canSign && favoriteConfig && ( - toggleFavorite(event)} - disabled={isUpdating} - > - {isUpdating ? ( - - ) : ( - - )} - {favorited ? "Unbookmark" : "Bookmark"} - - )} - - - {copied ? ( - - ) : ( - - )} - {copied ? "Copied!" : "Copy ID"} - - - - View JSON - + - ); } /** * Event context menu - same actions as EventMenu but triggered by right-click - * Used for generic event renderers that don't have a built-in menu button */ export function EventContextMenu({ event, children, + actions, onReactClick, canSign, -}: { - event: NostrEvent; - children: React.ReactNode; - onReactClick?: () => void; - canSign?: boolean; -}) { - const addWindow = useAddWindow(); - const { copy, copied } = useCopy(); - const [jsonDialogOpen, setJsonDialogOpen] = useState(false); - const favoriteConfig = getFavoriteConfig(event.kind); - const { isFavorite, toggleFavorite, isUpdating } = useFavoriteList( - favoriteConfig ?? FALLBACK_FAVORITE_CONFIG, - ); - const favorited = favoriteConfig ? isFavorite(event) : false; - - const openEventDetail = () => { - let pointer; - // For replaceable/parameterized replaceable events, use AddressPointer - if (isAddressableKind(event.kind)) { - // Find d-tag for identifier - const dTag = getTagValue(event, "d") || ""; - pointer = { - kind: event.kind, - pubkey: event.pubkey, - identifier: dTag, - }; - } else { - // For regular events, use EventPointer - pointer = { - id: event.id, - }; - } - - addWindow("open", { pointer }); - }; - - const copyEventId = () => { - // Get relay hints from where the event has been seen - const seenRelaysSet = getSeenRelays(event); - const relays = seenRelaysSet ? Array.from(seenRelaysSet) : []; - - // For replaceable/parameterized replaceable events, encode as naddr - if (isAddressableKind(event.kind)) { - // Find d-tag for identifier - const dTag = getTagValue(event, "d") || ""; - const naddr = nip19.naddrEncode({ - kind: event.kind, - pubkey: event.pubkey, - identifier: dTag, - relays: relays, - }); - copy(naddr); - } else { - // For regular events, encode as nevent - const nevent = nip19.neventEncode({ - id: event.id, - author: event.pubkey, - relays: relays, - }); - copy(nevent); - } - }; - - const viewEventJson = () => { - setJsonDialogOpen(true); - }; - - const zapEvent = () => { - // Get semantic author (e.g., zapper for zaps, host for streams) - const recipientPubkey = getSemanticAuthor(event); - - // For addressable events, use addressPointer; for regular events, use eventPointer - if (isAddressableKind(event.kind)) { - const dTag = getTagValue(event, "d") || ""; - addWindow("zap", { - recipientPubkey, - eventPointer: { id: event.id }, - addressPointer: { - kind: event.kind, - pubkey: event.pubkey, - identifier: dTag, - }, - }); - } else { - addWindow("zap", { - recipientPubkey, - eventPointer: { id: event.id }, - }); - } - }; - - const openChatWindow = () => { - // Only kind 1 notes support NIP-10 thread chat - if (event.kind === 1) { - const seenRelaysSet = getSeenRelays(event); - const relays = seenRelaysSet ? Array.from(seenRelaysSet) : []; - - // Open chat with NIP-10 thread protocol - addWindow("chat", { - protocol: "nip-10", - identifier: { - type: "thread", - value: { - id: event.id, - relays, - author: event.pubkey, - kind: event.kind, - }, - relays, - }, - }); - } - }; - +}: EventMenuItemsProps & { children: React.ReactNode }) { return ( {children} - - - Open - - - - Zap - - {event.kind === 1 && ( - - - Chat - - )} - {canSign && onReactClick && ( - - - React - - )} - {canSign && favoriteConfig && ( - toggleFavorite(event)} - disabled={isUpdating} - > - {isUpdating ? ( - - ) : ( - - )} - {favorited ? "Unbookmark" : "Bookmark"} - - )} - - - {copied ? ( - - ) : ( - - )} - {copied ? "Copied!" : "Copy ID"} - - - - View JSON - + - ); } @@ -585,57 +466,55 @@ export function BaseEventContainer({ const { canSign, signer, pubkey } = useAccount(); const { settings } = useSettings(); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); + const actions = useEventActions(event); - const handleReactClick = () => { + const handleReactClick = useCallback(() => { setEmojiPickerOpen(true); - }; + }, []); - const handleEmojiSelect = async (emoji: string, customEmoji?: EmojiTag) => { - if (!signer || !pubkey) return; + const handleEmojiSelect = useCallback( + async (emoji: string, customEmoji?: EmojiTag) => { + if (!signer || !pubkey) return; - try { - const factory = new EventFactory(); - factory.setSigner(signer); + try { + const factory = new EventFactory(); + factory.setSigner(signer); - const emojiArg = customEmoji - ? { - shortcode: customEmoji.shortcode, - url: customEmoji.url, - address: customEmoji.address, - } - : emoji; + const emojiArg = customEmoji + ? { + shortcode: customEmoji.shortcode, + url: customEmoji.url, + address: customEmoji.address, + } + : emoji; - const draft = await factory.create(ReactionBlueprint, event, emojiArg); - const signed = await factory.sign(draft); + 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); - } - }; + const targetPubkey = getSemanticAuthor(event); + const relays = await selectRelaysForInteraction(pubkey, targetPubkey); + await publishEventToRelays(signed, relays); + } catch (err) { + console.error("[BaseEventContainer] Failed to send reaction:", err); + } + }, + [signer, pubkey, event], + ); - // Format relative time for display const relativeTime = formatTimestamp( event.created_at, "relative", locale.locale, ); - // Format absolute timestamp for hover (ISO-8601 style) const absoluteTime = formatTimestamp( event.created_at, "absolute", locale.locale, ); - // Use author override if provided, otherwise use event author const displayPubkey = authorOverride?.pubkey || event.pubkey; - // Get client tag if present: ["client", "", "<31990:pubkey:d-tag>"] const clientTag = event.tags.find((t) => t[0] === "client"); const clientName = clientTag?.[1]; const clientAddress = clientTag?.[2]; @@ -649,6 +528,7 @@ export function BaseEventContainer({ <> @@ -665,6 +545,7 @@ export function BaseEventContainer({ @@ -679,6 +560,11 @@ export function BaseEventContainer({ /> + > = { * Shows basic event info with raw content * Right-click or tap menu button to access event menu */ -function DefaultKindRenderer({ event }: BaseEventProps) { +const DefaultKindRenderer = memo(function DefaultKindRenderer({ + event, +}: BaseEventProps) { return (
@@ -320,13 +323,13 @@ function DefaultKindRenderer({ event }: BaseEventProps) {
); -} +}); /** * Main KindRenderer component * Automatically selects the appropriate renderer based on event kind */ -export function KindRenderer({ +export const KindRenderer = memo(function KindRenderer({ event, depth = 0, }: { @@ -335,7 +338,7 @@ export function KindRenderer({ }) { const Renderer = kindRenderers[event.kind] || DefaultKindRenderer; return ; -} +}); /** * Registry of kind-specific detail renderers (for detail views) @@ -426,19 +429,27 @@ const detailRenderers: Record< * Default detail renderer for kinds without custom detail implementations * Falls back to the feed renderer */ -function DefaultDetailRenderer({ event }: { event: NostrEvent }) { +const DefaultDetailRenderer = memo(function DefaultDetailRenderer({ + event, +}: { + event: NostrEvent; +}) { return ; -} +}); /** * Main DetailKindRenderer component * Automatically selects the appropriate detail renderer based on event kind * Falls back to feed renderer if no detail renderer exists */ -export function DetailKindRenderer({ event }: { event: NostrEvent }) { +export const DetailKindRenderer = memo(function DetailKindRenderer({ + event, +}: { + event: NostrEvent; +}) { const Renderer = detailRenderers[event.kind] || DefaultDetailRenderer; return ; -} +}); /** * Export kind renderers registry for dynamic kind detection