feat: -P filter tag

This commit is contained in:
Alejandro Gómez
2025-12-15 23:42:19 +01:00
parent 390290d2eb
commit f4a0d5e669
12 changed files with 1074 additions and 168 deletions

View File

@@ -3,6 +3,7 @@ import { WindowInstance } from "@/types/app";
import { useProfile } from "@/hooks/useProfile";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useRelayState } from "@/hooks/useRelayState";
import { useGrimoire } from "@/core/state";
import { getKindName, getKindIcon } from "@/constants/kinds";
import { getNipTitle } from "@/constants/nips";
import {
@@ -21,6 +22,7 @@ import {
} from "@/lib/filter-formatters";
import { getEventDisplayTitle } from "@/lib/event-title";
import { UserName } from "./nostr/UserName";
import { getTagValues } from "@/lib/nostr-utils";
export interface WindowTitleData {
title: string | ReactElement;
@@ -29,45 +31,64 @@ export interface WindowTitleData {
}
/**
* Format profile names with prefix
* Format profile names with prefix, handling $me and $contacts aliases
* @param prefix - Prefix to use (e.g., 'by ', '@ ')
* @param pubkeys - Array of pubkeys to format
* @param pubkeys - Array of pubkeys to format (may include $me or $contacts)
* @param profiles - Array of corresponding profile metadata
* @param accountProfile - Profile of active account for $me resolution
* @param contactsCount - Number of contacts for $contacts display
* @returns Formatted string like "by Alice, Bob & 3 others" or null if no pubkeys
*/
function formatProfileNames(
prefix: string,
pubkeys: string[],
profiles: (ProfileContent | undefined)[],
accountProfile?: ProfileContent,
contactsCount?: number,
): string | null {
if (!pubkeys.length) return null;
const names: string[] = [];
const [pubkey1, pubkey2] = pubkeys;
const [profile1, profile2] = profiles;
let processedCount = 0;
// Add first profile
if (profile1) {
const name = profile1.display_name || profile1.name;
names.push(name || `${pubkey1.slice(0, 8)}...`);
} else if (pubkey1) {
names.push(`${pubkey1.slice(0, 8)}...`);
}
// Process first two pubkeys (may be aliases or real pubkeys)
for (let i = 0; i < Math.min(2, pubkeys.length); i++) {
const pubkey = pubkeys[i];
const profile = profiles[i];
// Add second profile if exists
if (pubkeys.length > 1) {
if (profile2) {
const name = profile2.display_name || profile2.name;
names.push(name || `${pubkey2.slice(0, 8)}...`);
} else if (pubkey2) {
names.push(`${pubkey2.slice(0, 8)}...`);
if (pubkey === "$me") {
// Show account's name or "You"
if (accountProfile) {
const name = accountProfile.display_name || accountProfile.name || "You";
names.push(name);
} else {
names.push("You");
}
processedCount++;
} else if (pubkey === "$contacts") {
// Show "Your Contacts" with count
if (contactsCount !== undefined && contactsCount > 0) {
names.push(`Your Contacts (${contactsCount})`);
} else {
names.push("Your Contacts");
}
processedCount++;
} else {
// Regular pubkey
if (profile) {
const name = profile.display_name || profile.name;
names.push(name || `${pubkey.slice(0, 8)}...`);
} else {
names.push(`${pubkey.slice(0, 8)}...`);
}
processedCount++;
}
}
// Add "& X other(s)" if more than 2
// Add "& X more" if more than 2
if (pubkeys.length > 2) {
const othersCount = pubkeys.length - 2;
names.push(`& ${othersCount} other${othersCount > 1 ? "s" : ""}`);
names.push(`& ${othersCount} more`);
}
return names.length > 0 ? `${prefix}${names.join(", ")}` : null;
@@ -166,7 +187,25 @@ function generateRawCommand(appId: string, props: any): string {
parts.push(`-t ${props.filter["#t"].slice(0, 2).join(",")}`);
}
if (props.filter.authors?.length) {
parts.push(`-a ${props.filter.authors.slice(0, 2).join(",")}`);
// Keep original aliases in tooltip for clarity
const authorDisplay = props.filter.authors
.slice(0, 2)
.join(",");
parts.push(`-a ${authorDisplay}`);
}
if (props.filter["#p"]?.length) {
// Keep original aliases in tooltip for clarity
const pTagDisplay = props.filter["#p"]
.slice(0, 2)
.join(",");
parts.push(`-p ${pTagDisplay}`);
}
if (props.filter["#P"]?.length) {
// Keep original aliases in tooltip for clarity
const pTagUpperDisplay = props.filter["#P"]
.slice(0, 2)
.join(",");
parts.push(`-P ${pTagUpperDisplay}`);
}
return parts.join(" ");
}
@@ -194,6 +233,24 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
// Get relay state for conn viewer
const { relays } = useRelayState();
// Get account state for alias resolution
const { state } = useGrimoire();
const activeAccount = state.activeAccount;
const accountPubkey = activeAccount?.pubkey;
// Fetch account profile for $me display
const accountProfile = useProfile(accountPubkey || "");
// Fetch contact list for $contacts display
const contactListEvent = useNostrEvent(
accountPubkey ? { kind: 3, pubkey: accountPubkey, identifier: "" } : undefined,
);
// Extract contacts count from kind 3 event
const contactsCount = contactListEvent
? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64).length
: 0;
// Profile titles
const profilePubkey = appId === "profile" ? props.pubkey : null;
const profile = useProfile(profilePubkey || "");
@@ -258,6 +315,12 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
const tagged1Profile = useProfile(tagged1Pubkey);
const tagged2Profile = useProfile(tagged2Pubkey);
const reqTaggedUppercase =
appId === "req" && props.filter?.["#P"] ? props.filter["#P"] : [];
const [taggedUpper1Pubkey, taggedUpper2Pubkey] = reqTaggedUppercase;
const taggedUpper1Profile = useProfile(taggedUpper1Pubkey);
const taggedUpper2Profile = useProfile(taggedUpper2Pubkey);
const reqHashtags =
appId === "req" && props.filter?.["#t"] ? props.filter["#t"] : [];
@@ -289,13 +352,28 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
// 3. Mentions (#p)
if (filter["#p"] && filter["#p"].length > 0) {
const taggedText = formatProfileNames("@", reqTagged, [
tagged1Profile,
tagged2Profile,
]);
const taggedText = formatProfileNames(
"@",
reqTagged,
[tagged1Profile, tagged2Profile],
accountProfile,
contactsCount,
);
if (taggedText) parts.push(taggedText);
}
// 3b. Zap Senders (#P)
if (filter["#P"] && filter["#P"].length > 0) {
const zapSendersText = formatProfileNames(
"⚡ from ",
reqTaggedUppercase,
[taggedUpper1Profile, taggedUpper2Profile],
accountProfile,
contactsCount,
);
if (zapSendersText) parts.push(zapSendersText);
}
// 4. Event References (#e) - NEW
if (filter["#e"] && filter["#e"].length > 0) {
const eventIdsText = formatEventIds(filter["#e"], 2);
@@ -310,10 +388,13 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
// 6. Authors
if (filter.authors && filter.authors.length > 0) {
const authorsText = formatProfileNames("by ", reqAuthors, [
author1Profile,
author2Profile,
]);
const authorsText = formatProfileNames(
"by ",
reqAuthors,
[author1Profile, author2Profile],
accountProfile,
contactsCount,
);
if (authorsText) parts.push(authorsText);
}
@@ -323,13 +404,13 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
if (timeRangeText) parts.push(`📅 ${timeRangeText}`);
}
// 8. Generic Tags - NEW (a-z, A-Z filters excluding e, p, t, d)
// 8. Generic Tags - NEW (a-z, A-Z filters excluding e, p, P, t, d)
const genericTags = Object.entries(filter)
.filter(
([key]) =>
key.startsWith("#") &&
key.length === 2 &&
!["#e", "#p", "#t", "#d"].includes(key),
!["#e", "#p", "#P", "#t", "#d"].includes(key),
)
.map(([key, values]) => ({ letter: key[1], values: values as string[] }));
@@ -349,11 +430,16 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
props,
reqAuthors,
reqTagged,
reqTaggedUppercase,
reqHashtags,
author1Profile,
author2Profile,
tagged1Profile,
tagged2Profile,
taggedUpper1Profile,
taggedUpper2Profile,
accountProfile,
contactsCount,
]);
// Encode/Decode titles

View File

@@ -64,6 +64,8 @@ import { useCopy } from "@/hooks/useCopy";
import { CodeCopyButton } from "@/components/CodeCopyButton";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils";
import { useNostrEvent } from "@/hooks/useNostrEvent";
// Memoized FeedEvent to prevent unnecessary re-renders during scroll
const MemoizedFeedEvent = memo(
@@ -77,6 +79,7 @@ interface ReqViewerProps {
closeOnEose?: boolean;
nip05Authors?: string[];
nip05PTags?: string[];
needsAccount?: boolean;
title?: string;
}
@@ -114,14 +117,15 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
)
.map(([key, values]) => ({ letter: key[1], values: values as string[] }));
// Calculate summary counts
// Calculate summary counts (excluding #p which is shown separately as mentions)
const tagCount =
(eTags?.length || 0) +
(pTagPubkeys.length || 0) +
(tTags?.length || 0) +
(dTags?.length || 0) +
genericTags.reduce((sum, tag) => sum + tag.values.length, 0);
const mentionCount = pTagPubkeys.length;
// Determine if we should use accordion for complex queries
const isComplexQuery =
(filter.kinds?.length || 0) +
@@ -147,6 +151,12 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
{authorPubkeys.length !== 1 ? "s" : ""}
</span>
)}
{mentionCount > 0 && (
<span className="flex items-center gap-1.5">
<User className="size-3.5" />
{mentionCount} mention{mentionCount !== 1 ? "s" : ""}
</span>
)}
{(filter.since || filter.until) && (
<span className="flex items-center gap-1.5">
<Clock className="size-3.5" />
@@ -171,7 +181,7 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
/* Accordion for complex queries */
<Accordion
type="multiple"
defaultValue={["kinds", "authors", "time", "search", "tags"]}
defaultValue={["kinds", "authors", "mentions", "time", "search", "tags"]}
className="space-y-2"
>
{/* Kinds Section */}
@@ -281,6 +291,46 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
</AccordionItem>
)}
{/* Mentions Section */}
{pTagPubkeys.length > 0 && (
<AccordionItem value="mentions" className="border-0">
<AccordionTrigger className="py-2 hover:no-underline">
<div className="flex items-center gap-2 text-xs font-semibold">
<User className="size-3.5 text-muted-foreground" />
Mentions ({pTagPubkeys.length})
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2 ml-5">
<div className="flex flex-wrap gap-2">
{pTagPubkeys
.slice(0, showAllPTags ? undefined : 3)
.map((pubkey) => {
return (
<UserName
key={pubkey}
pubkey={pubkey}
isMention
className="text-xs"
/>
);
})}
</div>
{pTagPubkeys.length > 3 && (
<button
onClick={() => setShowAllPTags(!showAllPTags)}
className="text-xs text-primary hover:underline"
>
{showAllPTags
? "Show less"
: `Show all ${pTagPubkeys.length}`}
</button>
)}
</div>
</AccordionContent>
</AccordionItem>
)}
{/* Tags Section */}
{tagCount > 0 && (
<AccordionItem value="tags" className="border-0">
@@ -325,39 +375,6 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
</div>
)}
{/* Mentions (#p) */}
{pTagPubkeys.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-medium">
Mentions ({pTagPubkeys.length})
</div>
<div className="flex flex-wrap gap-2">
{pTagPubkeys
.slice(0, showAllPTags ? undefined : 3)
.map((pubkey) => {
return (
<UserName
key={pubkey}
pubkey={pubkey}
isMention
className="text-xs"
/>
);
})}
</div>
{pTagPubkeys.length > 3 && (
<button
onClick={() => setShowAllPTags(!showAllPTags)}
className="text-xs text-primary hover:underline"
>
{showAllPTags
? "Show less"
: `Show all ${pTagPubkeys.length}`}
</button>
)}
</div>
)}
{/* Hashtags (#t) */}
{tTags && tTags.length > 0 && (
<div className="space-y-1">
@@ -507,6 +524,42 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
</div>
)}
{/* Mentions */}
{pTagPubkeys.length > 0 && (
<div className="">
<div className="flex items-center gap-2 text-xs font-semibold mb-1.5">
<User className="size-3.5 text-muted-foreground" />
Mentions ({pTagPubkeys.length})
</div>
<div className="ml-5 space-y-2">
<div className="flex flex-wrap gap-2">
{pTagPubkeys
.slice(0, showAllPTags ? undefined : 3)
.map((pubkey) => {
return (
<UserName
key={pubkey}
pubkey={pubkey}
isMention
className="text-xs"
/>
);
})}
</div>
{pTagPubkeys.length > 3 && (
<button
onClick={() => setShowAllPTags(!showAllPTags)}
className="text-xs text-primary hover:underline"
>
{showAllPTags
? "Show less"
: `Show all ${pTagPubkeys.length}`}
</button>
)}
</div>
</div>
)}
{/* Tags (simplified for simple queries) */}
{tagCount > 0 && (
<div className="">
@@ -518,27 +571,6 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
{eTags && eTags.length > 0 && (
<div>Event refs: {formatEventIds(eTags, 3)}</div>
)}
{pTagPubkeys.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span>Mentions:</span>
{pTagPubkeys.slice(0, 3).map((pubkey, idx) => (
<span
key={pubkey}
className="inline-flex items-center gap-1"
>
<UserName
pubkey={pubkey}
isMention
className="text-xs"
/>
{idx < Math.min(2, pTagPubkeys.length - 1) && ","}
</span>
))}
{pTagPubkeys.length > 3 && (
<span>...+{pTagPubkeys.length - 3} more</span>
)}
</div>
)}
{tTags && tTags.length > 0 && (
<div>Hashtags: {formatHashtags(tTags, 3)}</div>
)}
@@ -592,11 +624,33 @@ export default function ReqViewer({
closeOnEose = false,
nip05Authors,
nip05PTags,
needsAccount = false,
title = "nostr-events",
}: ReqViewerProps) {
const { state } = useGrimoire();
const { relays: relayStates } = useRelayState();
// Get active account for alias resolution
const activeAccount = state.activeAccount;
const accountPubkey = activeAccount?.pubkey;
// Fetch contact list (kind 3) if needed for $contacts resolution
const contactListEvent = useNostrEvent(
needsAccount && accountPubkey
? { kind: 3, pubkey: accountPubkey, identifier: "" }
: undefined,
);
// Extract contacts from kind 3 event
const contacts = contactListEvent
? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64)
: [];
// Resolve $me and $contacts aliases
const resolvedFilter = needsAccount
? resolveFilterAliases(filter, accountPubkey, contacts)
: filter;
// NIP-05 resolution already happened in argParser before window creation
// The filter prop already contains resolved pubkeys
// We just display the NIP-05 identifiers for user reference
@@ -622,9 +676,9 @@ export default function ReqViewer({
const { events, loading, error, eoseReceived } = useReqTimeline(
`req-${JSON.stringify(filter)}-${closeOnEose}`,
filter,
resolvedFilter,
defaultRelays,
{ limit: filter.limit || 50, stream },
{ limit: resolvedFilter.limit || 50, stream },
);
const [showQuery, setShowQuery] = useState(false);
@@ -883,7 +937,7 @@ export default function ReqViewer({
{/* Expandable Query */}
{showQuery && (
<QueryDropdown
filter={filter}
filter={resolvedFilter}
nip05Authors={nip05Authors}
nip05PTags={nip05PTags}
/>
@@ -898,38 +952,55 @@ export default function ReqViewer({
</div>
)}
{/* Account Required Error */}
{needsAccount && !accountPubkey && (
<div className="flex flex-col items-center justify-center h-full gap-4 p-8 text-center">
<div className="text-muted-foreground">
<User className="size-12 mx-auto mb-3" />
<h3 className="text-lg font-semibold mb-2">Account Required</h3>
<p className="text-sm max-w-md">
This query uses <code className="bg-muted px-1.5 py-0.5">$me</code>{" "}
or <code className="bg-muted px-1.5 py-0.5">$contacts</code>{" "}
aliases and requires an active account.
</p>
</div>
</div>
)}
{/* Results */}
<div className="flex-1 overflow-y-auto">
{/* Loading: Before EOSE received */}
{loading && events.length === 0 && !eoseReceived && (
<div className="p-4">
<TimelineSkeleton count={5} />
</div>
)}
{(!needsAccount || accountPubkey) && (
<div className="flex-1 overflow-y-auto">
{/* Loading: Before EOSE received */}
{loading && events.length === 0 && !eoseReceived && (
<div className="p-4">
<TimelineSkeleton count={5} />
</div>
)}
{/* EOSE received, no events, not streaming */}
{eoseReceived && events.length === 0 && !stream && !error && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
No events found matching filter
</div>
)}
{/* EOSE received, no events, not streaming */}
{eoseReceived && events.length === 0 && !stream && !error && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
No events found matching filter
</div>
)}
{/* EOSE received, no events, streaming (live mode) */}
{eoseReceived && events.length === 0 && stream && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
Listening for new events...
</div>
)}
{/* EOSE received, no events, streaming (live mode) */}
{eoseReceived && events.length === 0 && stream && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
Listening for new events...
</div>
)}
{events.length > 0 && (
<Virtuoso
style={{ height: "100%" }}
data={events}
computeItemKey={(_index, item) => item.id}
itemContent={(_index, event) => <MemoizedFeedEvent event={event} />}
/>
)}
</div>
{events.length > 0 && (
<Virtuoso
style={{ height: "100%" }}
data={events}
computeItemKey={(_index, item) => item.id}
itemContent={(_index, event) => <MemoizedFeedEvent event={event} />}
/>
)}
</div>
)}
{/* Export Dialog */}
<Dialog open={showExportDialog} onOpenChange={setShowExportDialog}>

View File

@@ -137,6 +137,7 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
closeOnEose={window.props.closeOnEose}
nip05Authors={window.props.nip05Authors}
nip05PTags={window.props.nip05PTags}
needsAccount={window.props.needsAccount}
/>
);
break;

View File

@@ -39,7 +39,7 @@ export function Kind9Renderer({ event, depth = 0 }: BaseEventProps) {
<BaseEventContainer event={event}>
{/* Show quoted message loading state */}
{quotedEventId && !parentEvent && (
<InlineReplySkeleton icon={<MessageCircle />} />
<InlineReplySkeleton icon={<MessageCircle className="size-3" />} />
)}
{/* Show quoted parent message once loaded (only if it's a chat message) */}

View File

@@ -21,12 +21,16 @@ export function Kind1063Renderer({ event }: BaseEventProps) {
const isImage = isImageMime(metadata.m);
const isVideo = isVideoMime(metadata.m);
const isAudio = isAudioMime(metadata.m);
const isAPK = metadata.m === "application/vnd.android.package-archive";
// Get additional metadata
const fileName =
event.tags.find((t) => t[0] === "name")?.[1] || "Unknown file";
const summary =
event.tags.find((t) => t[0] === "summary")?.[1] || event.content;
// For APKs, use: name tag -> content (package identifier) -> fallback
// For others, use: name tag -> fallback
const nameTag = event.tags.find((t) => t[0] === "name")?.[1];
const summaryTag = event.tags.find((t) => t[0] === "summary")?.[1];
const fileName = nameTag || (isAPK ? event.content : null) || "Unknown file";
const summary = summaryTag || (!nameTag && !isAPK ? event.content : null);
return (
<BaseEventContainer event={event}>
@@ -58,24 +62,15 @@ export function Kind1063Renderer({ event }: BaseEventProps) {
) : (
/* Non-media file preview */
<div className="flex items-center gap-3 p-4 border border-border rounded-lg bg-muted/20">
<FileText className="w-8 h-8 text-muted-foreground" />
<FileText className="w-8 h-8 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{fileName}</p>
{metadata.m && (
<p className="text-xs text-muted-foreground">{metadata.m}</p>
<p className="text-xs text-muted-foreground truncate">
{metadata.m}
</p>
)}
</div>
{metadata.url && (
<a
href={metadata.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-primary hover:underline"
>
<Download className="w-4 h-4" />
Download
</a>
)}
</div>
)}
@@ -84,7 +79,7 @@ export function Kind1063Renderer({ event }: BaseEventProps) {
{metadata.m && (
<>
<span className="text-muted-foreground">Type</span>
<code className="font-mono text-xs">{metadata.m}</code>
<code className="font-mono text-xs truncate">{metadata.m}</code>
</>
)}
{metadata.size && (
@@ -109,6 +104,19 @@ export function Kind1063Renderer({ event }: BaseEventProps) {
{/* Description/Summary */}
{summary && <RichText content={summary} className="text-sm" />}
{/* Download button */}
{metadata.url && (
<a
href={metadata.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-primary border border-primary/20 rounded-lg hover:bg-primary/10 transition-colors"
>
<Download className="w-4 h-4" />
Download
</a>
)}
</div>
</BaseEventContainer>
);

View File

@@ -32,7 +32,9 @@ export function Kind1Renderer({ event, depth = 0 }: BaseEventProps) {
return (
<BaseEventContainer event={event}>
{/* Show parent message loading state */}
{pointer && !parentEvent && <InlineReplySkeleton icon={<Reply />} />}
{pointer && !parentEvent && (
<InlineReplySkeleton icon={<Reply className="size-3" />} />
)}
{/* Show parent message once loaded */}
{pointer && parentEvent && (

View File

@@ -13,6 +13,7 @@ import {
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { KindRenderer } from "./index";
import { RichText } from "../RichText";
import { EventCardSkeleton } from "@/components/ui/skeleton";
/**
* Renderer for Kind 9735 - Zap Receipts
@@ -85,31 +86,25 @@ export function Kind9735Renderer({ event }: BaseEventProps) {
</div>
)}
{/* Embedded zapped event (if loaded) */}
{zappedEvent && (
<div className="border border-muted">
<EmbeddedEvent event={zappedEvent} />
{/* Zapped content with loading states */}
{addressPointer && !zappedAddress && (
<div className="border border-muted p-2">
<EventCardSkeleton variant="compact" showActions={false} />
</div>
)}
{/* Embedded zapped address (if loaded and different from event) */}
{zappedAddress && (
{addressPointer && zappedAddress && (
<div className="border border-muted">
<EmbeddedEvent event={zappedAddress} />
</div>
)}
{/* Loading state for event pointer */}
{eventPointer && !zappedEvent && (
<div className="border border-muted p-2 text-xs text-muted-foreground">
Loading zapped event...
{!addressPointer && eventPointer && !zappedEvent && (
<div className="border border-muted p-2">
<EventCardSkeleton variant="compact" showActions={false} />
</div>
)}
{/* Loading state for address pointer */}
{addressPointer && !zappedAddress && (
<div className="border border-muted p-2 text-xs text-muted-foreground">
Loading zapped address...
{!addressPointer && eventPointer && zappedEvent && (
<div className="border border-muted">
<EmbeddedEvent event={zappedEvent} />
</div>
)}
</div>