mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-04 01:31:11 +02:00
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:
93
GEMINI.md
Normal file
93
GEMINI.md
Normal 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
43
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
180
src/components/live/StreamChat.tsx
Normal file
180
src/components/live/StreamChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
112
src/components/live/VideoPlayerWithOverlay.tsx
Normal file
112
src/components/live/VideoPlayerWithOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -50,7 +50,7 @@ export interface BaseEventProps {
|
||||
*/
|
||||
export function EventAuthor({
|
||||
pubkey,
|
||||
label,
|
||||
label: _label,
|
||||
}: {
|
||||
pubkey: string;
|
||||
label?: string;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
parseLiveActivity,
|
||||
getLiveStatus,
|
||||
getLiveHost,
|
||||
formatStartTime,
|
||||
} from "@/lib/live-activity";
|
||||
import { BaseEventContainer, ClickableEventTitle } from "./BaseEventRenderer";
|
||||
import { Label } from "@/components/ui/Label";
|
||||
|
||||
129
src/hooks/useLiveTimeline.ts
Normal file
129
src/hooks/useLiveTimeline.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user