wip: skeletons

This commit is contained in:
Alejandro Gómez
2025-12-15 22:19:28 +01:00
parent aa2e74bcd7
commit 390290d2eb
20 changed files with 753 additions and 308 deletions

View File

@@ -15,20 +15,8 @@ import { CommunityNIPDetailRenderer } from "./nostr/kinds/CommunityNIPDetailRend
import { RepositoryDetailRenderer } from "./nostr/kinds/RepositoryDetailRenderer";
import { JsonViewer } from "./JsonViewer";
import { RelayLink } from "./nostr/RelayLink";
import {
Copy,
CopyCheck,
FileJson,
Wifi,
Loader2,
WifiOff,
XCircle,
ShieldCheck,
ShieldAlert,
ShieldX,
ShieldQuestion,
Shield,
} from "lucide-react";
import { EventDetailSkeleton } from "@/components/ui/skeleton";
import { Copy, CopyCheck, FileJson, Wifi } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
@@ -41,76 +29,12 @@ import { useCopy } from "../hooks/useCopy";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import { getTagValue } from "applesauce-core/helpers";
import { useRelayState } from "@/hooks/useRelayState";
import type { RelayState } from "@/types/relay-state";
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
export interface EventDetailViewerProps {
pointer: EventPointer | AddressPointer;
}
// Helper functions for relay status icons (from ReqViewer)
function getConnectionIcon(relay: RelayState | undefined) {
if (!relay) {
return {
icon: <WifiOff className="size-3 text-muted-foreground" />,
label: "Unknown",
};
}
const iconMap = {
connected: {
icon: <Wifi className="size-3 text-green-500" />,
label: "Connected",
},
connecting: {
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
label: "Connecting",
},
disconnected: {
icon: <WifiOff className="size-3 text-muted-foreground" />,
label: "Disconnected",
},
error: {
icon: <XCircle className="size-3 text-red-500" />,
label: "Connection Error",
},
};
return iconMap[relay.connectionState];
}
function getAuthIcon(relay: RelayState | undefined) {
if (!relay || relay.authStatus === "none") {
return null;
}
const iconMap = {
authenticated: {
icon: <ShieldCheck className="size-3 text-green-500" />,
label: "Authenticated",
},
challenge_received: {
icon: <ShieldQuestion className="size-3 text-yellow-500" />,
label: "Challenge Received",
},
authenticating: {
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
label: "Authenticating",
},
failed: {
icon: <ShieldX className="size-3 text-red-500" />,
label: "Authentication Failed",
},
rejected: {
icon: <ShieldAlert className="size-3 text-muted-foreground" />,
label: "Authentication Rejected",
},
none: {
icon: <Shield className="size-3 text-muted-foreground" />,
label: "No Authentication",
},
};
return iconMap[relay.authStatus] || iconMap.none;
}
/**
* EventDetailViewer - Detailed view for a single event
* Shows compact metadata header and rendered content
@@ -124,8 +48,8 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
// Loading state
if (!event) {
return (
<div className="flex flex-col items-center justify-center h-full p-8 text-muted-foreground">
<div className="text-sm">Loading event...</div>
<div className="flex flex-col h-full p-8">
<EventDetailSkeleton />
</div>
);
}
@@ -149,15 +73,6 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
relays: relays,
});
// Format timestamp - compact format
// const timestamp = new Date(event.created_at * 1000).toLocaleString("en-US", {
// month: "2-digit",
// day: "2-digit",
// year: "numeric",
// hour: "2-digit",
// minute: "2-digit",
// });
// Get relay state for each relay
const relayStatesForEvent = relays
? relays.map((url) => ({

View File

@@ -38,7 +38,7 @@ function AuthToast({
showInboxOutbox={false}
variant="prompt"
iconClassname="size-4"
urlClassname="text-sm"
urlClassname="break-all text-sm"
/>
</div>
@@ -154,13 +154,7 @@ export function GlobalAuthPrompt() {
relayUrl={challenge.relayUrl}
challenge={challenge.challenge}
onAuthenticate={async (remember) => {
console.log(
`[AuthPrompt] Authenticate with remember=${remember}`,
);
if (remember) {
console.log(
`[AuthPrompt] Setting preference to "always" for ${challenge.relayUrl}`,
);
await setAuthPreference(challenge.relayUrl, "always");
}
@@ -189,11 +183,7 @@ export function GlobalAuthPrompt() {
}
}}
onReject={async (remember) => {
console.log(`[AuthPrompt] Reject with remember=${remember}`);
if (remember) {
console.log(
`[AuthPrompt] Setting preference to "never" for ${challenge.relayUrl}`,
);
await setAuthPreference(challenge.relayUrl, "never");
}

View File

@@ -1,6 +1,7 @@
import { useProfile } from "@/hooks/useProfile";
import { UserName } from "./nostr/UserName";
import Nip05 from "./nostr/nip05";
import { ProfileCardSkeleton } from "@/components/ui/skeleton";
import {
Copy,
CopyCheck,
@@ -8,14 +9,6 @@ import {
Inbox,
Send,
Wifi,
Loader2,
WifiOff,
XCircle,
ShieldCheck,
ShieldAlert,
ShieldX,
ShieldQuestion,
Shield,
} from "lucide-react";
import { kinds, nip19 } from "nostr-tools";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
@@ -31,76 +24,12 @@ import {
} from "./ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { useRelayState } from "@/hooks/useRelayState";
import type { RelayState } from "@/types/relay-state";
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
export interface ProfileViewerProps {
pubkey: string;
}
// Helper functions for relay status icons (from EventDetailViewer)
function getConnectionIcon(relay: RelayState | undefined) {
if (!relay) {
return {
icon: <WifiOff className="size-3 text-muted-foreground" />,
label: "Unknown",
};
}
const iconMap = {
connected: {
icon: <Wifi className="size-3 text-green-500" />,
label: "Connected",
},
connecting: {
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
label: "Connecting",
},
disconnected: {
icon: <WifiOff className="size-3 text-muted-foreground" />,
label: "Disconnected",
},
error: {
icon: <XCircle className="size-3 text-red-500" />,
label: "Connection Error",
},
};
return iconMap[relay.connectionState];
}
function getAuthIcon(relay: RelayState | undefined) {
if (!relay || relay.authStatus === "none") {
return null;
}
const iconMap = {
authenticated: {
icon: <ShieldCheck className="size-3 text-green-500" />,
label: "Authenticated",
},
challenge_received: {
icon: <ShieldQuestion className="size-3 text-yellow-500" />,
label: "Challenge Received",
},
authenticating: {
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
label: "Authenticating",
},
failed: {
icon: <ShieldX className="size-3 text-red-500" />,
label: "Authentication Failed",
},
rejected: {
icon: <ShieldAlert className="size-3 text-muted-foreground" />,
label: "Authentication Rejected",
},
none: {
icon: <Shield className="size-3 text-muted-foreground" />,
label: "No Authentication",
},
};
return iconMap[relay.authStatus] || iconMap.none;
}
/**
* ProfileViewer - Detailed view for a user profile
* Shows profile metadata, inbox/outbox relays, and raw JSON
@@ -262,11 +191,7 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
{/* Profile Content */}
<div className="flex-1 overflow-y-auto p-4">
{!profile && !profileEvent && (
<div className="text-center text-muted-foreground text-sm">
Loading profile...
</div>
)}
{!profile && !profileEvent && <ProfileCardSkeleton variant="full" />}
{!profile && profileEvent && (
<div className="text-center text-muted-foreground text-sm">

View File

@@ -5,14 +5,6 @@ import {
Radio,
FileText,
Wifi,
WifiOff,
Loader2,
XCircle,
ShieldCheck,
ShieldAlert,
ShieldX,
ShieldQuestion,
Shield,
Filter as FilterIcon,
Download,
Clock,
@@ -20,6 +12,7 @@ import {
Hash,
Search,
Code,
Loader2,
} from "lucide-react";
import { Virtuoso } from "react-virtuoso";
import { useReqTimeline } from "@/hooks/useReqTimeline";
@@ -28,6 +21,7 @@ import { useRelayState } from "@/hooks/useRelayState";
import { FeedEvent } from "./nostr/Feed";
import { KindBadge } from "./KindBadge";
import { UserName } from "./nostr/UserName";
import { TimelineSkeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import {
DropdownMenu,
@@ -58,7 +52,6 @@ import {
} from "./ui/accordion";
import { RelayLink } from "./nostr/RelayLink";
import type { NostrFilter } from "@/types/nostr";
import type { RelayState } from "@/types/relay-state";
import {
formatEventIds,
formatDTags,
@@ -70,6 +63,7 @@ import { sanitizeFilename } from "@/lib/filename-utils";
import { useCopy } from "@/hooks/useCopy";
import { CodeCopyButton } from "@/components/CodeCopyButton";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
// Memoized FeedEvent to prevent unnecessary re-renders during scroll
const MemoizedFeedEvent = memo(
@@ -77,70 +71,6 @@ const MemoizedFeedEvent = memo(
(prev, next) => prev.event.id === next.event.id,
);
// Helper functions for relay status icons
function getConnectionIcon(relay: RelayState | undefined) {
if (!relay) {
return {
icon: <WifiOff className="size-3 text-muted-foreground" />,
label: "Unknown",
};
}
const iconMap = {
connected: {
icon: <Wifi className="size-3 text-green-500" />,
label: "Connected",
},
connecting: {
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
label: "Connecting",
},
disconnected: {
icon: <WifiOff className="size-3 text-muted-foreground" />,
label: "Disconnected",
},
error: {
icon: <XCircle className="size-3 text-red-500" />,
label: "Connection Error",
},
};
return iconMap[relay.connectionState];
}
function getAuthIcon(relay: RelayState | undefined) {
if (!relay || relay.authStatus === "none") {
return null;
}
const iconMap = {
authenticated: {
icon: <ShieldCheck className="size-3 text-green-500" />,
label: "Authenticated",
},
challenge_received: {
icon: <ShieldQuestion className="size-3 text-yellow-500" />,
label: "Challenge Received",
},
authenticating: {
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
label: "Authenticating",
},
failed: {
icon: <ShieldX className="size-3 text-red-500" />,
label: "Authentication Failed",
},
rejected: {
icon: <ShieldAlert className="size-3 text-muted-foreground" />,
label: "Authentication Rejected",
},
none: {
icon: <Shield className="size-3 text-muted-foreground" />,
label: "No Authentication",
},
};
return iconMap[relay.authStatus] || iconMap.none;
}
interface ReqViewerProps {
filter: NostrFilter;
relays?: string[];
@@ -972,8 +902,8 @@ export default function ReqViewer({
<div className="flex-1 overflow-y-auto">
{/* Loading: Before EOSE received */}
{loading && events.length === 0 && !eoseReceived && (
<div className="text-center text-muted-foreground font-mono text-sm p-4">
Loading events...
<div className="p-4">
<TimelineSkeleton count={5} />
</div>
)}

View File

@@ -1,5 +1,6 @@
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { KindRenderer } from "./kinds";
import { EventCardSkeleton } from "@/components/ui/skeleton";
interface EmbeddedEventProps {
/** Event ID string for regular events */
@@ -70,10 +71,10 @@ export function EmbeddedEvent({
);
}
// No onOpen handler - just show loading text
// No onOpen handler - show skeleton
return (
<span className="text-sm text-muted-foreground italic">
Loading event...
</span>
<div className={className}>
<EventCardSkeleton variant="compact" />
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import Zoom from "react-medium-image-zoom";
import "react-medium-image-zoom/dist/styles.css";
import { Music, AlertCircle } from "lucide-react";
import { Music, AlertCircle, Play, RotateCw } from "lucide-react";
import {
isImageURL,
isVideoURL,
@@ -22,6 +22,13 @@ interface MediaEmbedProps {
// Video/Audio-specific
showControls?: boolean; // default: true
onAudioClick?: () => void; // open dialog for audio
// Loading & Performance
aspectRatio?: string; // "16/9", "4/3", "1/1", "auto" - defaults based on media type
showPlaceholder?: boolean; // default: true
fadeIn?: boolean; // default: true
onLoad?: () => void;
onError?: (error: Error) => void;
}
const PRESETS = {
@@ -47,11 +54,55 @@ const PRESETS = {
},
} as const;
/**
* Get default aspect ratio based on media type and preset
*/
const getDefaultAspectRatio = (
mediaType: string,
preset: string,
): string | undefined => {
if (preset === "thumbnail") return "1/1";
if (mediaType === "video") return "16/9";
return undefined; // auto for images
};
/**
* Skeleton placeholder component with shimmer effect
*/
const SkeletonPlaceholder = ({
aspectRatio,
rounded,
children,
}: {
aspectRatio?: string;
rounded: string;
children?: React.ReactNode;
}) => (
<div
className={cn(
"absolute inset-0 bg-muted/20 animate-pulse flex items-center justify-center",
rounded,
)}
style={aspectRatio ? { aspectRatio } : undefined}
aria-busy="true"
aria-label="Loading media"
>
{children}
</div>
);
/**
* MediaEmbed component for displaying images, videos, and audio with constraints
* - Images: Use react-medium-image-zoom for inline zoom
* - Videos: Show preview with play button, can trigger dialog
* - Audio: Show audio player
*
* Features:
* - Loading placeholders with skeleton shimmer
* - Aspect ratio preservation to prevent layout shift
* - Smooth fade-in animations
* - Error handling with retry mechanism
* - Performance optimized with CSS containment
*/
export function MediaEmbed({
url,
@@ -62,8 +113,16 @@ export function MediaEmbed({
enableZoom = true,
showControls = true,
onAudioClick,
aspectRatio,
showPlaceholder = true,
fadeIn = true,
onLoad,
onError: onErrorCallback,
}: MediaEmbedProps) {
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isLoaded, setIsLoaded] = useState(false);
const [retryCount, setRetryCount] = useState(0);
// Auto-detect media type if not specified
const mediaType =
@@ -79,72 +138,174 @@ export function MediaEmbed({
const presetStyles = PRESETS[preset];
// Determine aspect ratio (user override or default based on type/preset)
const effectiveAspectRatio =
aspectRatio || getDefaultAspectRatio(mediaType, preset);
// Reset states when URL changes
useEffect(() => {
setIsLoading(true);
setIsLoaded(false);
setError(false);
}, [url]);
const handleError = () => {
setIsLoading(false);
setError(true);
if (onErrorCallback) {
const error = new Error("Failed to load media");
onErrorCallback(error);
}
};
// Error fallback UI
const handleLoad = () => {
setIsLoading(false);
setIsLoaded(true);
if (onLoad) onLoad();
};
const handleRetry = () => {
setError(false);
setIsLoading(true);
setRetryCount((prev) => prev + 1);
};
// Error fallback UI with retry
if (error) {
return (
<div
className={cn(
"flex flex-col items-center gap-2 p-4 border border-destructive/50 rounded-lg bg-destructive/10",
"flex flex-col items-center gap-3 p-4 border border-destructive/50 rounded-lg bg-destructive/10",
className,
)}
role="alert"
aria-live="polite"
>
<AlertCircle className="w-6 h-6 text-destructive" />
<p className="text-sm text-destructive">Failed to load media</p>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary underline hover:text-primary/80"
>
Open in new tab
</a>
<p className="text-sm text-destructive font-medium">
Failed to load media
</p>
<div className="flex gap-2">
{mediaType === "image" && (
<button
onClick={handleRetry}
className="flex items-center gap-1 px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors"
aria-label="Retry loading media"
>
<RotateCw className="w-3 h-3" />
Retry
</button>
)}
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 text-xs text-primary underline hover:text-primary/80"
>
Open in new tab
</a>
</div>
</div>
);
}
// Image rendering with zoom
// Image rendering with zoom, placeholder, and fade-in
if (mediaType === "image") {
const imageElement = (
<img
src={url}
alt={alt || "Image"}
loading="lazy"
className={cn(
"w-full h-auto object-contain",
presetStyles.rounded,
enableZoom && "cursor-zoom-in",
className,
const imageContent = (
<div
className={cn("relative overflow-hidden", presetStyles.rounded)}
style={
effectiveAspectRatio
? {
aspectRatio: effectiveAspectRatio,
maxHeight: presetStyles.maxHeight,
maxWidth:
preset === "thumbnail" ? presetStyles.maxWidth : "100%",
contain: "content", // Performance optimization
}
: {
maxHeight: presetStyles.maxHeight,
maxWidth:
preset === "thumbnail" ? presetStyles.maxWidth : "100%",
}
}
>
{/* Skeleton placeholder */}
{showPlaceholder && isLoading && (
<SkeletonPlaceholder
aspectRatio={effectiveAspectRatio}
rounded={presetStyles.rounded}
/>
)}
style={{
maxHeight: presetStyles.maxHeight,
maxWidth: preset === "thumbnail" ? presetStyles.maxWidth : "100%",
}}
onError={handleError}
/>
{/* Image with fade-in */}
<img
key={retryCount} // Force remount on retry
src={url}
alt={alt || "Image"}
loading="lazy"
className={cn(
"w-full h-full object-contain",
presetStyles.rounded,
enableZoom && "cursor-zoom-in",
fadeIn && "transition-opacity duration-300",
isLoaded ? "opacity-100" : "opacity-0",
className,
)}
onLoad={handleLoad}
onError={handleError}
aria-label={alt || "Image"}
/>
</div>
);
return enableZoom ? (
<Zoom zoomMargin={40}>{imageElement}</Zoom>
<Zoom zoomMargin={40}>{imageContent}</Zoom>
) : (
imageElement
imageContent
);
}
// Video rendering with inline playback
// Video rendering with placeholder and loading state
if (mediaType === "video") {
return (
<video
src={url}
className={cn("w-full", presetStyles.rounded, className)}
style={{ maxHeight: presetStyles.maxHeight }}
preload="metadata"
controls={showControls}
onError={handleError}
/>
<div
className={cn("relative overflow-hidden", presetStyles.rounded)}
style={{
aspectRatio: effectiveAspectRatio,
maxHeight: presetStyles.maxHeight,
maxWidth: "100%",
contain: "content", // Performance optimization
}}
>
{/* Skeleton placeholder with play icon */}
{showPlaceholder && isLoading && (
<SkeletonPlaceholder
aspectRatio={effectiveAspectRatio}
rounded={presetStyles.rounded}
>
<Play className="w-12 h-12 text-muted-foreground/50" />
</SkeletonPlaceholder>
)}
{/* Video with fade-in */}
<video
key={retryCount} // Force remount on retry
src={url}
className={cn(
"w-full h-full",
presetStyles.rounded,
fadeIn && "transition-opacity duration-300",
isLoaded ? "opacity-100" : "opacity-0",
className,
)}
preload="metadata"
controls={showControls}
onLoadedMetadata={handleLoad}
onError={handleError}
aria-label={alt || "Video"}
/>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { KindRenderer } from "./kinds";
import { UserName } from "./UserName";
import { ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
import { CompactQuoteSkeleton } from "@/components/ui/skeleton";
interface QuotedEventProps {
/** Event ID string for regular events */
@@ -65,11 +66,7 @@ export function QuotedEvent({
);
}
return (
<span className="text-sm text-muted-foreground italic">
Loading event...
</span>
);
return <CompactQuoteSkeleton className={className} />;
}
// For depth 0-1: Show full content inline by default

View File

@@ -6,6 +6,7 @@ import { MessageCircle } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { getTagValues } from "@/lib/nostr-utils";
import { isValidHexEventId } from "@/lib/nostr-validation";
import { InlineReplySkeleton } from "@/components/ui/skeleton";
/**
* Renderer for Kind 9 - Chat Message (NIP-C7)
@@ -36,7 +37,12 @@ export function Kind9Renderer({ event, depth = 0 }: BaseEventProps) {
return (
<BaseEventContainer event={event}>
{/* Show quoted parent message if this is a reply */}
{/* Show quoted message loading state */}
{quotedEventId && !parentEvent && (
<InlineReplySkeleton icon={<MessageCircle />} />
)}
{/* Show quoted parent message once loaded (only if it's a chat message) */}
{quotedEventId && parentEvent && parentEvent.kind === 9 && (
<div
onClick={handleQuoteClick}

View File

@@ -5,6 +5,7 @@ import { useNostrEvent } from "@/hooks/useNostrEvent";
import { UserName } from "../UserName";
import { Reply } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { InlineReplySkeleton } from "@/components/ui/skeleton";
/**
* Renderer for Kind 1 - Short Text Note
@@ -30,6 +31,10 @@ export function Kind1Renderer({ event, depth = 0 }: BaseEventProps) {
return (
<BaseEventContainer event={event}>
{/* Show parent message loading state */}
{pointer && !parentEvent && <InlineReplySkeleton icon={<Reply />} />}
{/* Show parent message once loaded */}
{pointer && parentEvent && (
<div
onClick={handleReplyClick}

View File

@@ -4,6 +4,7 @@ import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useMemo } from "react";
import { NostrEvent } from "@/types/nostr";
import { KindRenderer } from "./index";
import { EventCardSkeleton } from "@/components/ui/skeleton";
/**
* Renderer for Kind 7 - Reactions
@@ -133,8 +134,8 @@ export function Kind7Renderer({ event }: BaseEventProps) {
{/* Loading state */}
{reactedEventId && !reactedEvent && (
<div className="border border-muted p-2 text-xs text-muted-foreground">
Loading referenced event...
<div className="border border-muted p-2">
<EventCardSkeleton variant="compact" showActions={false} />
</div>
)}
</div>

View File

@@ -0,0 +1,32 @@
import * as React from "react";
import { Skeleton } from "./Skeleton";
export type CompactQuoteSkeletonProps = React.HTMLAttributes<HTMLDivElement>;
const CompactQuoteSkeleton = React.forwardRef<
HTMLDivElement,
CompactQuoteSkeletonProps
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={`border-l-2 border-muted pl-3 space-y-2 ${className || ""}`}
role="status"
aria-label="Loading quoted event..."
{...props}
>
{/* Author name */}
<Skeleton variant="text" width="6rem" height={14} />
{/* Quote Content: 2 lines */}
<div className="space-y-2">
<Skeleton variant="text" width="100%" height={12} />
<Skeleton variant="text" width="80%" height={12} />
</div>
</div>
);
});
CompactQuoteSkeleton.displayName = "CompactQuoteSkeleton";
export { CompactQuoteSkeleton };

View File

@@ -0,0 +1,95 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Skeleton } from "./Skeleton";
const eventCardSkeletonVariants = cva("flex flex-col gap-2", {
variants: {
variant: {
default: "",
compact: "",
detailed: "",
},
timeline: {
true: "p-3 border-b border-border/50",
false: "",
},
},
defaultVariants: {
variant: "default",
timeline: false,
},
});
export interface EventCardSkeletonProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof eventCardSkeletonVariants> {
showActions?: boolean;
isLast?: boolean;
}
const EventCardSkeleton = React.forwardRef<
HTMLDivElement,
EventCardSkeletonProps
>(
(
{
className,
variant = "default",
timeline = false,
showActions = true,
isLast = false,
...props
},
ref,
) => {
// Content line count based on variant
const contentLines =
variant === "compact" ? 2 : variant === "detailed" ? 6 : 4;
return (
<div
ref={ref}
className={`${eventCardSkeletonVariants({ variant, timeline })} ${timeline && isLast ? "border-0" : ""} ${className || ""}`}
role="status"
aria-label="Loading event..."
{...props}
>
{/* Header: Name + Timestamp (matches BaseEventContainer) */}
<div className="flex flex-row justify-between items-center">
<div className="flex flex-row gap-2 items-baseline">
<Skeleton variant="text" width="8rem" height={16} />
<Skeleton variant="text" width="4rem" height={12} />
</div>
{/* Menu placeholder */}
<Skeleton variant="rectangle" width={20} height={20} />
</div>
{/* Content */}
<div className="space-y-2">
{Array.from({ length: contentLines }).map((_, i) => (
<Skeleton
key={i}
variant="text"
width={i === contentLines - 1 ? "70%" : "100%"}
height={14}
/>
))}
</div>
{/* Footer: Action buttons */}
{showActions && (
<div className="flex gap-4">
<Skeleton variant="text" width="3rem" height={12} />
<Skeleton variant="text" width="3rem" height={12} />
<Skeleton variant="text" width="3rem" height={12} />
</div>
)}
</div>
);
},
);
EventCardSkeleton.displayName = "EventCardSkeleton";
export { EventCardSkeleton, eventCardSkeletonVariants };

View File

@@ -0,0 +1,54 @@
import * as React from "react";
import { Skeleton } from "./Skeleton";
export type EventDetailSkeletonProps = React.HTMLAttributes<HTMLDivElement>;
const EventDetailSkeleton = React.forwardRef<
HTMLDivElement,
EventDetailSkeletonProps
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={className}
role="status"
aria-label="Loading event details..."
{...props}
>
<div className="space-y-6">
{/* Header: Name + timestamp (matches BaseEventContainer) */}
<div className="flex flex-row gap-2 items-baseline">
<Skeleton variant="text" width="10rem" height={18} />
<Skeleton variant="text" width="6rem" height={14} />
</div>
{/* Event Content: 5 lines */}
<div className="space-y-3">
<Skeleton variant="text" width="100%" height={16} />
<Skeleton variant="text" width="100%" height={16} />
<Skeleton variant="text" width="95%" height={16} />
<Skeleton variant="text" width="98%" height={16} />
<Skeleton variant="text" width="85%" height={16} />
</div>
{/* Metadata Section */}
<div className="space-y-2 pt-4 border-t border-border">
<Skeleton variant="text" width="8rem" height={12} />
<Skeleton variant="text" width="10rem" height={12} />
<Skeleton variant="text" width="6rem" height={12} />
</div>
{/* Action Buttons */}
<div className="flex gap-4 pt-4">
<Skeleton variant="rectangle" width="5rem" height={36} />
<Skeleton variant="rectangle" width="5rem" height={36} />
<Skeleton variant="rectangle" width="5rem" height={36} />
</div>
</div>
</div>
);
});
EventDetailSkeleton.displayName = "EventDetailSkeleton";
export { EventDetailSkeleton };

View File

@@ -0,0 +1,40 @@
import * as React from "react";
import { Skeleton } from "./Skeleton";
export interface InlineReplySkeletonProps {
/** Icon to show on the left (Reply, MessageCircle, etc.) */
icon: React.ReactNode;
className?: string;
}
/**
* Inline skeleton for loading parent message previews
* Matches the exact structure and spacing of the reply box
*/
const InlineReplySkeleton = React.forwardRef<
HTMLDivElement,
InlineReplySkeletonProps
>(({ icon, className }, ref) => {
return (
<div
ref={ref}
className={`flex items-start gap-2 p-1 bg-muted/20 text-xs text-muted-foreground rounded ${className || ""}`}
role="status"
aria-label="Loading parent message..."
>
{/* Icon - visible during loading */}
{React.isValidElement(icon) ? (
<div className="size-3 flex-shrink-0 mt-0.5">{icon}</div>
) : null}
{/* Content placeholder - matches text line-height */}
<div className="flex-1 min-w-0 py-0.5">
<Skeleton variant="text" width="70%" className="h-3" />
</div>
</div>
);
});
InlineReplySkeleton.displayName = "InlineReplySkeleton";
export { InlineReplySkeleton };

View File

@@ -0,0 +1,74 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Skeleton } from "./Skeleton";
const profileCardSkeletonVariants = cva("flex flex-col gap-4", {
variants: {
variant: {
full: "",
compact: "",
inline: "flex-row items-baseline gap-2",
},
},
defaultVariants: {
variant: "full",
},
});
export interface ProfileCardSkeletonProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof profileCardSkeletonVariants> {
showBio?: boolean;
showNip05?: boolean;
}
const ProfileCardSkeleton = React.forwardRef<
HTMLDivElement,
ProfileCardSkeletonProps
>(
(
{ className, variant = "full", showBio = true, showNip05 = true, ...props },
ref,
) => {
// Name height based on variant (ProfileViewer uses 2xl font)
const nameHeight = variant === "full" ? 32 : 16;
const nameWidth = variant === "full" ? "16rem" : "8rem";
return (
<div
ref={ref}
className={profileCardSkeletonVariants({ variant, className })}
role="status"
aria-label="Loading profile..."
{...props}
>
{/* Name */}
<Skeleton variant="text" width={nameWidth} height={nameHeight} />
{/* NIP-05 (optional, only for full variant) */}
{showNip05 && variant === "full" && (
<Skeleton variant="text" width="12rem" height={12} />
)}
{/* Bio (only for full and compact variants) */}
{showBio && variant !== "inline" && (
<div className="space-y-2">
{variant === "full" && (
<Skeleton variant="text" width="3rem" height={10} />
)}
<Skeleton variant="text" width="100%" height={14} />
<Skeleton variant="text" width="95%" height={14} />
{variant === "full" && (
<Skeleton variant="text" width="85%" height={14} />
)}
</div>
)}
</div>
);
},
);
ProfileCardSkeleton.displayName = "ProfileCardSkeleton";
export { ProfileCardSkeleton, profileCardSkeletonVariants };

View File

@@ -0,0 +1,53 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
const skeletonVariants = cva("animate-skeleton-pulse bg-muted rounded", {
variants: {
variant: {
circle: "rounded-full",
rectangle: "rounded",
text: "rounded h-4",
},
},
defaultVariants: {
variant: "rectangle",
},
});
export interface SkeletonProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof skeletonVariants> {
width?: number | string;
height?: number | string;
}
const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
({ className, variant, width, height, style, ...props }, ref) => {
const inlineStyle: React.CSSProperties = {
...style,
...(width !== undefined && {
width: typeof width === "number" ? `${width}px` : width,
}),
...(height !== undefined && {
height: typeof height === "number" ? `${height}px` : height,
}),
};
return (
<div
ref={ref}
className={skeletonVariants({ variant, className })}
style={inlineStyle}
role="status"
aria-busy="true"
aria-label="Loading..."
{...props}
/>
);
},
);
Skeleton.displayName = "Skeleton";
export { Skeleton, skeletonVariants };

View File

@@ -0,0 +1,53 @@
import * as React from "react";
import {
EventCardSkeleton,
type EventCardSkeletonProps,
} from "./EventCardSkeleton";
export interface TimelineSkeletonProps extends Omit<
EventCardSkeletonProps,
"className" | "isLast" | "timeline"
> {
count?: number;
className?: string;
}
const TimelineSkeleton = React.forwardRef<
HTMLDivElement,
TimelineSkeletonProps
>(
(
{
count = 3,
variant = "compact",
showActions = false,
className,
...props
},
ref,
) => {
return (
<div
ref={ref}
className={className}
role="status"
aria-label="Loading timeline..."
>
{Array.from({ length: count }).map((_, i) => (
<EventCardSkeleton
key={i}
variant={variant}
showActions={showActions}
timeline={true}
isLast={i === count - 1}
{...props}
/>
))}
</div>
);
},
);
TimelineSkeleton.displayName = "TimelineSkeleton";
export { TimelineSkeleton };

View File

@@ -0,0 +1,26 @@
export { Skeleton, skeletonVariants } from "./Skeleton";
export type { SkeletonProps } from "./Skeleton";
export {
ProfileCardSkeleton,
profileCardSkeletonVariants,
} from "./ProfileCardSkeleton";
export type { ProfileCardSkeletonProps } from "./ProfileCardSkeleton";
export {
EventCardSkeleton,
eventCardSkeletonVariants,
} from "./EventCardSkeleton";
export type { EventCardSkeletonProps } from "./EventCardSkeleton";
export { TimelineSkeleton } from "./TimelineSkeleton";
export type { TimelineSkeletonProps } from "./TimelineSkeleton";
export { EventDetailSkeleton } from "./EventDetailSkeleton";
export type { EventDetailSkeletonProps } from "./EventDetailSkeleton";
export { CompactQuoteSkeleton } from "./CompactQuoteSkeleton";
export type { CompactQuoteSkeletonProps } from "./CompactQuoteSkeleton";
export { InlineReplySkeleton } from "./InlineReplySkeleton";
export type { InlineReplySkeletonProps } from "./InlineReplySkeleton";

View File

@@ -0,0 +1,82 @@
import {
Wifi,
WifiOff,
Loader2,
XCircle,
ShieldCheck,
ShieldAlert,
ShieldX,
ShieldQuestion,
Shield,
} from "lucide-react";
import type { RelayState } from "@/types/relay-state";
/**
* Get connection icon and label for a relay state
*/
export function getConnectionIcon(relay: RelayState | undefined) {
if (!relay) {
return {
icon: <WifiOff className="size-3 text-muted-foreground" />,
label: "Unknown",
};
}
const iconMap = {
connected: {
icon: <Wifi className="size-3 text-green-500" />,
label: "Connected",
},
connecting: {
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
label: "Connecting",
},
disconnected: {
icon: <WifiOff className="size-3 text-muted-foreground" />,
label: "Disconnected",
},
error: {
icon: <XCircle className="size-3 text-red-500" />,
label: "Connection Error",
},
};
return iconMap[relay.connectionState];
}
/**
* Get authentication icon and label for a relay state
* Returns null if no authentication is required
*/
export function getAuthIcon(relay: RelayState | undefined) {
if (!relay || relay.authStatus === "none") {
return null;
}
const iconMap = {
authenticated: {
icon: <ShieldCheck className="size-3 text-green-500" />,
label: "Authenticated",
},
challenge_received: {
icon: <ShieldQuestion className="size-3 text-yellow-500" />,
label: "Challenge Received",
},
authenticating: {
icon: <Loader2 className="size-3 text-yellow-500 animate-spin" />,
label: "Authenticating",
},
failed: {
icon: <ShieldX className="size-3 text-red-500" />,
label: "Authentication Failed",
},
rejected: {
icon: <ShieldAlert className="size-3 text-muted-foreground" />,
label: "Authentication Rejected",
},
none: {
icon: <Shield className="size-3 text-muted-foreground" />,
label: "No Authentication",
},
};
return iconMap[relay.authStatus] || iconMap.none;
}

View File

@@ -23,11 +23,16 @@ export default {
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' }
},
'skeleton-pulse': {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.5' }
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
'accordion-up': 'accordion-up 0.2s ease-out',
'skeleton-pulse': 'skeleton-pulse 1.5s ease-in-out infinite'
},
colors: {
background: 'hsl(var(--background))',