diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..45a8bd7 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,93 @@ +# GEMINI.md + +This file provides context and guidance for Gemini (and other AI agents) when working with the Grimoire repository. + +## Project Overview + +Grimoire is a Nostr protocol explorer and developer tool. It features a tiling window manager interface where each window is a Nostr "app" (profile viewer, event feed, NIP documentation, etc.). Commands are launched Unix-style via a `Cmd+K` palette. + +**Stack:** React 19 + TypeScript + Vite + TailwindCSS + Jotai + Dexie + Applesauce + +## Core Architecture + +### 1. Dual State System + +* **UI State** (`src/core/state.ts` + `src/core/logic.ts`): + * Managed by **Jotai** atoms, persisted to `localStorage`. + * **Mutations:** strict adherence to pure functions in `src/core/logic.ts` (`(state, payload) => newState`). + * **Scope:** Workspaces, windows, layout tree, active account. + +* **Nostr State** (`src/services/event-store.ts`): + * **Singleton `EventStore`** from `applesauce-core`. + * Single source of truth for all Nostr events. + * Reactive: Components subscribe via hooks (`useProfile`, `useTimeline`, `useNostrEvent`). + * **CRITICAL:** Do NOT create new `EventStore` instances. Use the singleton in `src/services/`. + +* **Relay State** (`src/services/relay-liveness.ts`): + * Singleton `RelayLiveness` tracks relay health. + * Persisted to Dexie. + +### 2. Window System + +* **Layout:** Recursive binary split layout via `react-mosaic-component`. +* **Structure:** + * **Leaf:** Window ID (UUID). + * **Branch:** Split space. +* **Constraint:** **Never manipulate the layout tree directly.** Use `updateLayout()` callbacks or `logic.ts` helpers. +* **Window Props:** `id`, `appId` (type identifier), `title`, `props`. + +### 3. Command System + +* **Definition:** `src/types/man.ts` defines commands as Unix man pages. +* **Flow:** User types command -> `argParser` resolves props -> Helper opens specific `appId` viewer. +* **Global Flags:** Defined in `src/lib/global-flags.ts` (e.g., `--title` overrides window title). + +### 4. Reactive Nostr Pattern + +* **Flow:** Relays -> EventStore -> Observables -> Component Hooks. +* **Helpers:** Use `applesauce-react` hooks or custom hooks in `src/hooks/`. +* **Replaceable Events:** Handled automatically by EventStore (kinds 0, 3, 10000+, 30000+). + +## Key Conventions + +* **Path Alias:** `@/` maps to `./src/`. +* **Styling:** TailwindCSS + HSL variables for theming (defined in `index.css`). +* **Organization:** Domain-based (`nostr/`, `ui/`, `services/`, `hooks/`, `lib/`). +* **Types:** Prefer `applesauce-core` types; extend in `src/types/`. + +## Important Patterns + +### Adding New Commands +1. Add entry to `manPages` in `src/types/man.ts`. +2. Create argument parser in `src/lib/*-parser.ts`. +3. Create viewer component for the `appId`. +4. Register viewer in `WindowTitle.tsx` (or equivalent registry). + +### Event Rendering +* **Pattern:** Registry-based rendering. +* **Files:** `src/components/nostr/kinds/index.tsx`. +* **Components:** `KindRenderer` (feed) and `DetailKindRenderer` (detail/full view). +* **Naming:** Use descriptive names (`LiveActivityRenderer`) not numbers (`Kind30311Renderer`). +* **Safety:** All renderers are wrapped in `EventErrorBoundary`. + +### Testing +* **Framework:** Vitest. +* **Commands:** + * `npm test`: Watch mode. + * `npm run test:run`: CI mode (single run). +* **Focus:** Test pure functions in `logic.ts`, parsers in `lib/*-parser.ts`, and utilities. + +## Critical Rules for Agents + +> [!IMPORTANT] +> **Do NOT create new instances of singletons.** +> * `EventStore`, `RelayPool`, `RelayLiveness` are singletons in `src/services/`. + +> [!IMPORTANT] +> **Respect the Layout Tree.** +> * Do not manually traverse or modify the Mosaic layout object. Use specific update callbacks. + +> [!NOTE] +> **Use the Knowledge Base.** +> * Refer to `CLAUDE.md` for the original documentation source. +> * Check `.claude/skills` for library-specific documentation (Applesauce, Nostr tools). diff --git a/package-lock.json b/package-lock.json index 77252b0..056419c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,9 +31,11 @@ "date-fns": "^4.1.0", "dexie": "^4.2.1", "dexie-react-hooks": "^4.2.0", + "hls-video-element": "^1.5.10", "hls.js": "^1.6.15", "jotai": "^2.15.2", "lucide-react": "latest", + "media-chrome": "^4.17.2", "prismjs": "^1.30.0", "react": "^19.2.1", "react-dom": "^19.2.1", @@ -4858,6 +4860,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ce-la-react": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/ce-la-react/-/ce-la-react-0.3.2.tgz", + "integrity": "sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA==", + "license": "BSD-3-Clause", + "peerDependencies": { + "react": ">=17.0.0" + } + }, "node_modules/chai": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", @@ -5097,6 +5108,12 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/custom-media-element": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/custom-media-element/-/custom-media-element-1.4.5.tgz", + "integrity": "sha512-cjrsQufETwxjvwZbYbKBCJNvmQ2++G9AvT45zDi7NXL9k2PdVcs2h0jQz96J6G4TMKRCcEsoJ+QTgQD00Igtjw==", + "license": "MIT" + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -5977,6 +5994,17 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hls-video-element": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/hls-video-element/-/hls-video-element-1.5.10.tgz", + "integrity": "sha512-FruzD03CaQlPlNKfXO1njPbo3jCSImAtFwX1OqgFbMllTQzdYqAHODiWan0q3mr1cYCONOWiAz2/nX+2qHHC+g==", + "license": "MIT", + "dependencies": { + "custom-media-element": "^1.4.5", + "hls.js": "^1.6.5", + "media-tracks": "^0.3.4" + } + }, "node_modules/hls.js": { "version": "1.6.15", "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", @@ -6787,6 +6815,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/media-chrome": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-4.17.2.tgz", + "integrity": "sha512-o/IgiHx0tdSVwRxxqF5H12FK31A/A8T71sv3KdAvh7b6XeBS9dXwqvIFwlR9kdEuqg3n7xpmRIuL83rmYq8FTg==", + "license": "MIT", + "dependencies": { + "ce-la-react": "^0.3.2" + } + }, + "node_modules/media-tracks": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/media-tracks/-/media-tracks-0.3.4.tgz", + "integrity": "sha512-5SUElzGMYXA7bcyZBL1YzLTxH9Iyw1AeYNJxzByqbestrrtB0F3wfiWUr7aROpwodO4fwnxOt78Xjb3o3ONNQg==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", diff --git a/package.json b/package.json index 5969eb5..571e01a 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,11 @@ "date-fns": "^4.1.0", "dexie": "^4.2.1", "dexie-react-hooks": "^4.2.0", + "hls-video-element": "^1.5.10", "hls.js": "^1.6.15", "jotai": "^2.15.2", "lucide-react": "latest", + "media-chrome": "^4.17.2", "prismjs": "^1.30.0", "react": "^19.2.1", "react-dom": "^19.2.1", diff --git a/src/components/ManPage.tsx b/src/components/ManPage.tsx index 5b628a6..6f46abc 100644 --- a/src/components/ManPage.tsx +++ b/src/components/ManPage.tsx @@ -55,13 +55,13 @@ export default function ManPage({ cmd }: ManPageProps) { {page.options && page.options.length > 0 && (

