diff --git a/src/components/EventDetailViewer.tsx b/src/components/EventDetailViewer.tsx index f832d03..9171b64 100644 --- a/src/components/EventDetailViewer.tsx +++ b/src/components/EventDetailViewer.tsx @@ -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: , - label: "Unknown", - }; - } - - const iconMap = { - connected: { - icon: , - label: "Connected", - }, - connecting: { - icon: , - label: "Connecting", - }, - disconnected: { - icon: , - label: "Disconnected", - }, - error: { - icon: , - label: "Connection Error", - }, - }; - return iconMap[relay.connectionState]; -} - -function getAuthIcon(relay: RelayState | undefined) { - if (!relay || relay.authStatus === "none") { - return null; - } - - const iconMap = { - authenticated: { - icon: , - label: "Authenticated", - }, - challenge_received: { - icon: , - label: "Challenge Received", - }, - authenticating: { - icon: , - label: "Authenticating", - }, - failed: { - icon: , - label: "Authentication Failed", - }, - rejected: { - icon: , - label: "Authentication Rejected", - }, - none: { - icon: , - 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 ( -
-
Loading event...
+
+
); } @@ -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) => ({ diff --git a/src/components/GlobalAuthPrompt.tsx b/src/components/GlobalAuthPrompt.tsx index 8edcadc..00221fd 100644 --- a/src/components/GlobalAuthPrompt.tsx +++ b/src/components/GlobalAuthPrompt.tsx @@ -38,7 +38,7 @@ function AuthToast({ showInboxOutbox={false} variant="prompt" iconClassname="size-4" - urlClassname="text-sm" + urlClassname="break-all text-sm" />
@@ -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"); } diff --git a/src/components/ProfileViewer.tsx b/src/components/ProfileViewer.tsx index 119cb7a..c4ccbf4 100644 --- a/src/components/ProfileViewer.tsx +++ b/src/components/ProfileViewer.tsx @@ -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: , - label: "Unknown", - }; - } - - const iconMap = { - connected: { - icon: , - label: "Connected", - }, - connecting: { - icon: , - label: "Connecting", - }, - disconnected: { - icon: , - label: "Disconnected", - }, - error: { - icon: , - label: "Connection Error", - }, - }; - return iconMap[relay.connectionState]; -} - -function getAuthIcon(relay: RelayState | undefined) { - if (!relay || relay.authStatus === "none") { - return null; - } - - const iconMap = { - authenticated: { - icon: , - label: "Authenticated", - }, - challenge_received: { - icon: , - label: "Challenge Received", - }, - authenticating: { - icon: , - label: "Authenticating", - }, - failed: { - icon: , - label: "Authentication Failed", - }, - rejected: { - icon: , - label: "Authentication Rejected", - }, - none: { - icon: , - 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 */}
- {!profile && !profileEvent && ( -
- Loading profile... -
- )} + {!profile && !profileEvent && } {!profile && profileEvent && (
diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index c1dc882..2e2267f 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -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: , - label: "Unknown", - }; - } - - const iconMap = { - connected: { - icon: , - label: "Connected", - }, - connecting: { - icon: , - label: "Connecting", - }, - disconnected: { - icon: , - label: "Disconnected", - }, - error: { - icon: , - label: "Connection Error", - }, - }; - return iconMap[relay.connectionState]; -} - -function getAuthIcon(relay: RelayState | undefined) { - if (!relay || relay.authStatus === "none") { - return null; - } - - const iconMap = { - authenticated: { - icon: , - label: "Authenticated", - }, - challenge_received: { - icon: , - label: "Challenge Received", - }, - authenticating: { - icon: , - label: "Authenticating", - }, - failed: { - icon: , - label: "Authentication Failed", - }, - rejected: { - icon: , - label: "Authentication Rejected", - }, - none: { - icon: , - label: "No Authentication", - }, - }; - return iconMap[relay.authStatus] || iconMap.none; -} - interface ReqViewerProps { filter: NostrFilter; relays?: string[]; @@ -972,8 +902,8 @@ export default function ReqViewer({
{/* Loading: Before EOSE received */} {loading && events.length === 0 && !eoseReceived && ( -
- Loading events... +
+
)} diff --git a/src/components/nostr/EmbeddedEvent.tsx b/src/components/nostr/EmbeddedEvent.tsx index 45387ca..68b8126 100644 --- a/src/components/nostr/EmbeddedEvent.tsx +++ b/src/components/nostr/EmbeddedEvent.tsx @@ -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 ( - - Loading event... - +
+ +
); } diff --git a/src/components/nostr/MediaEmbed.tsx b/src/components/nostr/MediaEmbed.tsx index 7d4a8c7..fd121f1 100644 --- a/src/components/nostr/MediaEmbed.tsx +++ b/src/components/nostr/MediaEmbed.tsx @@ -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; +}) => ( +
+ {children} +
+); + /** * 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 (
-

Failed to load media

- - Open in new tab - +

+ Failed to load media +

+
+ {mediaType === "image" && ( + + )} + + Open in new tab + +
); } - // Image rendering with zoom + // Image rendering with zoom, placeholder, and fade-in if (mediaType === "image") { - const imageElement = ( - {alt + {/* Skeleton placeholder */} + {showPlaceholder && isLoading && ( + )} - style={{ - maxHeight: presetStyles.maxHeight, - maxWidth: preset === "thumbnail" ? presetStyles.maxWidth : "100%", - }} - onError={handleError} - /> + + {/* Image with fade-in */} + {alt +
); return enableZoom ? ( - {imageElement} + {imageContent} ) : ( - imageElement + imageContent ); } - // Video rendering with inline playback + // Video rendering with placeholder and loading state if (mediaType === "video") { return ( -