ui: improve man page layout for options and examples

- Display option flags on separate lines with indented descriptions to prevent overflow
- Parse and separate example commands from their descriptions
- Highlight commands in accent color with muted descriptions below
- Increase spacing between items for better readability
This commit is contained in:
Alejandro Gómez
2025-12-18 09:37:14 +01:00
parent 97c89142ae
commit 3b06e23686
12 changed files with 691 additions and 296 deletions

93
GEMINI.md Normal file
View File

@@ -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).

43
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -55,13 +55,13 @@ export default function ManPage({ cmd }: ManPageProps) {
{page.options && page.options.length > 0 && (
<section>
<h2 className="font-bold mb-2">OPTIONS</h2>
<div className="ml-8 space-y-2">
<div className="ml-8 space-y-3">
{page.options.map((opt, i) => (
<div key={i} className="flex gap-4">
<span className="text-accent font-semibold min-w-[120px]">
<div key={i}>
<div className="text-accent font-semibold">
{opt.flag}
</span>
<span className="text-muted-foreground">{opt.description}</span>
</div>
<div className="ml-8 text-muted-foreground">{opt.description}</div>
</div>
))}
</div>
@@ -72,12 +72,29 @@ export default function ManPage({ cmd }: ManPageProps) {
{page.examples && page.examples.length > 0 && (
<section>
<h2 className="font-bold mb-2">EXAMPLES</h2>
<div className="ml-8 space-y-1">
{page.examples.map((example, i) => (
<div key={i} className="text-muted-foreground">
{example}
</div>
))}
<div className="ml-8 space-y-3">
{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 (
<div key={i}>
<div className="text-accent font-medium">{command}</div>
<div className="ml-8 text-muted-foreground text-sm">
{description.trim()}
</div>
</div>
);
}
// Fallback for examples without descriptions
return (
<div key={i} className="text-accent font-medium">
{example}
</div>
);
})}
</div>
</section>
)}

View File

@@ -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 (
<div className={cn("flex flex-col h-full", className)}>
{/* Chat messages area */}
<div className="flex-1 flex flex-col-reverse gap-0.5 overflow-y-auto p-0 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
{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)
// <Day Label Today>
// [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 (
<div key={event.id} className="flex flex-col-reverse">
{event.kind === kinds.Zap ? (
<ZapMessage event={event} />
) : (
<ChatMessage event={event} />
)}
{showDateHeader && (
<div className="flex justify-center py-2 pointer-events-none">
<span className="text-[10px] font-light text-muted-foreground">
{currentDate.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</span>
</div>
)}
</div>
);
})}
</div>
{/* Chat input - Commented out for now */}
{/* <form
onSubmit={handleSubmit}
className="flex gap-0 border-t border-border bg-background"
>
<input
type="text"
value={message}
onChange={(e) => 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"
/>
<Button
type="submit"
disabled={!message.trim()}
variant="default"
size="sm"
aria-label="Send message"
className="h-8 rounded-none px-3"
>
<Send className="w-4 h-4" />
</Button>
</form> */}
</div>
);
}
function ChatMessage({ event }: { event: NostrEvent }) {
return (
<RichText
className="text-xs leading-tight text-foreground/90"
event={event}
options={{ showMedia: false, showEventEmbeds: false }}
>
<UserName
pubkey={event.pubkey}
className="font-bold leading-tight flex-shrink-0 mr-1.5 text-accent"
/>
</RichText>
);
}
function ZapMessage({ event }: { event: NostrEvent }) {
const amount = getZapAmount(event);
const zapper = getZapSender(event);
if (!amount || !zapper) return null;
return (
<RichText
className="text-xs"
event={event}
options={{ showMedia: false, showEventEmbeds: false }}
>
<div className="flex flex-row justify-between items-center">
<UserName pubkey={zapper} className="font-bold text-xs truncate" />
<span className="text-xs font-bold text-yellow-500 inline-flex items-center gap-1">
<Zap className="w-3 h-3 fill-yellow-500" />
<span className="text-sm">
{(amount / 1000).toLocaleString("en", {
notation: "compact",
})}
</span>
</span>
</div>
</RichText>
);
}

View File

@@ -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<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const isMountedRef = useRef(true);
const [error, setError] = useState<string | null>(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 (
<div className={`video-player relative bg-black ${className}`}>
<video
ref={videoRef}
className="w-full aspect-video"
controls
playsInline
title={title}
/>
{/* Loading State */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="text-white text-center">
<div className="animate-spin text-2xl mb-2"></div>
<p className="text-sm">Loading stream...</p>
</div>
</div>
<MediaController
className={className}
style={
{
"--media-secondary-color": "hsl(270 100% 70%)",
"--media-primary-color": "hsl(210 40% 98%)",
"--media-control-background": "hsl(222.2 84% 4.9% / 0.8)",
"--media-control-hover-background": "hsl(270 100% 70% / 0.2)",
"--media-range-track-background": "hsl(217.2 32.6% 17.5%)",
"--media-preview-background": "hsl(222.2 84% 4.9%)",
"--media-text-color": "hsl(210 40% 98%)",
aspectRatio: "16 / 9",
width: "100%",
} as CSSProperties
}
>
{isHLS ? (
/* @ts-expect-error Web Component */
<hls-video
slot="media"
src={url}
playsInline={true}
title={title}
style={{ aspectRatio: "16 / 9" }}
config={{
lowLatencyMode: true,
}}
/>
) : (
<video
slot="media"
src={url}
playsInline={true}
title={title}
style={{ aspectRatio: "16 / 9" }}
/>
)}
{/* Error State */}
{error && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 text-white p-4">
<p className="text-xl mb-2"></p>
<p className="text-center text-sm mb-4">{error}</p>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-1.5 bg-purple-600 hover:bg-purple-700 rounded text-sm transition-colors"
>
<ExternalLink className="size-4" />
Open in new tab
</a>
</div>
)}
</div>
<MediaControlBar>
<MediaPlayButton />
<MediaTimeRange />
<MediaTimeDisplay showDuration />
<MediaMuteButton />
<MediaVolumeRange />
<MediaPipButton />
<MediaFullscreenButton />
</MediaControlBar>
</MediaController>
);
}