OPTIONS

-
+
{page.options.map((opt, i) => ( -
- +
+
{opt.flag} - - {opt.description} +
+
{opt.description}
))}
@@ -72,12 +72,29 @@ export default function ManPage({ cmd }: ManPageProps) { {page.examples && page.examples.length > 0 && (

EXAMPLES

-
- {page.examples.map((example, i) => ( -
- {example} -
- ))} +
+ {page.examples.map((example, i) => { + // Split command from description + // Pattern: command ends before first capital letter after flags + const match = example.match(/^(.*?)(\s+[A-Z].*)$/); + if (match) { + const [, command, description] = match; + return ( +
+
{command}
+
+ {description.trim()} +
+
+ ); + } + // Fallback for examples without descriptions + return ( +
+ {example} +
+ ); + })}
)} diff --git a/src/components/live/StreamChat.tsx b/src/components/live/StreamChat.tsx new file mode 100644 index 0000000..0805c5f --- /dev/null +++ b/src/components/live/StreamChat.tsx @@ -0,0 +1,180 @@ +import { useMemo } from "react"; +import { useLiveTimeline } from "@/hooks/useLiveTimeline"; +import type { NostrEvent } from "@/types/nostr"; +import { kinds } from "nostr-tools"; +import { UserName } from "../nostr/UserName"; +import { RichText } from "../nostr/RichText"; +import { Zap } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { getZapAmount, getZapSender } from "applesauce-core/helpers"; + +interface StreamChatProps { + streamEvent: NostrEvent; + streamRelays: string[]; + hostRelays: string[]; + className?: string; +} + +// isConsecutive removed + +const isSameDay = (date1: Date, date2: Date) => { + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ); +}; + +export function StreamChat({ + streamEvent, + streamRelays, + hostRelays, + className, +}: StreamChatProps) { + // const [message, setMessage] = useState(""); + + // Combine stream relays + host relays + const allRelays = useMemo( + () => Array.from(new Set([...streamRelays, ...hostRelays])), + [streamRelays, hostRelays], + ); + + // Fetch chat messages (kind 1311) and zaps (kind 9735) that a-tag this stream + const timelineFilter = useMemo( + () => ({ + kinds: [1311, 9735], + "#a": [ + `${streamEvent.kind}:${streamEvent.pubkey}:${streamEvent.tags.find((t) => t[0] === "d")?.[1] || ""}`, + ], + limit: 100, + }), + [streamEvent], + ); + + const { events: allMessages } = useLiveTimeline( + `stream-feed-${streamEvent.id}`, + timelineFilter, + allRelays, + { stream: true }, + ); + + /* + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + // TODO: Implement sending chat message + console.log("Send message:", message); + setMessage(""); + }; + */ + + return ( +
+ {/* Chat messages area */} +
+ {allMessages.map((event, index) => { + const currentDate = new Date(event.created_at * 1000); + const prevEvent = allMessages[index + 1]; + // If prevEvent exists, compare days. If different, we need a separator AFTER this message (visually before/above it) + // Actually, in flex-col-reverse: + // [Newest Message] (index 0) + // + // [Old Message] (index 1) + + // Wait, logic is simpler: + // Loop through events. Determine if Date Header is needed between this event and the next one (older one). + + const prevDate = prevEvent + ? new Date(prevEvent.created_at * 1000) + : null; + const showDateHeader = !prevDate || !isSameDay(currentDate, prevDate); + + return ( +
+ {event.kind === kinds.Zap ? ( + + ) : ( + + )} + {showDateHeader && ( +
+ + {currentDate.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + })} + +
+ )} +
+ ); + })} +
+ + {/* Chat input - Commented out for now */} + {/*
+ setMessage(e.target.value)} + placeholder="Send message..." + className="flex-1 px-2 py-1 bg-transparent text-sm focus:outline-none placeholder:text-muted-foreground/50 h-8" + /> + +
*/} +
+ ); +} + +function ChatMessage({ event }: { event: NostrEvent }) { + return ( + + + + ); +} + +function ZapMessage({ event }: { event: NostrEvent }) { + const amount = getZapAmount(event); + const zapper = getZapSender(event); + + if (!amount || !zapper) return null; + + return ( + +
+ + + + + {(amount / 1000).toLocaleString("en", { + notation: "compact", + })} + + +
+
+ ); +} diff --git a/src/components/live/VideoPlayer.tsx b/src/components/live/VideoPlayer.tsx index a6e2037..ab69ab8 100644 --- a/src/components/live/VideoPlayer.tsx +++ b/src/components/live/VideoPlayer.tsx @@ -1,175 +1,74 @@ -import { useEffect, useRef, useState } from "react"; -import Hls from "hls.js"; -import { ExternalLink } from "lucide-react"; +import type { CSSProperties } from "react"; +import { + MediaController, + MediaControlBar, + MediaTimeRange, + MediaTimeDisplay, + MediaVolumeRange, + MediaPlayButton, + MediaMuteButton, + MediaFullscreenButton, + MediaPipButton, +} from "media-chrome/react"; +import "hls-video-element"; interface VideoPlayerProps { url: string; - autoPlay?: boolean; title?: string; className?: string; } -export function VideoPlayer({ - url, - autoPlay = false, - title, - className = "", -}: VideoPlayerProps) { - const videoRef = useRef(null); - const hlsRef = useRef(null); - const isMountedRef = useRef(true); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isReady, setIsReady] = useState(false); - - // Effect 1: Setup video player (only depends on url) - useEffect(() => { - if (!videoRef.current || !url) return; - - isMountedRef.current = true; - const video = videoRef.current; - - // Reset state - if (isMountedRef.current) { - setError(null); - setIsLoading(true); - setIsReady(false); - } - - // Detect HLS format - const isHLSFormat = - url.includes(".m3u8") || url.includes("application/x-mpegURL"); - - // Named event handlers for proper cleanup - const handleLoadedData = () => { - if (isMountedRef.current) { - setIsLoading(false); - setIsReady(true); - } - }; - - const handleVideoError = () => { - if (isMountedRef.current) { - setError("Failed to load video"); - setIsLoading(false); - } - }; - - if (isHLSFormat) { - // Check for native HLS support (Safari) - if (video.canPlayType("application/vnd.apple.mpegurl")) { - video.src = url; - video.addEventListener("loadeddata", handleLoadedData); - video.addEventListener("error", handleVideoError); - } - // Use hls.js for browsers without native support - else if (Hls.isSupported()) { - const hls = new Hls({ - enableWorker: true, - lowLatencyMode: true, - }); - - hls.loadSource(url); - hls.attachMedia(video); - - hls.on(Hls.Events.MANIFEST_PARSED, () => { - if (isMountedRef.current) { - setIsLoading(false); - setIsReady(true); - } - }); - - hls.on(Hls.Events.ERROR, (_event, data) => { - console.error("HLS error:", data); - if (data.fatal && isMountedRef.current) { - setError(`Stream error: ${data.type}`); - setIsLoading(false); - } - }); - - hlsRef.current = hls; - } else { - setError("HLS streaming not supported"); - setIsLoading(false); - } - } else { - // Direct video URL - video.src = url; - video.addEventListener("loadeddata", handleLoadedData); - video.addEventListener("error", handleVideoError); - } - - // Cleanup - return () => { - isMountedRef.current = false; - - // Remove event listeners - video.removeEventListener("loadeddata", handleLoadedData); - video.removeEventListener("error", handleVideoError); - - // Cleanup HLS - if (hlsRef.current) { - hlsRef.current.detachMedia(); - hlsRef.current.destroy(); - hlsRef.current = null; - } - - // Clear video source - video.src = ""; - video.load(); - }; - }, [url]); - - // Effect 2: Handle autoplay (separate from player setup) - useEffect(() => { - if (!videoRef.current || !autoPlay || !isReady || isLoading) return; - - const video = videoRef.current; - - video.play().catch((err) => { - console.error("Autoplay failed:", err); - if (isMountedRef.current) { - setError("Click to play"); - } - }); - }, [autoPlay, isReady, isLoading]); +export function VideoPlayer({ url, title, className = "" }: VideoPlayerProps) { + // Detect HLS format + const isHLS = url.includes(".m3u8") || url.includes("application/x-mpegURL"); return ( -
-
+ + + + + + + + + + ); } diff --git a/src/components/live/VideoPlayerWithOverlay.tsx b/src/components/live/VideoPlayerWithOverlay.tsx new file mode 100644 index 0000000..84e2e33 --- /dev/null +++ b/src/components/live/VideoPlayerWithOverlay.tsx @@ -0,0 +1,112 @@ +import { useState, useRef, useEffect } from "react"; +import { VideoPlayer } from "./VideoPlayer"; +import { StatusBadge } from "./StatusBadge"; +import { UserName } from "../nostr/UserName"; +import { Label } from "../ui/Label"; +import type { LiveStatus } from "@/types/live-activity"; +import { cn } from "@/lib/utils"; + +interface VideoPlayerWithOverlayProps { + url: string; + title: string; + description?: string; + hostPubkey: string; + status: LiveStatus; + hashtags: string[]; + className?: string; +} + +export function VideoPlayerWithOverlay({ + url, + title, + description, + hostPubkey, + status, + hashtags, + className, +}: VideoPlayerWithOverlayProps) { + const [isHovering, setIsHovering] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const containerRef = useRef(null); + + // Detect video playing state + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const video = container.querySelector("video"); + if (!video) return; + + const handlePlay = () => setIsPlaying(true); + const handlePause = () => setIsPlaying(false); + + video.addEventListener("play", handlePlay); + video.addEventListener("pause", handlePause); + + return () => { + video.removeEventListener("play", handlePlay); + video.removeEventListener("pause", handlePause); + }; + }, []); + + return ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + + + {/* Live indicator - hides when playing */} + {status === "live" && ( +
+ +
+ )} + + {/* Info overlay on hover */} +
+
+

