fix: avoid unnecesary re-renders

This commit is contained in:
Alejandro Gómez
2026-02-26 16:23:47 +01:00
parent d630a72409
commit 41a1009166
45 changed files with 301 additions and 254 deletions

View File

@@ -47,7 +47,7 @@ import { RelaysDropdown } from "./chat/RelaysDropdown";
import { MessageReactions } from "./chat/MessageReactions";
import { StatusBadge } from "./live/StatusBadge";
import { ChatMessageContextMenu } from "./chat/ChatMessageContextMenu";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { Button } from "./ui/button";
import LoginDialog from "./nostr/LoginDialog";
import {
@@ -512,7 +512,7 @@ export function ChatViewer({
customTitle,
headerPrefix,
}: ChatViewerProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
// Get active account with signing capability
const { pubkey, canSign, signer } = useAccount();

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { manPages } from "@/types/man";
import { AppId } from "@/types/app";
@@ -21,7 +21,7 @@ export default function Command({
commandLine,
}: CommandProps) {
const [showTooltip, setShowTooltip] = useState(false);
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const handleClick = async () => {
if (commandLine) {

View File

@@ -6,7 +6,7 @@ import {
reencodeWithRelays,
type DecodedData,
} from "@/lib/decode-parser";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { useCopy } from "../hooks/useCopy";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
@@ -18,7 +18,7 @@ interface DecodeViewerProps {
}
export default function DecodeViewer({ args }: DecodeViewerProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const { copy, copied } = useCopy();
const [relays, setRelays] = useState<string[]>([]);
const [newRelay, setNewRelay] = useState("");

View File

@@ -2,7 +2,7 @@ import { NostrEvent } from "@/types/nostr";
import { KindBadge } from "./KindBadge";
import { Wifi } from "lucide-react";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { getKindName } from "@/constants/kinds";
import {
DropdownMenu,
@@ -22,7 +22,7 @@ interface EventFooterProps {
* Right: Relay count dropdown
*/
export function EventFooter({ event }: EventFooterProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
// Get relays this event was seen on
const seenRelaysSet = getSeenRelays(event);

View File

@@ -1,6 +1,6 @@
import { getKindInfo } from "@/constants/kinds";
import { cn } from "@/lib/utils";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
interface KindBadgeProps {
kind: number;
@@ -23,7 +23,7 @@ export function KindBadge({
iconClassname = "text-muted-foreground",
clickable = false,
}: KindBadgeProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const kindInfo = getKindInfo(kind);
const Icon = kindInfo?.icon;

View File

@@ -1,5 +1,5 @@
import { manPages } from "@/types/man";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { CenteredContent } from "./ui/CenteredContent";
import { cn } from "@/lib/utils";
import { Button } from "./ui/button";
@@ -22,7 +22,7 @@ export function ExecutableCommand({
children: React.ReactNode;
spellId?: string;
}) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const handleClick = async () => {
const parts = commandLine.trim().split(/\s+/);

View File

@@ -1,5 +1,5 @@
import { getNIPInfo } from "../lib/nip-icons";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { isNipDeprecated } from "@/constants/nips";
export interface NIPBadgeProps {
@@ -19,7 +19,7 @@ export function NIPBadge({
showName = true,
showNIPPrefix = true,
}: NIPBadgeProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const nipInfo = getNIPInfo(nipNumber);
const name = nipInfo?.name || `NIP-${nipNumber}`;
const description =

View File

@@ -4,7 +4,7 @@ import { VALID_NIPS, NIP_TITLES } from "@/constants/nips";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { NIPBadge } from "./NIPBadge";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { CenteredContent } from "./ui/CenteredContent";
/**
@@ -14,7 +14,7 @@ import { CenteredContent } from "./ui/CenteredContent";
export default function NipsViewer() {
const [search, setSearch] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
// Autofocus on mount
useEffect(() => {

View File

@@ -25,7 +25,7 @@ import {
import { Virtuoso } from "react-virtuoso";
import { WindowInstance } from "@/types/app";
import { useReqTimelineEnhanced } from "@/hooks/useReqTimelineEnhanced";
import { useGrimoire } from "@/core/state";
import { useAddWindow, useGrimoire } from "@/core/state";
import { useRelayState } from "@/hooks/useRelayState";
import { useOutboxRelays } from "@/hooks/useOutboxRelays";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
@@ -99,7 +99,7 @@ const MemoizedFeedEvent = memo(
* Shows truncated ID with click to open
*/
function EventIdPreview({ eventId }: { eventId: string }) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const handleClick = useCallback(() => {
addWindow("open", { pointer: { id: eventId } });
@@ -727,7 +727,8 @@ export default function ReqViewer({
title = "nostr-events",
windowId = "pop-up",
}: ReqViewerProps) {
const { state, addWindow, updateWindow } = useGrimoire();
const { state, updateWindow } = useGrimoire();
const addWindow = useAddWindow();
const { relays: relayStates } = useRelayState();
// Get active account for alias resolution

View File

@@ -31,7 +31,7 @@ import type { LocalSpell } from "@/services/db";
import { ExecutableCommand } from "./ManPage";
import { PublishSpellAction } from "@/actions/publish-spell";
import { DeleteEventAction } from "@/actions/delete-event";
import { useGrimoire } from "@/core/state";
import { useAddWindow, useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
import { KindBadge } from "@/components/KindBadge";
import { parseReqCommand } from "@/lib/req-parser";
@@ -47,7 +47,7 @@ interface SpellCardProps {
}
function SpellCard({ spell, onDelete, onPublish }: SpellCardProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const [isPublishing, setIsPublishing] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const displayName = spell.name || spell.alias || "Untitled Spell";

View File

@@ -20,7 +20,7 @@ import {
Smile,
Zap,
} from "lucide-react";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { useCopy } from "@/hooks/useCopy";
import { JsonViewer } from "@/components/JsonViewer";
import { KindBadge } from "@/components/KindBadge";
@@ -59,7 +59,7 @@ export function ChatMessageContextMenu({
adapter,
message,
}: ChatMessageContextMenuProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const { copy, copied } = useCopy();
const [jsonDialogOpen, setJsonDialogOpen] = useState(false);
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);

View File

@@ -1,7 +1,7 @@
import { memo, useCallback } from "react";
import type { NostrEvent } from "@/types/nostr";
import { kinds } from "nostr-tools";
import { useGrimoire } from "@/core/state";
import { useAddWindow, useGrimoire } from "@/core/state";
import { formatTimestamp } from "@/hooks/useLocale";
import { getTagValue } from "applesauce-core/helpers";
import { getZapSender } from "applesauce-common/helpers/zap";
@@ -24,7 +24,7 @@ interface CompactEventRowProps {
* Layout: [KindBadge] [Author] [Preview] [Time]
*/
export function CompactEventRow({ event }: CompactEventRowProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const { locale } = useGrimoire();
// Get the compact preview renderer for this kind, or use default

View File

@@ -11,7 +11,7 @@
import { useState } from "react";
import { Image, Video, Music, File, HardDrive } from "lucide-react";
import { getHashFromURL } from "blossom-client-sdk/helpers/url";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { formatFileSize } from "@/lib/imeta";
import {
Tooltip,
@@ -112,7 +112,7 @@ function MediaIcon({ type }: { type: "image" | "video" | "audio" }) {
}
export function CompactMediaRenderer({ url, type, imeta }: MediaRendererProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const [expanded, setExpanded] = useState(false);
const truncatedHash = getTruncatedHash(url);

View File

@@ -1,5 +1,5 @@
import { MessageSquare } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { cn } from "@/lib/utils";
import { getTagValue } from "applesauce-core/helpers";
import type { NostrEvent } from "@/types/nostr";
@@ -34,7 +34,7 @@ export function GroupLink({
className,
iconClassname,
}: GroupLinkProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
// Handle special case: "_" is the unmanaged relay top-level group
const isUnmanagedGroup = groupId === "_";

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useCallback } from "react";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import remarkGfm from "remark-gfm";
import { remarkNostrMentions } from "applesauce-content/markdown";
@@ -9,13 +9,13 @@ import { MediaEmbed } from "./MediaEmbed";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { CodeCopyButton } from "@/components/CodeCopyButton";
import { useCopy } from "@/hooks/useCopy";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
/**
* Component to render nostr: mentions inline
*/
function NostrMention({ href }: { href: string }) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
try {
// Remove nostr: prefix and any trailing characters
@@ -155,6 +155,14 @@ export interface MarkdownContentProps {
canonicalUrl?: string | null;
}
// Stable module-level constants — never recreated between renders
const REMARK_PLUGINS = [remarkGfm, remarkNostrMentions];
function urlTransform(url: string) {
if (url.startsWith("nostr:")) return url;
return defaultUrlTransform(url);
}
/**
* Shared markdown renderer for Nostr content (articles, NIPs, etc.)
* Handles nostr: mentions, syntax highlighting, media embeds, and relative URLs
@@ -163,178 +171,183 @@ export function MarkdownContent({
content,
canonicalUrl = null,
}: MarkdownContentProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
// Helper to resolve relative URLs using canonical URL as base
const resolveUrl = useMemo(
() =>
(url: string): string | null => {
// If it's already absolute, return as-is
if (url.match(/^https?:\/\//)) {
return url;
const resolveUrl = useCallback(
(url: string): string | null => {
// If it's already absolute, return as-is
if (url.match(/^https?:\/\//)) {
return url;
}
// If we have a canonical URL, try to resolve relative URLs
if (canonicalUrl) {
try {
return new URL(url, canonicalUrl).toString();
} catch {
console.warn("Failed to resolve relative URL:", url);
return null;
}
}
// No canonical URL and it's relative - can't resolve
return null;
},
[canonicalUrl],
);
// Memoize the processed content string
const processedContent = useMemo(
() => content.replace(/\\n/g, "\n"),
[content],
);
// Memoize components object to prevent ReactMarkdown from remounting children
const components = useMemo(
() => ({
// Enable images with zoom
img: ({ src, alt }: { src?: string; alt?: string }) => {
if (!src) return null;
const resolvedUrl = resolveUrl(src);
if (!resolvedUrl) {
// Can't resolve URL - show fallback
return (
<div className="my-4 p-4 border border-border rounded-lg bg-muted/10 text-sm text-muted-foreground">
<p>Media unavailable (relative URL without base)</p>
<p className="text-xs mt-1 break-all">{src}</p>
</div>
);
}
// If we have a canonical URL, try to resolve relative URLs
if (canonicalUrl) {
try {
return new URL(url, canonicalUrl).toString();
} catch {
console.warn("Failed to resolve relative URL:", url);
return null;
return (
<MediaEmbed
url={resolvedUrl}
alt={alt}
preset="preview"
enableZoom
className="my-4"
/>
);
},
// Handle links: nostr mentions, NIP links, and regular URLs
a: ({
href,
children,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (!href) return null;
// Render nostr: mentions inline
if (href.startsWith("nostr:")) {
return <NostrMention href={href} />;
}
// Check if it's a relative NIP link (e.g., "./01.md" or "01.md")
const isRelativeLink =
!href.startsWith("http://") && !href.startsWith("https://");
if (isRelativeLink && (href.endsWith(".md") || href.includes(".md#"))) {
// Extract NIP number from various formats (numeric 1-3 digits or hex A0-FF)
const nipMatch = href.match(/([0-9A-F]{1,3})\.md/i);
if (nipMatch) {
const nipNumber = nipMatch[1].toUpperCase();
return (
<a
href={`#nip-${nipNumber}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
addWindow("nip", { number: nipNumber }, `NIP ${nipNumber}`);
}}
className="text-accent underline decoration-dotted cursor-crosshair hover:text-accent/80"
>
{children}
</a>
);
}
}
// No canonical URL and it's relative - can't resolve
return null;
// Regular links with break-all for long URLs
return (
<a
href={href}
className="text-accent underline decoration-dotted break-all"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
},
[canonicalUrl],
// Don't render pre wrapper when we have a CodeBlock (it has its own container)
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
// Style adjustments for dark theme
h1: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className="text-2xl font-bold mt-8 mb-4" {...props} />
),
h2: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 className="text-xl font-bold mt-6 mb-3" {...props} />
),
h3: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 className="text-lg font-bold mt-4 mb-2" {...props} />
),
p: (props: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className="text-sm leading-relaxed mb-4" {...props} />
),
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || "");
const language = match ? match[1] : null;
const code = String(children).replace(/\n$/, "");
// Inline code (no language)
if (!language) {
return (
<code
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
{...props}
>
{children}
</code>
);
}
// Block code with syntax highlighting and copy button
return <CodeBlock code={code} language={language} />;
},
blockquote: (props: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote
className="border-l-4 border-muted pl-4 italic text-muted-foreground my-4"
{...props}
/>
),
ul: (props: React.HTMLAttributes<HTMLUListElement>) => (
<ul
className="text-sm list-disc list-inside my-4 space-y-2"
{...props}
/>
),
ol: (props: React.HTMLAttributes<HTMLOListElement>) => (
<ol
className="text-sm list-decimal list-inside my-4 space-y-2"
{...props}
/>
),
hr: () => <hr className="my-4" />,
}),
[resolveUrl, addWindow],
);
return (
<article className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkNostrMentions]}
remarkPlugins={REMARK_PLUGINS}
skipHtml
urlTransform={(url) => {
if (url.startsWith("nostr:")) return url;
return defaultUrlTransform(url);
}}
components={{
// Enable images with zoom
img: ({ src, alt }) => {
if (!src) return null;
const resolvedUrl = resolveUrl(src);
if (!resolvedUrl) {
// Can't resolve URL - show fallback
return (
<div className="my-4 p-4 border border-border rounded-lg bg-muted/10 text-sm text-muted-foreground">
<p>Media unavailable (relative URL without base)</p>
<p className="text-xs mt-1 break-all">{src}</p>
</div>
);
}
return (
<MediaEmbed
url={resolvedUrl}
alt={alt}
preset="preview"
enableZoom
className="my-4"
/>
);
},
// Handle links: nostr mentions, NIP links, and regular URLs
a: ({ href, children, ...props }) => {
if (!href) return null;
// Render nostr: mentions inline
if (href.startsWith("nostr:")) {
return <NostrMention href={href} />;
}
// Check if it's a relative NIP link (e.g., "./01.md" or "01.md")
const isRelativeLink =
!href.startsWith("http://") && !href.startsWith("https://");
if (
isRelativeLink &&
(href.endsWith(".md") || href.includes(".md#"))
) {
// Extract NIP number from various formats (numeric 1-3 digits or hex A0-FF)
const nipMatch = href.match(/([0-9A-F]{1,3})\.md/i);
if (nipMatch) {
const nipNumber = nipMatch[1].toUpperCase();
return (
<a
href={`#nip-${nipNumber}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
addWindow(
"nip",
{ number: nipNumber },
`NIP ${nipNumber}`,
);
}}
className="text-accent underline decoration-dotted cursor-crosshair hover:text-accent/80"
>
{children}
</a>
);
}
}
// Regular links with break-all for long URLs
return (
<a
href={href}
className="text-accent underline decoration-dotted break-all"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
},
// Don't render pre wrapper when we have a CodeBlock (it has its own container)
pre: ({ children }) => <>{children}</>,
// Style adjustments for dark theme
h1: ({ ...props }) => (
<h1 className="text-2xl font-bold mt-8 mb-4" {...props} />
),
h2: ({ ...props }) => (
<h2 className="text-xl font-bold mt-6 mb-3" {...props} />
),
h3: ({ ...props }) => (
<h3 className="text-lg font-bold mt-4 mb-2" {...props} />
),
p: ({ ...props }) => (
<p className="text-sm leading-relaxed mb-4" {...props} />
),
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || "");
const language = match ? match[1] : null;
const code = String(children).replace(/\n$/, "");
// Inline code (no language)
if (!language) {
return (
<code
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
{...props}
>
{children}
</code>
);
}
// Block code with syntax highlighting and copy button
return <CodeBlock code={code} language={language} />;
},
blockquote: ({ ...props }) => (
<blockquote
className="border-l-4 border-muted pl-4 italic text-muted-foreground my-4"
{...props}
/>
),
ul: ({ ...props }) => (
<ul
className="text-sm list-disc list-inside my-4 space-y-2"
{...props}
/>
),
ol: ({ ...props }) => (
<ol
className="text-sm list-decimal list-inside my-4 space-y-2"
{...props}
/>
),
hr: () => <hr className="my-4" />,
}}
urlTransform={urlTransform}
components={components}
>
{content.replace(/\\n/g, "\n")}
{processedContent}
</ReactMarkdown>
</article>
);

View File

@@ -1,5 +1,5 @@
import { Inbox, Send, ShieldAlert } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { useRelayInfo } from "@/hooks/useRelayInfo";
import {
HoverCard,
@@ -50,7 +50,7 @@ export function RelayLink({
className,
variant = "default",
}: RelayLinkProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const relayInfo = useRelayInfo(url);
const handleClick = () => {

View File

@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { FolderGit2 } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import {
getRepositoryName,
@@ -41,7 +41,7 @@ export function RepositoryLink({
inline = false,
showIcon = true,
}: RepositoryLinkProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
// Parse repository address to get the pointer (if not provided directly)
const repoPointer = useMemo(() => {

View File

@@ -1,5 +1,5 @@
import type { NipNode } from "@/lib/nip-transformer";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { getNIPInfo } from "@/lib/nip-icons";
interface NipNodeProps {
@@ -10,7 +10,7 @@ interface NipNodeProps {
* Renders a NIP reference as a clickable link that opens the NIP viewer
*/
export function Nip({ node }: NipNodeProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const { number, raw } = node;
const nipInfo = getNIPInfo(number);

View File

@@ -1,5 +1,5 @@
import type { RelayNode } from "@/lib/relay-transformer";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
interface RelayNodeProps {
node: RelayNode;
@@ -18,7 +18,7 @@ function formatRelayUrlForDisplay(url: string): string {
* Renders a relay URL as a clickable link that opens the relay viewer
*/
export function Relay({ node }: RelayNodeProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const { url } = node;
const displayUrl = formatRelayUrlForDisplay(url);

View File

@@ -25,7 +25,7 @@ import {
MessageSquare,
SmilePlus,
} from "lucide-react";
import { useGrimoire } from "@/core/state";
import { useAddWindow, useGrimoire } from "@/core/state";
import { useCopy } from "@/hooks/useCopy";
import { useAccount } from "@/hooks/useAccount";
import { useSettings } from "@/hooks/useSettings";
@@ -133,7 +133,7 @@ export function EventMenu({
onReactClick?: () => void;
canSign?: boolean;
}) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const { copy, copied } = useCopy();
const [jsonDialogOpen, setJsonDialogOpen] = useState(false);
@@ -303,7 +303,7 @@ export function EventContextMenu({
onReactClick?: () => void;
canSign?: boolean;
}) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const { copy, copied } = useCopy();
const [jsonDialogOpen, setJsonDialogOpen] = useState(false);
@@ -472,7 +472,7 @@ export function ClickableEventTitle({
className,
as: Component = "h3",
}: ClickableEventTitleProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -530,7 +530,8 @@ export function BaseEventContainer({
label?: string;
};
}) {
const { locale, addWindow } = useGrimoire();
const { locale } = useGrimoire();
const addWindow = useAddWindow();
const { canSign, signer, pubkey } = useAccount();
const { settings } = useSettings();
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);

View File

@@ -1,7 +1,7 @@
import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer";
import { NostrEvent } from "@/types/nostr";
import { getServersFromEvent } from "@/services/blossom";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { HardDrive, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
* Shows the user's configured Blossom blob storage servers
*/
export function BlossomServerListRenderer({ event }: BaseEventProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const servers = getServersFromEvent(event);
const handleServerClick = (serverUrl: string) => {
@@ -73,7 +73,7 @@ export function BlossomServerListDetailRenderer({
}: {
event: NostrEvent;
}) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const servers = getServersFromEvent(event);
const handleServerClick = (serverUrl: string) => {

View File

@@ -3,7 +3,7 @@ import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { UserName } from "../UserName";
import { MessageCircle } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { getTagValues } from "@/lib/nostr-utils";
import { isValidHexEventId } from "@/lib/nostr-validation";
import { InlineReplySkeleton } from "@/components/ui/skeleton";
@@ -13,7 +13,7 @@ import { InlineReplySkeleton } from "@/components/ui/skeleton";
* Displays chat messages with optional quoted parent message
*/
export function Kind9Renderer({ event, depth = 0 }: BaseEventProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
// Parse 'q' tag for quoted parent message
const quotedEventIds = getTagValues(event, "q");

View File

@@ -4,7 +4,7 @@ import { ExternalLink } from "lucide-react";
import { CodeCopyButton } from "@/components/CodeCopyButton";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { useCopy } from "@/hooks/useCopy";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import {
getCodeLanguage,
@@ -32,7 +32,7 @@ interface Kind1337DetailRendererProps {
* Note: NIP-C0 helpers wrap getTagValue which caches internally
*/
export function Kind1337DetailRenderer({ event }: Kind1337DetailRendererProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const { copy, copied } = useCopy();
// All these helpers wrap getTagValue, which caches internally

View File

@@ -2,7 +2,7 @@ import type { NostrEvent } from "@/types/nostr";
import { getTagValue } from "applesauce-core/helpers";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import { BaseEventContainer, ClickableEventTitle } from "./BaseEventRenderer";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { MessageSquare } from "lucide-react";
interface GroupMetadataRendererProps {
@@ -14,7 +14,7 @@ interface GroupMetadataRendererProps {
* Displays group info and links to chat
*/
export function GroupMetadataRenderer({ event }: GroupMetadataRendererProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
// Extract group metadata
const groupId = getTagValue(event, "d") || "";

View File

@@ -11,7 +11,7 @@ import {
import { KindBadge } from "@/components/KindBadge";
import { Badge } from "@/components/ui/badge";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { UserName } from "../UserName";
import { Globe, Smartphone, TabletSmartphone, Package } from "lucide-react";
import { useState } from "react";
@@ -49,7 +49,7 @@ function HandlerCard({
address: { kind: number; pubkey: string; identifier: string };
platform?: string;
}) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const handlerEvent = useNostrEvent(address);
if (!handlerEvent) {

View File

@@ -11,7 +11,7 @@ import {
import { KindBadge } from "@/components/KindBadge";
import { Badge } from "@/components/ui/badge";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { Globe, Smartphone, TabletSmartphone, Package } from "lucide-react";
/**
@@ -44,7 +44,7 @@ function HandlerItem({
platform?: string;
relayHint?: string;
}) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const handlerEvent = useNostrEvent(address);
const appName = handlerEvent
? getAppName(handlerEvent)

View File

@@ -10,7 +10,7 @@ import {
} from "applesauce-common/helpers/highlight";
import { EmbeddedEvent } from "../EmbeddedEvent";
import { UserName } from "../UserName";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { formatTimestamp } from "@/hooks/useLocale";
import { RichText } from "../RichText";
@@ -20,7 +20,7 @@ import { RichText } from "../RichText";
* Note: All applesauce helpers cache internally, no useMemo needed
*/
export function Kind9802DetailRenderer({ event }: { event: NostrEvent }) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const highlightText = getHighlightText(event);
const comment = getHighlightComment(event);
const context = getHighlightContext(event);

View File

@@ -9,7 +9,7 @@ import {
} from "applesauce-common/helpers/highlight";
import { UserName } from "../UserName";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { RichText } from "../RichText";
import { getArticleTitle } from "applesauce-common/helpers/article";
import { KindBadge } from "@/components/KindBadge";
@@ -20,7 +20,7 @@ import { KindBadge } from "@/components/KindBadge";
* Note: All applesauce helpers cache internally, no useMemo needed
*/
export function Kind9802Renderer({ event }: BaseEventProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const highlightText = getHighlightText(event);
const sourceUrl = getHighlightSourceUrl(event);
const comment = getHighlightComment(event);

View File

@@ -5,7 +5,7 @@ import { UserName } from "../UserName";
import { MarkdownContent } from "../MarkdownContent";
import { EmbeddedEvent } from "../EmbeddedEvent";
import { RepositoryLink } from "../RepositoryLink";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { formatTimestamp } from "@/hooks/useLocale";
import {
getStatusRootEventId,
@@ -57,7 +57,7 @@ function getStatusBadgeClasses(kind: number): string {
* Full view with status info, referenced event, and optional comment
*/
export function IssueStatusDetailRenderer({ event }: { event: NostrEvent }) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const rootEventId = getStatusRootEventId(event);
const relayHint = getStatusRootRelayHint(event);

View File

@@ -6,7 +6,7 @@ import {
import { EmbeddedEvent } from "../EmbeddedEvent";
import { StatusIndicator } from "../StatusIndicator";
import { MarkdownContent } from "../MarkdownContent";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import {
getStatusRootEventId,
getStatusRootRelayHint,
@@ -18,7 +18,7 @@ import type { EventPointer } from "nostr-tools/nip19";
* Displays status action with embedded reference to the issue/patch/PR
*/
export function IssueStatusRenderer({ event }: BaseEventProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const rootEventId = getStatusRootEventId(event);
const relayHint = getStatusRootRelayHint(event);

View File

@@ -10,7 +10,7 @@ import {
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { UserName } from "../UserName";
import { Reply } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { InlineReplySkeleton } from "@/components/ui/skeleton";
import { KindBadge } from "@/components/KindBadge";
import { getEventDisplayTitle } from "@/lib/event-title";
@@ -145,7 +145,7 @@ function RootScopeDisplay({
root: CommentRootScope;
event: NostrEvent;
}) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const pointer = scopeToPointer(root.scope);
const rootEvent = useNostrEvent(pointer, event);
@@ -189,7 +189,7 @@ function RootScopeDisplay({
* Shows root scope (what the thread is about) and parent reply (if nested)
*/
export function Kind1111Renderer({ event, depth = 0 }: BaseEventProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const root = getCommentRootScope(event);
const topLevel = isTopLevelComment(event);

View File

@@ -6,7 +6,7 @@ import { parseReplaceableAddress } from "applesauce-core/helpers/pointers";
import { getDisplayName } from "@/lib/nostr-utils";
import { useProfile } from "@/hooks/useProfile";
import { Video } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
/**
* Renderer for Kind 1311 - Live Chat Message (NIP-53)
@@ -14,7 +14,7 @@ import { useGrimoire } from "@/core/state";
* and a link to the original live activity
*/
export function LiveChatMessageRenderer({ event, depth = 0 }: BaseEventProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
// Get the 'a' tag pointing to the live activity (kind 30311)
const aTag = event.tags.find((tag) => tag[0] === "a");

View File

@@ -5,7 +5,7 @@ import { getNip10References } from "applesauce-common/helpers/threading";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { UserName } from "../UserName";
import { Reply } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { InlineReplySkeleton } from "@/components/ui/skeleton";
import { KindBadge } from "@/components/KindBadge";
import { getEventDisplayTitle } from "@/lib/event-title";
@@ -72,7 +72,7 @@ function ParentEventCard({
* Shows immediate parent (reply) only for cleaner display
*/
export function Kind1Renderer({ event, depth = 0 }: BaseEventProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
// Use NIP-10 threading helpers
const refs = getNip10References(event);

View File

@@ -8,7 +8,7 @@ import {
FolderGit2,
} from "lucide-react";
import { useCopy } from "@/hooks/useCopy";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import type { NostrEvent } from "@/types/nostr";
import {
@@ -30,7 +30,7 @@ export function RepositoryStateDetailRenderer({
}: {
event: NostrEvent;
}) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const repoId = useMemo(() => getRepositoryIdentifier(event), [event]);
const headRef = useMemo(() => getRepositoryStateHead(event), [event]);
const branch = useMemo(() => parseHeadBranch(headRef), [headRef]);

View File

@@ -2,7 +2,7 @@ import { Repeat2 } from "lucide-react";
import { getEventPointerFromETag } from "applesauce-core/helpers/pointers";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import { EmbeddedEvent } from "../EmbeddedEvent";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
/**
* Renderer for Kind 6 (Repost) and Kind 16 (Generic Repost)
@@ -12,7 +12,7 @@ import { useGrimoire } from "@/core/state";
* Kind 16: Generic repost for any event kind (NIP-18)
*/
export function RepostRenderer({ event }: BaseEventProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
// Get the event being reposted (e tag) with relay hints
const eTag = event.tags.find((tag) => tag[0] === "e");

View File

@@ -12,7 +12,7 @@ import { CopyableJsonViewer } from "@/components/JsonViewer";
import { User, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import { UserName } from "../UserName";
import { useGrimoire } from "@/core/state";
import { useAddWindow, useGrimoire } from "@/core/state";
import { useProfile } from "@/hooks/useProfile";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { getDisplayName } from "@/lib/nostr-utils";
@@ -29,7 +29,7 @@ export function MePlaceholder({
className?: string;
pubkey?: string;
}) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const profile = useProfile(pubkey);
const displayName = pubkey ? getDisplayName(pubkey, profile) : "$me";
@@ -67,7 +67,7 @@ export function ContactsPlaceholder({
className?: string;
pubkey?: string;
}) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const contactList = useNostrEvent(
pubkey
? {

View File

@@ -1,7 +1,7 @@
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import { MediaEmbed } from "../MediaEmbed";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { UserName } from "../UserName";
import { RichText } from "../RichText";
import { InlineReplySkeleton } from "@/components/ui/skeleton";
@@ -68,7 +68,7 @@ function ParentPreview({
* Simple display: just the audio player, with reply context for 1244
*/
export function VoiceMessageRenderer({ event }: BaseEventProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
// Audio URL is in event.content per NIP-A0
const audioUrl = event.content.trim();

View File

@@ -17,7 +17,7 @@ import { ExternalLink } from "@/components/ExternalLink";
import { MediaEmbed } from "../MediaEmbed";
import { Badge } from "@/components/ui/badge";
import { useMemo } from "react";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import {
Package,
Globe,
@@ -40,7 +40,7 @@ interface ZapstoreAppDetailRendererProps {
* Release item component showing version and download link
*/
function ReleaseItem({ release }: { release: NostrEvent }) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const version = getReleaseVersion(release);
const fileEventId = getReleaseFileEventId(release);
@@ -158,7 +158,7 @@ function PlatformItem({ platform }: { platform: Platform }) {
export function ZapstoreAppDetailRenderer({
event,
}: ZapstoreAppDetailRendererProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const appName = getAppName(event);
const summary = getAppSummary(event);
const iconUrl = getAppIcon(event);

View File

@@ -13,7 +13,7 @@ import {
} from "@/lib/zapstore-helpers";
import { PlatformIcon } from "./zapstore/PlatformIcon";
import { useMemo } from "react";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { FileDown } from "lucide-react";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import { relayListCache } from "@/services/relay-list-cache";
@@ -25,7 +25,7 @@ import { useLiveTimeline } from "@/hooks/useLiveTimeline";
* Clean feed view with app name, summary, platform icons, and download button
*/
export function ZapstoreAppRenderer({ event }: BaseEventProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const appName = getAppName(event);
const summary = getAppSummary(event);
const identifier = getAppIdentifier(event);

View File

@@ -9,7 +9,7 @@ import {
getCurationSetIdentifier,
} from "@/lib/zapstore-helpers";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { UserName } from "../UserName";
import { Package } from "lucide-react";
import { PlatformIcon } from "./zapstore/PlatformIcon";
@@ -26,7 +26,7 @@ function AppCard({
}: {
address: { kind: number; pubkey: string; identifier: string };
}) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const appEvent = useNostrEvent(address);
if (!appEvent) {

View File

@@ -9,7 +9,7 @@ import {
getAppName,
} from "@/lib/zapstore-helpers";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { Package } from "lucide-react";
function AppItem({
@@ -17,7 +17,7 @@ function AppItem({
}: {
address: { kind: number; pubkey: string; identifier: string };
}) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const appEvent = useNostrEvent(address);
const appName = appEvent
? getAppName(appEvent)

View File

@@ -8,7 +8,7 @@ import {
getAppIcon,
} from "@/lib/zapstore-helpers";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { Badge } from "@/components/ui/badge";
import { UserName } from "../UserName";
import {
@@ -29,7 +29,7 @@ interface ZapstoreReleaseDetailRendererProps {
export function ZapstoreReleaseDetailRenderer({
event,
}: ZapstoreReleaseDetailRendererProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const version = getReleaseVersion(event);
const identifier = getReleaseIdentifier(event);
const fileEventId = getReleaseFileEventId(event);

View File

@@ -10,7 +10,7 @@ import {
getAppName,
} from "@/lib/zapstore-helpers";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { Badge } from "@/components/ui/badge";
import { Package, FileDown } from "lucide-react";
@@ -19,7 +19,7 @@ import { Package, FileDown } from "lucide-react";
* Displays release version with links to app and download file
*/
export function ZapstoreReleaseRenderer({ event }: BaseEventProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const version = getReleaseVersion(event);
const fileEventId = getReleaseFileEventId(event);
const appPointer = getReleaseAppPointer(event);

View File

@@ -1,5 +1,5 @@
import { FileText, ExternalLink } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { useAddWindow } from "@/core/state";
import { cn } from "@/lib/utils";
import { EmbeddedEvent } from "../EmbeddedEvent";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
@@ -59,7 +59,7 @@ export function EventRefItem({
eventPointer,
addressPointer,
}: EventRefItemProps) {
const { addWindow } = useGrimoire();
const addWindow = useAddWindow();
const handleClick = () => {
if (eventPointer) {

View File

@@ -136,6 +136,38 @@ const activeGrimoireStateAtom = atom(
},
);
/**
* Lightweight hook that returns only the addWindow callback.
* Does NOT subscribe to state changes — safe for use in deeply nested components
* (e.g., MarkdownContent, NostrMention) that only need to open windows.
*/
export const useAddWindow = () => {
const dispatch = useSetAtom(activeGrimoireStateAtom);
return useCallback(
(
appId: AppId,
props: any,
commandString?: string,
customTitle?: string,
spellId?: string,
) => {
dispatch({
type: "UPDATE",
updater: (prev) =>
Logic.addWindow(prev, {
appId,
props,
commandString,
customTitle,
spellId,
}),
});
},
[dispatch],
);
};
// The Hook
export const useGrimoire = () => {
const state = useAtomValue(activeGrimoireStateAtom);