View File

@@ -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<HTMLDivElement>(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 (
<div
ref={containerRef}
className={cn("relative", className)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<VideoPlayer url={url} title={title} />
{/* Live indicator - hides when playing */}
{status === "live" && (
<div
className={cn(
"absolute top-4 left-4 transition-opacity duration-300",
isPlaying && !isHovering ? "opacity-0" : "opacity-100",
)}
>
<StatusBadge status={status} size="sm" />
</div>
)}
{/* Info overlay on hover */}
<div
className={cn(
"absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent",
"flex flex-col justify-end p-6 gap-3",
"transition-opacity duration-300",
"pointer-events-none",
isHovering ? "opacity-100" : "opacity-0",
)}
>
<div className="space-y-2">
<h2 className="text-2xl font-bold text-white">{title}</h2>
{description && (
<p className="text-sm text-neutral-200 line-clamp-2">
{description}
</p>
)}
<div className="flex items-center gap-2">
<UserName
pubkey={hostPubkey}
className="text-sm text-white font-semibold"
/>
</div>
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-2">
{hashtags.slice(0, 5).map((tag) => (
<Label key={tag} size="sm">
#{tag}
</Label>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -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<RichTextOptions> = {
@@ -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}
</div>
</OptionsContext.Provider>

View File

@@ -50,7 +50,7 @@ export interface BaseEventProps {
*/
export function EventAuthor({
pubkey,
label,
label: _label,
}: {
pubkey: string;
label?: string;

View File

@@ -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 (
<div className="flex flex-col h-full bg-background">
{/* Video/Media Section */}
{/* Video Section */}
<div className="flex-shrink-0">
{status === "live" && activity.streaming ? (
{videoUrl ? (
<VideoPlayer
url={activity.streaming}
autoPlay
title={activity.title}
/>
) : status === "ended" && activity.recording ? (
<VideoPlayer
url={activity.recording}
autoPlay
title={activity.title}
url={videoUrl}
title={activity.title || "Untitled Live Activity"}
/>
) : activity.image ? (
<div className="relative aspect-video">
@@ -70,110 +75,23 @@ export function LiveActivityDetailRenderer({
)}
</div>
{/* Content Section */}
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex flex-col gap-0">
<UserName
pubkey={hostPubkey}
className="text-lg font-semibold"
/>
<h1 className="text-3xl font-bold mb-3">
{activity.title || "Untitled Live Activity"}
</h1>
</div>
{/* Compact title bar */}
<div className="flex items-center justify-between gap-4">
<h1 className="text-lg font-bold flex-1 line-clamp-1">
{activity.title || "Untitled Live Activity"}
</h1>
<UserName pubkey={hostPubkey} className="text-sm font-semibold line-clamp-1" />
</div>
{activity.summary && (
<p className="text-muted-foreground text-md">
{activity.summary}
</p>
)}
</div>
<StatusBadge status={status} size="md" />
</div>
{/* Participants with Roles */}
{activity.participants.length > 1 && (
<div>
<h2 className="text-lg font-semibold mb-3">
Speakers & Participants
</h2>
<div className="grid gap-2">
{activity.participants.map((participant) => (
<div
key={participant.pubkey}
className="flex items-center gap-3 p-3 bg-muted/50 rounded-lg"
>
<div className="flex-1">
<UserName
pubkey={participant.pubkey}
className="font-medium"
/>
</div>
<ParticipantRoleBadge role={participant.role} />
</div>
))}
</div>
</div>
)}
{/* Hashtags */}
{activity.hashtags.length > 0 && (
<div className="flex flex-wrap gap-2">
{activity.hashtags
.filter((t) => !t.startsWith("internal:"))
.map((tag) => (
<Label key={tag} size="md">
{tag}
</Label>
))}
</div>
)}
{/* Relays */}
{activity.relays.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3">Relays</h2>
<div className="space-y-1">
{activity.relays.map((relay) => (
<div
key={relay}
className="text-xs font-mono text-muted-foreground bg-muted/50 px-3 py-2 rounded"
>
{relay}
</div>
))}
</div>
</div>
)}
</div>
{/* Chat Section */}
<div className="flex-1 min-h-0">
<StreamChat
streamEvent={event}
streamRelays={activity.relays}
hostRelays={hostRelays}
className="h-full"
/>
</div>
</div>
);
}
// Participant Role Badge
function ParticipantRoleBadge({ role }: { role: string }) {
const roleColors: Record<string, string> = {
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 (
<div
className={cn(
"px-2 py-1 rounded text-xs font-semibold flex-shrink-0",
className,
)}
>
{role}
</div>
);
}

View File

@@ -4,7 +4,6 @@ import {
parseLiveActivity,
getLiveStatus,
getLiveHost,
formatStartTime,
} from "@/lib/live-activity";
import { BaseEventContainer, ClickableEventTitle } from "./BaseEventRenderer";
import { Label } from "@/components/ui/Label";

View File

@@ -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<Error | null>(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,
};
}