{title}

+ + {description && ( +

+ {description} +

+ )} + +
+ +
+ + {hashtags.length > 0 && ( +
+ {hashtags.slice(0, 5).map((tag) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/nostr/RichText.tsx b/src/components/nostr/RichText.tsx index 5db48a4..0c92085 100644 --- a/src/components/nostr/RichText.tsx +++ b/src/components/nostr/RichText.tsx @@ -56,6 +56,7 @@ interface RichTextProps { className?: string; depth?: number; options?: RichTextOptions; + children?: React.ReactNode; } // Content node component types for rendering @@ -79,6 +80,7 @@ export function RichText({ className = "", depth = 1, options = {}, + children, }: RichTextProps) { // Merge provided options with defaults const mergedOptions: Required = { @@ -89,15 +91,15 @@ export function RichText({ // Call hook unconditionally - it will handle undefined/null const trimmedEvent = event ? { - ...event, - content: event.content.trim(), - } + ...event, + content: event.content.trim(), + } : undefined; const renderedContent = useRenderedContent( content ? ({ - content, - } as NostrEvent) + content, + } as NostrEvent) : trimmedEvent, contentComponents, ); @@ -109,6 +111,7 @@ export function RichText({ dir="auto" className={cn("leading-relaxed break-words", className)} > + {children} {renderedContent}
diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 5a69ace..9f5a7da 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -50,7 +50,7 @@ export interface BaseEventProps { */ export function EventAuthor({ pubkey, - label, + label: _label, }: { pubkey: string; label?: string; diff --git a/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx b/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx index 08ff089..187d373 100644 --- a/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx +++ b/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx @@ -6,11 +6,11 @@ import { getLiveHost, } from "@/lib/live-activity"; import { VideoPlayer } from "@/components/live/VideoPlayer"; +import { StreamChat } from "@/components/live/StreamChat"; import { StatusBadge } from "@/components/live/StatusBadge"; -import { Label } from "@/components/ui/Label"; import { UserName } from "../UserName"; import { Calendar } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { useOutboxRelays } from "@/hooks/useOutboxRelays"; interface LiveActivityDetailRendererProps { event: NostrEvent; @@ -23,21 +23,26 @@ export function LiveActivityDetailRenderer({ const status = useMemo(() => getLiveStatus(event), [event]); const hostPubkey = useMemo(() => getLiveHost(event), [event]); + // Get host's relay list for chat + const { relays: hostRelays } = useOutboxRelays({ + authors: [hostPubkey], + }); + + const videoUrl = + status === "live" && activity.streaming + ? activity.streaming + : status === "ended" && activity.recording + ? activity.recording + : null; + return (
- {/* Video/Media Section */} + {/* Video Section */}
- {status === "live" && activity.streaming ? ( + {videoUrl ? ( - ) : status === "ended" && activity.recording ? ( - ) : activity.image ? (
@@ -70,110 +75,23 @@ export function LiveActivityDetailRenderer({ )}
- {/* Content Section */} -
-
- {/* Header */} -
-
-
- -

- {activity.title || "Untitled Live Activity"} -

-
+ {/* Compact title bar */} +
+

+ {activity.title || "Untitled Live Activity"} +

+ +
- {activity.summary && ( -

- {activity.summary} -

- )} -
- -
- - {/* Participants with Roles */} - {activity.participants.length > 1 && ( -
-

- Speakers & Participants -

-
- {activity.participants.map((participant) => ( -
-
- -
- -
- ))} -
-
- )} - - {/* Hashtags */} - {activity.hashtags.length > 0 && ( -
- {activity.hashtags - .filter((t) => !t.startsWith("internal:")) - .map((tag) => ( - - ))} -
- )} - - {/* Relays */} - {activity.relays.length > 0 && ( -
-

Relays

-
- {activity.relays.map((relay) => ( -
- {relay} -
- ))} -
-
- )} -
+ {/* Chat Section */} +
+
); } - -// Participant Role Badge -function ParticipantRoleBadge({ role }: { role: string }) { - const roleColors: Record = { - Host: "bg-purple-600 text-white", - Speaker: "bg-blue-600 text-white", - Moderator: "bg-green-600 text-white", - Participant: "bg-neutral-600 text-white", - }; - - const className = roleColors[role] || roleColors.Participant; - - return ( -
- {role} -
- ); -} diff --git a/src/components/nostr/kinds/LiveActivityRenderer.tsx b/src/components/nostr/kinds/LiveActivityRenderer.tsx index c2206b5..e4d0b4a 100644 --- a/src/components/nostr/kinds/LiveActivityRenderer.tsx +++ b/src/components/nostr/kinds/LiveActivityRenderer.tsx @@ -4,7 +4,6 @@ import { parseLiveActivity, getLiveStatus, getLiveHost, - formatStartTime, } from "@/lib/live-activity"; import { BaseEventContainer, ClickableEventTitle } from "./BaseEventRenderer"; import { Label } from "@/components/ui/Label"; diff --git a/src/hooks/useLiveTimeline.ts b/src/hooks/useLiveTimeline.ts new file mode 100644 index 0000000..8955815 --- /dev/null +++ b/src/hooks/useLiveTimeline.ts @@ -0,0 +1,129 @@ +import { useState, useEffect, useMemo } from "react"; +import pool from "@/services/relay-pool"; +import type { NostrEvent, Filter } from "nostr-tools"; +import { useEventStore, useObservableMemo } from "applesauce-react/hooks"; +import { isNostrEvent } from "@/lib/type-guards"; + +interface UseLiveTimelineOptions { + limit?: number; + stream?: boolean; +} + +interface UseLiveTimelineReturn { + events: NostrEvent[]; + loading: boolean; + error: Error | null; + eoseReceived: boolean; +} + +/** + * Hook that combines REQ streaming (like useReqTimeline) with EventStore reactivity (like useTimeline). + * - Subscribes to relays using pool.subscription (populating the EventStore). + * - Returns a memoized observable from eventStore using eventStore.timeline(filter). + * @param id - Unique identifier for this timeline (for debugging/logging) + * @param filters - Nostr filter object + * @param relays - Array of relay URLs + * @param options - Additional options like limit and stream + * @returns Object containing events array (from store, sorted), loading state, and error + */ +export function useLiveTimeline( + id: string, + filters: Filter | Filter[], + relays: string[], + options: UseLiveTimelineOptions = { limit: 200 }, +): UseLiveTimelineReturn { + const eventStore = useEventStore(); + const { limit, stream = false } = options; + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [eoseReceived, setEoseReceived] = useState(false); + + // Stabilize filters and relays for dependency array + // Using JSON.stringify and .join() for deep comparison - this is intentional + // eslint-disable-next-line react-hooks/exhaustive-deps + const stableFilters = useMemo(() => filters, [JSON.stringify(filters)]); + // eslint-disable-next-line react-hooks/exhaustive-deps + const stableRelays = useMemo(() => relays, [relays.join(",")]); + + // 1. Subscription Effect - Fetch data and feed EventStore + useEffect(() => { + if (relays.length === 0) { + setLoading(false); + return; + } + + console.log("LiveTimeline: Starting query", { + id, + relays, + filters, + limit, + stream, + }); + + setLoading(true); + setError(null); + setEoseReceived(false); + + // Normalize filters to array + const filterArray = Array.isArray(filters) ? filters : [filters]; + + // Add limit to filters if specified + const filtersWithLimit = filterArray.map((f) => ({ + ...f, + limit: limit || f.limit, + })); + + const observable = pool.subscription(relays, filtersWithLimit, { + retries: 5, + reconnect: 5, + resubscribe: true, + eventStore, // Automatically add events to store + }); + + const subscription = observable.subscribe( + (response) => { + // Response can be an event or 'EOSE' string + if (typeof response === "string") { + console.log("LiveTimeline: EOSE received"); + setEoseReceived(true); + if (!stream) { + setLoading(false); + } + } else if (isNostrEvent(response)) { + // Event automatically added to store by pool.subscription (via options.eventStore) + } else { + console.warn("LiveTimeline: Unexpected response type:", response); + } + }, + (err: Error) => { + console.error("LiveTimeline: Error", err); + setError(err); + setLoading(false); + }, + () => { + // Only set loading to false if not streaming + if (!stream) { + setLoading(false); + } + }, + ); + + return () => { + subscription.unsubscribe(); + }; + }, [id, stableFilters, stableRelays, limit, stream, eventStore]); + + // 2. Observable Effect - Read from EventStore + const timelineEvents = useObservableMemo(() => { + // eventStore.timeline returns an Observable that emits sorted array of events matching filter + // It updates whenever relevant events are added/removed from store + return eventStore.timeline(filters); + }, [stableFilters]); + + return { + events: timelineEvents || [], + loading, + error, + eoseReceived, + }; +}