From 97c89142aecc713c8f61e0361c4410f32ff0ff77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Wed, 17 Dec 2025 11:44:12 +0100 Subject: [PATCH] wip: live video events --- CLAUDE.md | 3 + package-lock.json | 18 ++ package.json | 2 + src/components/ConnViewer.tsx | 19 +- src/components/DecodeViewer.tsx | 15 +- src/components/DynamicWindowTitle.tsx | 22 +- src/components/EventErrorBoundary.tsx | 24 +- src/components/ProfileViewer.tsx | 17 +- src/components/ReqViewer.tsx | 72 +++-- src/components/WindowRenderer.tsx | 5 +- src/components/WindowTitle.tsx | 4 +- src/components/live/StatusBadge.tsx | 58 ++++ src/components/live/VideoPlayer.tsx | 175 ++++++++++ src/components/nostr/RelayLink.tsx | 7 +- .../nostr/kinds/ArticleRenderer.tsx | 1 - .../nostr/kinds/BaseEventRenderer.tsx | 35 +- .../nostr/kinds/BookmarkRenderer.tsx | 1 - .../nostr/kinds/CodeSnippetRenderer.tsx | 1 - .../nostr/kinds/CommunityNIPRenderer.tsx | 1 - .../nostr/kinds/HighlightDetailRenderer.tsx | 5 +- .../nostr/kinds/HighlightRenderer.tsx | 5 +- src/components/nostr/kinds/IssueRenderer.tsx | 1 - .../kinds/LiveActivityDetailRenderer.tsx | 179 +++++++++++ .../nostr/kinds/LiveActivityRenderer.tsx | 138 ++++++++ src/components/nostr/kinds/PatchRenderer.tsx | 1 - .../nostr/kinds/PictureRenderer.tsx | 5 +- .../nostr/kinds/PullRequestRenderer.tsx | 1 - .../nostr/kinds/ZapReceiptRenderer.tsx | 16 +- src/components/nostr/kinds/index.tsx | 11 +- src/core/logic.ts | 12 +- src/core/state.ts | 5 +- src/hooks/useOutboxRelays.ts | 22 +- src/lib/command-parser.test.ts | 300 +++++++++--------- src/lib/command-parser.ts | 3 +- src/lib/global-flags.test.ts | 300 ++++++++++++------ src/lib/global-flags.ts | 12 +- src/lib/imeta.ts | 4 +- src/lib/live-activity.ts | 142 +++++++++ src/lib/req-parser.test.ts | 16 +- src/lib/req-parser.ts | 4 +- src/services/loaders.test.ts | 38 ++- src/services/loaders.ts | 19 +- src/services/relay-list-cache.ts | 12 +- src/services/relay-selection.ts | 102 ++++-- src/types/live-activity.ts | 50 +++ 45 files changed, 1450 insertions(+), 433 deletions(-) create mode 100644 src/components/live/StatusBadge.tsx create mode 100644 src/components/live/VideoPlayer.tsx create mode 100644 src/components/nostr/kinds/LiveActivityDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/LiveActivityRenderer.tsx create mode 100644 src/lib/live-activity.ts create mode 100644 src/types/live-activity.ts diff --git a/CLAUDE.md b/CLAUDE.md index 801421a..3088b21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,9 @@ Use hooks like `useProfile()`, `useNostrEvent()`, `useTimeline()` - they handle - Detail renderers: `DetailKindRenderer` component with `detailRenderers` registry - Registry pattern allows adding new kind renderers without modifying parent components - Falls back to `DefaultKindRenderer` or feed renderer for unregistered kinds +- **Naming Convention**: Use human-friendly names for renderers (e.g., `LiveActivityRenderer` instead of `Kind30311Renderer`) to make code understandable without memorizing kind numbers + - Feed renderer: `[Name]Renderer.tsx` (e.g., `LiveActivityRenderer.tsx`) + - Detail renderer: `[Name]DetailRenderer.tsx` (e.g., `LiveActivityDetailRenderer.tsx`) **Mosaic Layout**: - Layout mutations via `updateLayout()` callback only diff --git a/package-lock.json b/package-lock.json index 53ba0d7..77252b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,8 +28,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "dexie": "^4.2.1", "dexie-react-hooks": "^4.2.0", + "hls.js": "^1.6.15", "jotai": "^2.15.2", "lucide-react": "latest", "prismjs": "^1.30.0", @@ -5095,6 +5097,16 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5965,6 +5977,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", diff --git a/package.json b/package.json index 99de201..5969eb5 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "dexie": "^4.2.1", "dexie-react-hooks": "^4.2.0", + "hls.js": "^1.6.15", "jotai": "^2.15.2", "lucide-react": "latest", "prismjs": "^1.30.0", diff --git a/src/components/ConnViewer.tsx b/src/components/ConnViewer.tsx index 111fcd2..15795d6 100644 --- a/src/components/ConnViewer.tsx +++ b/src/components/ConnViewer.tsx @@ -292,12 +292,21 @@ function LivenessStatsRow({ url }: LivenessStatsRowProps) { // Format liveness state icon and label const livenessIcon = () => { if (!livenessState) { - return { icon: , label: "Unknown" }; + return { + icon: , + label: "Unknown", + }; } const iconMap = { - online: { icon: , label: "Online" }, - offline: { icon: , label: "Offline" }, + online: { + icon: , + label: "Online", + }, + offline: { + icon: , + label: "Offline", + }, dead: { icon: , label: "Dead" }, }; return iconMap[livenessState.state]; @@ -312,7 +321,9 @@ function LivenessStatsRow({ url }: LivenessStatsRowProps) { return `${minutes}m`; }; - const backoffRemaining = livenessState ? liveness.getBackoffRemaining(url) : 0; + const backoffRemaining = livenessState + ? liveness.getBackoffRemaining(url) + : 0; const isInBackoff = backoffRemaining > 0; if (!livenessState) { diff --git a/src/components/DecodeViewer.tsx b/src/components/DecodeViewer.tsx index 0611547..9a15a3e 100644 --- a/src/components/DecodeViewer.tsx +++ b/src/components/DecodeViewer.tsx @@ -93,21 +93,12 @@ export default function DecodeViewer({ args }: DecodeViewerProps) { const openEvent = () => { if (!decoded) return; if (decoded.data.type === "note") { - addWindow( - "open", - { pointer: { id: decoded.data.data, relays } }, - ); + addWindow("open", { pointer: { id: decoded.data.data, relays } }); } else if (decoded.data.type === "nevent") { - addWindow( - "open", - { pointer: { id: decoded.data.data.id, relays } }, - ); + addWindow("open", { pointer: { id: decoded.data.data.id, relays } }); } else if (decoded.data.type === "naddr") { const { kind, pubkey, identifier } = decoded.data.data; - addWindow( - "open", - { pointer: { kind, pubkey, identifier, relays } }, - ); + addWindow("open", { pointer: { kind, pubkey, identifier, relays } }); } }; diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 5f4b4a5..2828634 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -59,7 +59,8 @@ function formatProfileNames( if (pubkey === "$me") { // Show account's name or "You" if (accountProfile) { - const name = accountProfile.display_name || accountProfile.name || "You"; + const name = + accountProfile.display_name || accountProfile.name || "You"; names.push(name); } else { names.push("You"); @@ -188,23 +189,17 @@ function generateRawCommand(appId: string, props: any): string { } if (props.filter.authors?.length) { // Keep original aliases in tooltip for clarity - const authorDisplay = props.filter.authors - .slice(0, 2) - .join(","); + const authorDisplay = props.filter.authors.slice(0, 2).join(","); parts.push(`-a ${authorDisplay}`); } if (props.filter["#p"]?.length) { // Keep original aliases in tooltip for clarity - const pTagDisplay = props.filter["#p"] - .slice(0, 2) - .join(","); + const pTagDisplay = props.filter["#p"].slice(0, 2).join(","); parts.push(`-p ${pTagDisplay}`); } if (props.filter["#P"]?.length) { // Keep original aliases in tooltip for clarity - const pTagUpperDisplay = props.filter["#P"] - .slice(0, 2) - .join(","); + const pTagUpperDisplay = props.filter["#P"].slice(0, 2).join(","); parts.push(`-P ${pTagUpperDisplay}`); } return parts.join(" "); @@ -243,12 +238,15 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { // Fetch contact list for $contacts display const contactListEvent = useNostrEvent( - accountPubkey ? { kind: 3, pubkey: accountPubkey, identifier: "" } : undefined, + accountPubkey + ? { kind: 3, pubkey: accountPubkey, identifier: "" } + : undefined, ); // Extract contacts count from kind 3 event const contactsCount = contactListEvent - ? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64).length + ? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64) + .length : 0; // Profile titles diff --git a/src/components/EventErrorBoundary.tsx b/src/components/EventErrorBoundary.tsx index b84ae4a..559e9bf 100644 --- a/src/components/EventErrorBoundary.tsx +++ b/src/components/EventErrorBoundary.tsx @@ -3,7 +3,11 @@ import { AlertTriangle, Bug, FileJson, RefreshCw } from "lucide-react"; import type { NostrEvent } from "@/types/nostr"; import { nip19 } from "nostr-tools"; import { Button } from "./ui/button"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "./ui/collapsible"; interface EventErrorBoundaryProps { children: ReactNode; @@ -36,13 +40,19 @@ export class EventErrorBoundary extends Component< }; } - static getDerivedStateFromError(_error: Error): Partial { + static getDerivedStateFromError( + _error: Error, + ): Partial { return { hasError: true }; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { // Log error to console for debugging - console.error("[EventErrorBoundary] Caught rendering error:", error, errorInfo); + console.error( + "[EventErrorBoundary] Caught rendering error:", + error, + errorInfo, + ); this.setState({ error, @@ -88,7 +98,8 @@ export class EventErrorBoundary extends Component< Rendering Error

- This event failed to render. The error has been logged to the console. + This event failed to render. The error has been logged to the + console.

@@ -98,7 +109,10 @@ export class EventErrorBoundary extends Component<
- + {eventId.slice(0, 16)}...
diff --git a/src/components/ProfileViewer.tsx b/src/components/ProfileViewer.tsx index 7a237c5..9ff4541 100644 --- a/src/components/ProfileViewer.tsx +++ b/src/components/ProfileViewer.tsx @@ -51,26 +51,35 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) { // Check if we have a valid cached relay list relayListCache.has(pubkey).then(async (hasCached) => { if (hasCached) { - console.debug(`[ProfileViewer] Using cached relay list for ${pubkey.slice(0, 8)}`); + console.debug( + `[ProfileViewer] Using cached relay list for ${pubkey.slice(0, 8)}`, + ); // Load cached event into EventStore so UI can display it const cached = await relayListCache.get(pubkey); if (cached?.event) { eventStore.add(cached.event); - console.debug(`[ProfileViewer] Loaded cached relay list into EventStore for ${pubkey.slice(0, 8)}`); + console.debug( + `[ProfileViewer] Loaded cached relay list into EventStore for ${pubkey.slice(0, 8)}`, + ); } return; } // No cached or stale - fetch fresh from network - console.debug(`[ProfileViewer] Fetching fresh relay list for ${pubkey.slice(0, 8)}`); + console.debug( + `[ProfileViewer] Fetching fresh relay list for ${pubkey.slice(0, 8)}`, + ); subscription = addressLoader({ kind: kinds.RelayList, pubkey, identifier: "", }).subscribe({ error: (err) => { - console.debug(`[ProfileViewer] Failed to fetch relay list for ${pubkey.slice(0, 8)}:`, err); + console.debug( + `[ProfileViewer] Failed to fetch relay list for ${pubkey.slice(0, 8)}:`, + err, + ); }, }); }); diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index 326f442..3fb9575 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -185,7 +185,14 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) { /* Accordion for complex queries */ {/* Kinds Section */} @@ -647,18 +654,20 @@ export default function ReqViewer({ // Extract contacts from kind 3 event (memoized to prevent unnecessary recalculation) const contacts = useMemo( - () => contactListEvent - ? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64) - : [], - [contactListEvent] + () => + contactListEvent + ? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64) + : [], + [contactListEvent], ); // Resolve $me and $contacts aliases (memoized to prevent unnecessary object creation) const resolvedFilter = useMemo( - () => needsAccount - ? resolveFilterAliases(filter, accountPubkey, contacts) - : filter, - [needsAccount, filter, accountPubkey, contacts] + () => + needsAccount + ? resolveFilterAliases(filter, accountPubkey, contacts) + : filter, + [needsAccount, filter, accountPubkey, contacts], ); // NIP-05 resolution already happened in argParser before window creation @@ -668,8 +677,9 @@ export default function ReqViewer({ // NIP-65 outbox relay selection // Memoize fallbackRelays to prevent re-creation on every render const fallbackRelays = useMemo( - () => state.activeAccount?.relays?.inbox.map((r) => r.url) || AGGREGATOR_RELAYS, - [state.activeAccount?.relays?.inbox] + () => + state.activeAccount?.relays?.inbox.map((r) => r.url) || AGGREGATOR_RELAYS, + [state.activeAccount?.relays?.inbox], ); // Memoize outbox options to prevent object re-creation @@ -679,7 +689,7 @@ export default function ReqViewer({ timeout: 1000, maxRelays: 42, }), - [fallbackRelays] + [fallbackRelays], ); // Select optimal relays based on authors (write relays) and #p tags (read relays) @@ -700,21 +710,21 @@ export default function ReqViewer({ // Wait for outbox relay selection to complete before subscribing // This prevents multiple reconnections during discovery/selection phases - if (relaySelectionPhase !== 'ready') { + if (relaySelectionPhase !== "ready") { return []; } return selectedRelays; }, [relays, relaySelectionPhase, selectedRelays]); - // Get relay state for each relay and calculate connected count const relayStatesForReq = useMemo( - () => finalRelays.map((url) => ({ - url, - state: relayStates[url], - })), - [finalRelays, relayStates] + () => + finalRelays.map((url) => ({ + url, + state: relayStates[url], + })), + [finalRelays, relayStates], ); const connectedCount = relayStatesForReq.filter( (r) => r.state?.connectionState === "connected", @@ -854,7 +864,7 @@ export default function ReqViewer({
- {relaySelectionPhase === 'discovering' + {relaySelectionPhase === "discovering" ? "DISCOVERING RELAYS" - : relaySelectionPhase === 'selecting' + : relaySelectionPhase === "selecting" ? "SELECTING RELAYS" : loading && eoseReceived && stream ? "LIVE" @@ -931,7 +941,10 @@ export default function ReqViewer({ - + {/* Connection Status */}
@@ -1082,9 +1095,10 @@ export default function ReqViewer({

Account Required

- This query uses $me{" "} - or $contacts{" "} - aliases and requires an active account. + This query uses{" "} + $me or{" "} + $contacts aliases + and requires an active account.

@@ -1119,7 +1133,9 @@ export default function ReqViewer({ style={{ height: "100%" }} data={events} computeItemKey={(_index, item) => item.id} - itemContent={(_index, event) => } + itemContent={(_index, event) => ( + + )} /> )}
diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 5b6e3ae..91ab763 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -192,7 +192,10 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { } return ( - + }>
{content}
diff --git a/src/components/WindowTitle.tsx b/src/components/WindowTitle.tsx index c981387..94279c9 100644 --- a/src/components/WindowTitle.tsx +++ b/src/components/WindowTitle.tsx @@ -26,7 +26,9 @@ export function WindowTile({ // Convert title to string for MosaicWindow (which only accepts strings) // The actual title (with React elements) is rendered in the custom toolbar const titleString = - typeof title === "string" ? title : tooltip || window.title || window.appId.toUpperCase(); + typeof title === "string" + ? title + : tooltip || window.title || window.appId.toUpperCase(); // Custom toolbar renderer to include icon const renderToolbar = () => { diff --git a/src/components/live/StatusBadge.tsx b/src/components/live/StatusBadge.tsx new file mode 100644 index 0000000..835c01f --- /dev/null +++ b/src/components/live/StatusBadge.tsx @@ -0,0 +1,58 @@ +import { Circle, Calendar, Video } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { LiveStatus } from "@/types/live-activity"; + +interface StatusBadgeProps { + status: LiveStatus; + size?: "sm" | "md"; + hideLabel?: boolean; +} + +export function StatusBadge({ + status, + size = "sm", + hideLabel = false, +}: StatusBadgeProps) { + const config = { + live: { + label: "LIVE", + className: "bg-red-600 text-white", + icon: Circle, + }, + planned: { + label: "UPCOMING", + className: "bg-blue-600 text-white", + icon: Calendar, + }, + ended: { + label: "ENDED", + className: "bg-neutral-600 text-white", + icon: Video, + }, + }[status]; + + const Icon = config.icon; + + const sizeClasses = { + sm: "px-2 py-1 text-xs gap-1", + md: "px-3 py-1.5 text-sm gap-2", + }; + + const iconSizeClasses = { + sm: "w-3 h-3", + md: "w-4 h-4", + }; + + return ( +
+ + {hideLabel ? null : {config.label}} +
+ ); +} diff --git a/src/components/live/VideoPlayer.tsx b/src/components/live/VideoPlayer.tsx new file mode 100644 index 0000000..a6e2037 --- /dev/null +++ b/src/components/live/VideoPlayer.tsx @@ -0,0 +1,175 @@ +import { useEffect, useRef, useState } from "react"; +import Hls from "hls.js"; +import { ExternalLink } from "lucide-react"; + +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]); + + return ( +
+
+ ); +} diff --git a/src/components/nostr/RelayLink.tsx b/src/components/nostr/RelayLink.tsx index c0669b5..e3df092 100644 --- a/src/components/nostr/RelayLink.tsx +++ b/src/components/nostr/RelayLink.tsx @@ -110,12 +110,7 @@ export function RelayLink({ )} - + {displayUrl} diff --git a/src/components/nostr/kinds/ArticleRenderer.tsx b/src/components/nostr/kinds/ArticleRenderer.tsx index ecf0d2a..7467b8c 100644 --- a/src/components/nostr/kinds/ArticleRenderer.tsx +++ b/src/components/nostr/kinds/ArticleRenderer.tsx @@ -24,7 +24,6 @@ export function Kind30023Renderer({ event }: BaseEventProps) { {title && ( {title} diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 114b994..5a69ace 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -32,17 +32,30 @@ const PARAMETERIZED_REPLACEABLE_END = 40000; export interface BaseEventProps { event: NostrEvent; depth?: number; + /** + * Override the displayed author pubkey when the semantic "author" differs from event.pubkey + * Examples: + * - Zaps (kind 9735): Show the zapper, not the lightning service pubkey + * - Live events (kind 30311): Show the host, not the event publisher + * - Delegated events: Show the delegator, not the delegate + */ + authorOverride?: { + pubkey: string; + label?: string; // e.g., "Host", "Sender", "Zapper", "From" + }; } /** * User component - displays author info with profile */ -export function EventAuthor({ pubkey }: { pubkey: string }) { - return ( -
- -
- ); +export function EventAuthor({ + pubkey, + label, +}: { + pubkey: string; + label?: string; +}) { + return ; } /** @@ -230,9 +243,14 @@ export function ClickableEventTitle({ export function BaseEventContainer({ event, children, + authorOverride, }: { event: NostrEvent; children: React.ReactNode; + authorOverride?: { + pubkey: string; + label?: string; + }; }) { // Format relative time for display const { locale } = useGrimoire(); @@ -249,11 +267,14 @@ export function BaseEventContainer({ locale.locale, ); + // Use author override if provided, otherwise use event author + const displayPubkey = authorOverride?.pubkey || event.pubkey; + return (
- + {title} diff --git a/src/components/nostr/kinds/CodeSnippetRenderer.tsx b/src/components/nostr/kinds/CodeSnippetRenderer.tsx index 2926ed2..cee34d7 100644 --- a/src/components/nostr/kinds/CodeSnippetRenderer.tsx +++ b/src/components/nostr/kinds/CodeSnippetRenderer.tsx @@ -85,7 +85,6 @@ export function Kind1337Renderer({ event }: BaseEventProps) { {/* Title */} {name || "Code Snippet"} diff --git a/src/components/nostr/kinds/CommunityNIPRenderer.tsx b/src/components/nostr/kinds/CommunityNIPRenderer.tsx index 623e5a4..4caf252 100644 --- a/src/components/nostr/kinds/CommunityNIPRenderer.tsx +++ b/src/components/nostr/kinds/CommunityNIPRenderer.tsx @@ -17,7 +17,6 @@ export function CommunityNIPRenderer({ event }: BaseEventProps) {
{title} diff --git a/src/components/nostr/kinds/HighlightDetailRenderer.tsx b/src/components/nostr/kinds/HighlightDetailRenderer.tsx index 9c11aa9..e9b7379 100644 --- a/src/components/nostr/kinds/HighlightDetailRenderer.tsx +++ b/src/components/nostr/kinds/HighlightDetailRenderer.tsx @@ -119,10 +119,7 @@ export function Kind9802DetailRenderer({ event }: { event: NostrEvent }) { addressPointer={addressPointer} onOpen={(pointer) => { if (typeof pointer === "string") { - addWindow( - "open", - { id: pointer }, - ); + addWindow("open", { id: pointer }); } else { addWindow("open", pointer); } diff --git a/src/components/nostr/kinds/HighlightRenderer.tsx b/src/components/nostr/kinds/HighlightRenderer.tsx index eb7c853..2adaf6e 100644 --- a/src/components/nostr/kinds/HighlightRenderer.tsx +++ b/src/components/nostr/kinds/HighlightRenderer.tsx @@ -52,10 +52,7 @@ export function Kind9802Renderer({ event }: BaseEventProps) { // Handle click to open source event const handleOpenEvent = () => { if (eventPointer?.id) { - addWindow( - "open", - { pointer: eventPointer }, - ); + addWindow("open", { pointer: eventPointer }); } else if (addressPointer) { addWindow("open", { pointer: addressPointer }); } diff --git a/src/components/nostr/kinds/IssueRenderer.tsx b/src/components/nostr/kinds/IssueRenderer.tsx index 8fd3de4..a16ed27 100644 --- a/src/components/nostr/kinds/IssueRenderer.tsx +++ b/src/components/nostr/kinds/IssueRenderer.tsx @@ -73,7 +73,6 @@ export function IssueRenderer({ event }: BaseEventProps) { {/* Issue Title */} {title || "Untitled Issue"} diff --git a/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx b/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx new file mode 100644 index 0000000..08ff089 --- /dev/null +++ b/src/components/nostr/kinds/LiveActivityDetailRenderer.tsx @@ -0,0 +1,179 @@ +import { useMemo } from "react"; +import type { NostrEvent } from "@/types/nostr"; +import { + parseLiveActivity, + getLiveStatus, + getLiveHost, +} from "@/lib/live-activity"; +import { VideoPlayer } from "@/components/live/VideoPlayer"; +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"; + +interface LiveActivityDetailRendererProps { + event: NostrEvent; +} + +export function LiveActivityDetailRenderer({ + event, +}: LiveActivityDetailRendererProps) { + const activity = useMemo(() => parseLiveActivity(event), [event]); + const status = useMemo(() => getLiveStatus(event), [event]); + const hostPubkey = useMemo(() => getLiveHost(event), [event]); + + return ( +
+ {/* Video/Media Section */} +
+ {status === "live" && activity.streaming ? ( + + ) : status === "ended" && activity.recording ? ( + + ) : activity.image ? ( +
+ {activity.title} + {status === "planned" && ( +
+
+ +

Event Not Started

+ {activity.starts && ( +

+ Starts {new Date(activity.starts * 1000).toLocaleString()} +

+ )} +
+
+ )} +
+ ) : ( +
+
+ +

No stream available

+
+
+ )} +
+ + {/* Content Section */} +
+
+ {/* Header */} +
+
+
+ +

+ {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} +
+ ))} +
+
+ )} +
+
+
+ ); +} + +// 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 new file mode 100644 index 0000000..c2206b5 --- /dev/null +++ b/src/components/nostr/kinds/LiveActivityRenderer.tsx @@ -0,0 +1,138 @@ +import { useMemo, useState } from "react"; +import type { NostrEvent } from "@/types/nostr"; +import { + parseLiveActivity, + getLiveStatus, + getLiveHost, + formatStartTime, +} from "@/lib/live-activity"; +import { BaseEventContainer, ClickableEventTitle } from "./BaseEventRenderer"; +import { Label } from "@/components/ui/Label"; +import { VideoPlayer } from "@/components/live/VideoPlayer"; +import { StatusBadge } from "@/components/live/StatusBadge"; +import { Users, Play, Circle, Calendar, Video } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface LiveActivityRendererProps { + event: NostrEvent; +} + +export function LiveActivityRenderer({ event }: LiveActivityRendererProps) { + const activity = useMemo(() => parseLiveActivity(event), [event]); + const status = useMemo(() => getLiveStatus(event), [event]); + const hostPubkey = useMemo(() => getLiveHost(event), [event]); + const [showVideo, setShowVideo] = useState(false); + + const hasVideo = status === "live" && activity.streaming; + const hasRecording = status === "ended" && activity.recording; + + return ( + +
+ {/* Media Section - Image or Video */} +
+ {/* Show video if live and user clicked to load */} + {hasVideo && showVideo ? ( + + ) : hasRecording && showVideo ? ( + + ) : ( + // Show image or placeholder +
{ + if (hasVideo || hasRecording) setShowVideo(true); + }} + > + {activity.image ? ( + <> + {activity.title} + {/* Play button overlay for video */} + {(hasVideo || hasRecording) && ( +
+
+ +
+
+ )} + + ) : ( +
+ +
+ )} +
+ )} + + {/* Status Badge Overlay */} +
+ +
+ + {/* Participant Count (if live and available) */} + {status === "live" && + activity.currentParticipants !== undefined && + activity.currentParticipants > 0 && ( +
+ + {activity.currentParticipants.toLocaleString()} +
+ )} +
+ +
+ {/* Title */} + + {activity.title || "Untitled Live Activity"} + + + {/* Summary */} + {activity.summary && ( +

+ {activity.summary} +

+ )} +
+ + {/* Metadata */} +
+ {/* Hashtags */} + {activity.hashtags + .filter((t) => !t.startsWith("internal:")) + .map((tag) => ( + + ))} +
+
+
+ ); +} + +function StatusIcon({ status }: { status: "live" | "planned" | "ended" }) { + const config = { + live: { icon: Circle, className: "text-red-600" }, + planned: { icon: Calendar, className: "text-blue-600" }, + ended: { icon: Video, className: "text-neutral-600" }, + }[status]; + + const Icon = config.icon; + + return ; +} diff --git a/src/components/nostr/kinds/PatchRenderer.tsx b/src/components/nostr/kinds/PatchRenderer.tsx index 8f5a250..b5d955a 100644 --- a/src/components/nostr/kinds/PatchRenderer.tsx +++ b/src/components/nostr/kinds/PatchRenderer.tsx @@ -75,7 +75,6 @@ export function PatchRenderer({ event }: BaseEventProps) { {/* Patch Subject */} {subject || "Untitled Patch"} diff --git a/src/components/nostr/kinds/PictureRenderer.tsx b/src/components/nostr/kinds/PictureRenderer.tsx index 9861f0a..78855e9 100644 --- a/src/components/nostr/kinds/PictureRenderer.tsx +++ b/src/components/nostr/kinds/PictureRenderer.tsx @@ -1,10 +1,7 @@ import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; import { MediaEmbed } from "../MediaEmbed"; import { RichText } from "../RichText"; -import { - parseImetaTags, - getAspectRatioFromDimensions, -} from "@/lib/imeta"; +import { parseImetaTags, getAspectRatioFromDimensions } from "@/lib/imeta"; /** * Renderer for Kind 20 - Picture Event (NIP-68) diff --git a/src/components/nostr/kinds/PullRequestRenderer.tsx b/src/components/nostr/kinds/PullRequestRenderer.tsx index f6e92ff..c179d3f 100644 --- a/src/components/nostr/kinds/PullRequestRenderer.tsx +++ b/src/components/nostr/kinds/PullRequestRenderer.tsx @@ -75,7 +75,6 @@ export function PullRequestRenderer({ event }: BaseEventProps) { {/* PR Title */} {subject || "Untitled Pull Request"} diff --git a/src/components/nostr/kinds/ZapReceiptRenderer.tsx b/src/components/nostr/kinds/ZapReceiptRenderer.tsx index 0c15fec..6487e8a 100644 --- a/src/components/nostr/kinds/ZapReceiptRenderer.tsx +++ b/src/components/nostr/kinds/ZapReceiptRenderer.tsx @@ -48,15 +48,6 @@ export function Kind9735Renderer({ event }: BaseEventProps) { return Math.floor(zapAmount / 1000); }, [zapAmount]); - // Override event.pubkey to show zap sender instead of receipt pubkey - const displayEvent = useMemo( - () => ({ - ...event, - pubkey: zapSender || event.pubkey, - }), - [event, zapSender], - ); - if (!isValid) { return ( @@ -66,7 +57,12 @@ export function Kind9735Renderer({ event }: BaseEventProps) { } return ( - +
{/* Zap indicator */}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index d8c65ed..a08ffe4 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -32,6 +32,8 @@ import { RepositoryRenderer } from "./RepositoryRenderer"; import { RepositoryDetailRenderer } from "./RepositoryDetailRenderer"; import { Kind39701Renderer } from "./BookmarkRenderer"; import { GenericRelayListRenderer } from "./GenericRelayListRenderer"; +import { LiveActivityRenderer } from "./LiveActivityRenderer"; +import { LiveActivityDetailRenderer } from "./LiveActivityDetailRenderer"; import { NostrEvent } from "@/types/nostr"; import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; @@ -67,8 +69,9 @@ const kindRenderers: Record> = { 10050: GenericRelayListRenderer, // DM Relay List (NIP-51) 30002: GenericRelayListRenderer, // Relay Sets (NIP-51) 30023: Kind30023Renderer, // Long-form Article - 30817: CommunityNIPRenderer, // Community NIP + 30311: LiveActivityRenderer, // Live Streaming Event (NIP-53) 30617: RepositoryRenderer, // Repository (NIP-34) + 30817: CommunityNIPRenderer, // Community NIP 39701: Kind39701Renderer, // Web Bookmarks (NIP-B0) }; @@ -107,7 +110,10 @@ export function KindRenderer({ * Registry of kind-specific detail renderers (for detail views) * Maps event kinds to their detailed renderer components */ -const detailRenderers: Record> = { +const detailRenderers: Record< + number, + React.ComponentType<{ event: NostrEvent }> +> = { 0: Kind0DetailRenderer, // Profile Metadata Detail 3: Kind3DetailView, // Contact List Detail 1337: Kind1337DetailRenderer, // Code Snippet Detail (NIP-C0) @@ -117,6 +123,7 @@ const detailRenderers: Record 9802: Kind9802DetailRenderer, // Highlight Detail 10002: Kind10002DetailRenderer, // Relay List Detail (NIP-65) 30023: Kind30023DetailRenderer, // Long-form Article Detail + 30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53) 30617: RepositoryDetailRenderer, // Repository Detail (NIP-34) 30817: CommunityNIPDetailRenderer, // Community NIP Detail }; diff --git a/src/core/logic.ts b/src/core/logic.ts index 3076f64..d4f6a8a 100644 --- a/src/core/logic.ts +++ b/src/core/logic.ts @@ -56,7 +56,12 @@ export const createWorkspace = ( */ export const addWindow = ( state: GrimoireState, - payload: { appId: string; props: any; commandString?: string; customTitle?: string }, + payload: { + appId: string; + props: any; + commandString?: string; + customTitle?: string; + }, ): GrimoireState => { const activeId = state.activeWorkspaceId; const ws = state.workspaces[activeId]; @@ -329,7 +334,10 @@ export const updateWindow = ( state: GrimoireState, windowId: string, updates: Partial< - Pick + Pick< + WindowInstance, + "props" | "title" | "customTitle" | "commandString" | "appId" + > >, ): GrimoireState => { const window = state.windows[windowId]; diff --git a/src/core/state.ts b/src/core/state.ts index 6e09175..0895304 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -148,7 +148,10 @@ export const useGrimoire = () => { ( windowId: string, updates: Partial< - Pick + Pick< + WindowInstance, + "props" | "title" | "customTitle" | "commandString" | "appId" + > >, ) => setState((prev) => Logic.updateWindow(prev, windowId, updates)), [setState], diff --git a/src/hooks/useOutboxRelays.ts b/src/hooks/useOutboxRelays.ts index aaa5331..d5eaa85 100644 --- a/src/hooks/useOutboxRelays.ts +++ b/src/hooks/useOutboxRelays.ts @@ -33,7 +33,7 @@ import type { * const { events } = useReqTimeline("timeline-id", filter, relays); * ``` */ -export type RelaySelectionPhase = 'discovering' | 'selecting' | 'ready'; +export type RelaySelectionPhase = "discovering" | "selecting" | "ready"; export function useOutboxRelays( filter: NostrFilter, @@ -46,7 +46,7 @@ export function useOutboxRelays( isOptimized: false, }); const [loading, setLoading] = useState(true); - const [phase, setPhase] = useState('discovering'); + const [phase, setPhase] = useState("discovering"); // Stable reference for filter.authors and filter["#p"] // Only re-run when these change @@ -75,7 +75,7 @@ export function useOutboxRelays( async function selectRelays() { setLoading(true); - setPhase('discovering'); + setPhase("discovering"); try { // Reconstruct options inside effect to avoid dependency on object reference @@ -86,7 +86,7 @@ export function useOutboxRelays( timeout, }; - setPhase('selecting'); + setPhase("selecting"); const selection = await selectRelaysForFilter( eventStore, filter, @@ -95,13 +95,13 @@ export function useOutboxRelays( if (!cancelled) { setResult(selection); - setPhase('ready'); + setPhase("ready"); } } catch (err) { console.error("[useOutboxRelays] Failed to select relays:", err); // Keep previous result on error if (!cancelled) { - setPhase('ready'); + setPhase("ready"); } } finally { if (!cancelled) { @@ -115,7 +115,15 @@ export function useOutboxRelays( return () => { cancelled = true; }; - }, [eventStore, authorsKey, pTagsKey, fallbackRelaysKey, maxRelays, maxRelaysPerUser, timeout]); + }, [ + eventStore, + authorsKey, + pTagsKey, + fallbackRelaysKey, + maxRelays, + maxRelaysPerUser, + timeout, + ]); return { ...result, diff --git a/src/lib/command-parser.test.ts b/src/lib/command-parser.test.ts index 7ec3a66..2496f6e 100644 --- a/src/lib/command-parser.test.ts +++ b/src/lib/command-parser.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { parseCommandInput } from './command-parser'; +import { describe, it, expect } from "vitest"; +import { parseCommandInput } from "./command-parser"; /** * Regression tests for parseCommandInput @@ -7,238 +7,250 @@ import { parseCommandInput } from './command-parser'; * These tests document the current behavior to ensure we don't break * existing command parsing when we add global flag support. */ -describe('parseCommandInput - regression tests', () => { - describe('basic commands', () => { - it('should parse simple command with no args', () => { - const result = parseCommandInput('help'); - expect(result.commandName).toBe('help'); +describe("parseCommandInput - regression tests", () => { + describe("basic commands", () => { + it("should parse simple command with no args", () => { + const result = parseCommandInput("help"); + expect(result.commandName).toBe("help"); expect(result.args).toEqual([]); expect(result.command).toBeDefined(); }); - it('should parse command with single arg', () => { - const result = parseCommandInput('nip 01'); - expect(result.commandName).toBe('nip'); - expect(result.args).toEqual(['01']); + it("should parse command with single arg", () => { + const result = parseCommandInput("nip 01"); + expect(result.commandName).toBe("nip"); + expect(result.args).toEqual(["01"]); }); - it('should parse command with multiple args', () => { - const result = parseCommandInput('profile alice@domain.com'); - expect(result.commandName).toBe('profile'); - expect(result.args).toEqual(['alice@domain.com']); + it("should parse command with multiple args", () => { + const result = parseCommandInput("profile alice@domain.com"); + expect(result.commandName).toBe("profile"); + expect(result.args).toEqual(["alice@domain.com"]); }); }); - describe('commands with flags', () => { - it('should preserve req command with flags', () => { - const result = parseCommandInput('req -k 1 -a alice'); - expect(result.commandName).toBe('req'); - expect(result.args).toEqual(['-k', '1', '-a', 'alice']); + describe("commands with flags", () => { + it("should preserve req command with flags", () => { + const result = parseCommandInput("req -k 1 -a alice"); + expect(result.commandName).toBe("req"); + expect(result.args).toEqual(["-k", "1", "-a", "alice"]); }); - it('should preserve comma-separated values', () => { - const result = parseCommandInput('req -k 1,3,7 -l 50'); - expect(result.commandName).toBe('req'); - expect(result.args).toEqual(['-k', '1,3,7', '-l', '50']); + it("should preserve comma-separated values", () => { + const result = parseCommandInput("req -k 1,3,7 -l 50"); + expect(result.commandName).toBe("req"); + expect(result.args).toEqual(["-k", "1,3,7", "-l", "50"]); }); - it('should handle long flag names', () => { - const result = parseCommandInput('req --kind 1 --limit 20'); - expect(result.commandName).toBe('req'); - expect(result.args).toEqual(['--kind', '1', '--limit', '20']); + it("should handle long flag names", () => { + const result = parseCommandInput("req --kind 1 --limit 20"); + expect(result.commandName).toBe("req"); + expect(result.args).toEqual(["--kind", "1", "--limit", "20"]); }); - it('should handle mixed short and long flags', () => { - const result = parseCommandInput('req -k 1 --author alice -l 50'); - expect(result.commandName).toBe('req'); - expect(result.args).toEqual(['-k', '1', '--author', 'alice', '-l', '50']); + it("should handle mixed short and long flags", () => { + const result = parseCommandInput("req -k 1 --author alice -l 50"); + expect(result.commandName).toBe("req"); + expect(result.args).toEqual(["-k", "1", "--author", "alice", "-l", "50"]); }); }); - describe('commands with complex identifiers', () => { - it('should handle hex pubkey', () => { - const hexKey = '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2'; + describe("commands with complex identifiers", () => { + it("should handle hex pubkey", () => { + const hexKey = + "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"; const result = parseCommandInput(`profile ${hexKey}`); - expect(result.commandName).toBe('profile'); + expect(result.commandName).toBe("profile"); expect(result.args).toEqual([hexKey]); }); - it('should handle npub', () => { - const npub = 'npub1abc123def456'; + it("should handle npub", () => { + const npub = "npub1abc123def456"; const result = parseCommandInput(`profile ${npub}`); - expect(result.commandName).toBe('profile'); + expect(result.commandName).toBe("profile"); expect(result.args).toEqual([npub]); }); - it('should handle nip05 identifier', () => { - const result = parseCommandInput('profile alice@nostr.com'); - expect(result.commandName).toBe('profile'); - expect(result.args).toEqual(['alice@nostr.com']); + it("should handle nip05 identifier", () => { + const result = parseCommandInput("profile alice@nostr.com"); + expect(result.commandName).toBe("profile"); + expect(result.args).toEqual(["alice@nostr.com"]); }); - it('should handle relay URL', () => { - const result = parseCommandInput('relay wss://relay.damus.io'); - expect(result.commandName).toBe('relay'); - expect(result.args).toEqual(['wss://relay.damus.io']); + it("should handle relay URL", () => { + const result = parseCommandInput("relay wss://relay.damus.io"); + expect(result.commandName).toBe("relay"); + expect(result.args).toEqual(["wss://relay.damus.io"]); }); }); - describe('special arguments', () => { - it('should handle $me alias', () => { - const result = parseCommandInput('req -k 1 -a $me'); - expect(result.commandName).toBe('req'); - expect(result.args).toEqual(['-k', '1', '-a', '$me']); + describe("special arguments", () => { + it("should handle $me alias", () => { + const result = parseCommandInput("req -k 1 -a $me"); + expect(result.commandName).toBe("req"); + expect(result.args).toEqual(["-k", "1", "-a", "$me"]); }); - it('should handle $contacts alias', () => { - const result = parseCommandInput('req -k 1 -a $contacts'); - expect(result.commandName).toBe('req'); - expect(result.args).toEqual(['-k', '1', '-a', '$contacts']); + it("should handle $contacts alias", () => { + const result = parseCommandInput("req -k 1 -a $contacts"); + expect(result.commandName).toBe("req"); + expect(result.args).toEqual(["-k", "1", "-a", "$contacts"]); }); }); - describe('whitespace handling', () => { - it('should trim leading whitespace', () => { - const result = parseCommandInput(' profile alice'); - expect(result.commandName).toBe('profile'); - expect(result.args).toEqual(['alice']); + describe("whitespace handling", () => { + it("should trim leading whitespace", () => { + const result = parseCommandInput(" profile alice"); + expect(result.commandName).toBe("profile"); + expect(result.args).toEqual(["alice"]); }); - it('should trim trailing whitespace', () => { - const result = parseCommandInput('profile alice '); - expect(result.commandName).toBe('profile'); - expect(result.args).toEqual(['alice']); + it("should trim trailing whitespace", () => { + const result = parseCommandInput("profile alice "); + expect(result.commandName).toBe("profile"); + expect(result.args).toEqual(["alice"]); }); - it('should collapse multiple spaces', () => { - const result = parseCommandInput('req -k 1 -a alice'); - expect(result.commandName).toBe('req'); - expect(result.args).toEqual(['-k', '1', '-a', 'alice']); + it("should collapse multiple spaces", () => { + const result = parseCommandInput("req -k 1 -a alice"); + expect(result.commandName).toBe("req"); + expect(result.args).toEqual(["-k", "1", "-a", "alice"]); }); }); - describe('error cases', () => { - it('should handle empty input', () => { - const result = parseCommandInput(''); - expect(result.commandName).toBe(''); - expect(result.error).toBe('No command provided'); + describe("error cases", () => { + it("should handle empty input", () => { + const result = parseCommandInput(""); + expect(result.commandName).toBe(""); + expect(result.error).toBe("No command provided"); }); - it('should handle unknown command', () => { - const result = parseCommandInput('unknowncommand'); - expect(result.commandName).toBe('unknowncommand'); - expect(result.error).toContain('Unknown command'); + it("should handle unknown command", () => { + const result = parseCommandInput("unknowncommand"); + expect(result.commandName).toBe("unknowncommand"); + expect(result.error).toContain("Unknown command"); }); }); - describe('case sensitivity', () => { - it('should handle lowercase command', () => { - const result = parseCommandInput('profile alice'); - expect(result.commandName).toBe('profile'); + describe("case sensitivity", () => { + it("should handle lowercase command", () => { + const result = parseCommandInput("profile alice"); + expect(result.commandName).toBe("profile"); }); - it('should handle uppercase command (converted to lowercase)', () => { - const result = parseCommandInput('PROFILE alice'); - expect(result.commandName).toBe('profile'); + it("should handle uppercase command (converted to lowercase)", () => { + const result = parseCommandInput("PROFILE alice"); + expect(result.commandName).toBe("profile"); }); - it('should handle mixed case command', () => { - const result = parseCommandInput('Profile alice'); - expect(result.commandName).toBe('profile'); + it("should handle mixed case command", () => { + const result = parseCommandInput("Profile alice"); + expect(result.commandName).toBe("profile"); }); }); - describe('real-world command examples', () => { - it('req: get recent notes', () => { - const result = parseCommandInput('req -k 1 -l 20'); - expect(result.commandName).toBe('req'); - expect(result.args).toEqual(['-k', '1', '-l', '20']); + describe("real-world command examples", () => { + it("req: get recent notes", () => { + const result = parseCommandInput("req -k 1 -l 20"); + expect(result.commandName).toBe("req"); + expect(result.args).toEqual(["-k", "1", "-l", "20"]); }); - it('req: get notes from specific author', () => { - const result = parseCommandInput('req -k 1 -a npub1abc... -l 50'); - expect(result.commandName).toBe('req'); - expect(result.args).toEqual(['-k', '1', '-a', 'npub1abc...', '-l', '50']); + it("req: get notes from specific author", () => { + const result = parseCommandInput("req -k 1 -a npub1abc... -l 50"); + expect(result.commandName).toBe("req"); + expect(result.args).toEqual(["-k", "1", "-a", "npub1abc...", "-l", "50"]); }); - it('req: complex filter', () => { - const result = parseCommandInput('req -k 1,3,7 -a alice@nostr.com -l 100 --since 24h'); - expect(result.commandName).toBe('req'); - expect(result.args).toEqual(['-k', '1,3,7', '-a', 'alice@nostr.com', '-l', '100', '--since', '24h']); + it("req: complex filter", () => { + const result = parseCommandInput( + "req -k 1,3,7 -a alice@nostr.com -l 100 --since 24h", + ); + expect(result.commandName).toBe("req"); + expect(result.args).toEqual([ + "-k", + "1,3,7", + "-a", + "alice@nostr.com", + "-l", + "100", + "--since", + "24h", + ]); }); - it('profile: by npub', () => { - const result = parseCommandInput('profile npub1abc...'); - expect(result.commandName).toBe('profile'); - expect(result.args).toEqual(['npub1abc...']); + it("profile: by npub", () => { + const result = parseCommandInput("profile npub1abc..."); + expect(result.commandName).toBe("profile"); + expect(result.args).toEqual(["npub1abc..."]); }); - it('profile: by nip05', () => { - const result = parseCommandInput('profile jack@cash.app'); - expect(result.commandName).toBe('profile'); - expect(result.args).toEqual(['jack@cash.app']); + it("profile: by nip05", () => { + const result = parseCommandInput("profile jack@cash.app"); + expect(result.commandName).toBe("profile"); + expect(result.args).toEqual(["jack@cash.app"]); }); - it('nip: view specification', () => { - const result = parseCommandInput('nip 19'); - expect(result.commandName).toBe('nip'); - expect(result.args).toEqual(['19']); + it("nip: view specification", () => { + const result = parseCommandInput("nip 19"); + expect(result.commandName).toBe("nip"); + expect(result.args).toEqual(["19"]); }); - it('relay: view relay info', () => { - const result = parseCommandInput('relay nos.lol'); - expect(result.commandName).toBe('relay'); - expect(result.args).toEqual(['nos.lol']); + it("relay: view relay info", () => { + const result = parseCommandInput("relay nos.lol"); + expect(result.commandName).toBe("relay"); + expect(result.args).toEqual(["nos.lol"]); }); }); - describe('global flags - new functionality', () => { - it('should extract --title flag', () => { + describe("global flags - new functionality", () => { + it("should extract --title flag", () => { const result = parseCommandInput('profile alice --title "My Window"'); - expect(result.commandName).toBe('profile'); - expect(result.args).toEqual(['alice']); - expect(result.globalFlags?.windowProps?.title).toBe('My Window'); + expect(result.commandName).toBe("profile"); + expect(result.args).toEqual(["alice"]); + expect(result.globalFlags?.windowProps?.title).toBe("My Window"); }); - it('should handle --title at start', () => { + it("should handle --title at start", () => { const result = parseCommandInput('--title "My Window" profile alice'); - expect(result.commandName).toBe('profile'); - expect(result.args).toEqual(['alice']); - expect(result.globalFlags?.windowProps?.title).toBe('My Window'); + expect(result.commandName).toBe("profile"); + expect(result.args).toEqual(["alice"]); + expect(result.globalFlags?.windowProps?.title).toBe("My Window"); }); - it('should handle --title in middle', () => { + it("should handle --title in middle", () => { const result = parseCommandInput('req -k 1 --title "My Feed" -a alice'); - expect(result.commandName).toBe('req'); - expect(result.args).toEqual(['-k', '1', '-a', 'alice']); - expect(result.globalFlags?.windowProps?.title).toBe('My Feed'); + expect(result.commandName).toBe("req"); + expect(result.args).toEqual(["-k", "1", "-a", "alice"]); + expect(result.globalFlags?.windowProps?.title).toBe("My Feed"); }); - it('should handle --title with single quotes', () => { + it("should handle --title with single quotes", () => { const result = parseCommandInput("profile alice --title 'My Window'"); - expect(result.globalFlags?.windowProps?.title).toBe('My Window'); + expect(result.globalFlags?.windowProps?.title).toBe("My Window"); }); - it('should handle --title without quotes (single word)', () => { - const result = parseCommandInput('profile alice --title MyWindow'); - expect(result.globalFlags?.windowProps?.title).toBe('MyWindow'); + it("should handle --title without quotes (single word)", () => { + const result = parseCommandInput("profile alice --title MyWindow"); + expect(result.globalFlags?.windowProps?.title).toBe("MyWindow"); }); - it('should preserve command behavior when no --title', () => { - const result = parseCommandInput('req -k 1 -a alice'); - expect(result.commandName).toBe('req'); - expect(result.args).toEqual(['-k', '1', '-a', 'alice']); + it("should preserve command behavior when no --title", () => { + const result = parseCommandInput("req -k 1 -a alice"); + expect(result.commandName).toBe("req"); + expect(result.args).toEqual(["-k", "1", "-a", "alice"]); expect(result.globalFlags).toEqual({}); }); - it('should error when --title has no value', () => { - const result = parseCommandInput('profile alice --title'); - expect(result.error).toContain('--title requires a value'); + it("should error when --title has no value", () => { + const result = parseCommandInput("profile alice --title"); + expect(result.error).toContain("--title requires a value"); }); - it('should handle emoji in --title', () => { + it("should handle emoji in --title", () => { const result = parseCommandInput('profile alice --title "👤 Alice"'); - expect(result.globalFlags?.windowProps?.title).toBe('👤 Alice'); + expect(result.globalFlags?.windowProps?.title).toBe("👤 Alice"); }); }); }); diff --git a/src/lib/command-parser.ts b/src/lib/command-parser.ts index 50f82a7..870929e 100644 --- a/src/lib/command-parser.ts +++ b/src/lib/command-parser.ts @@ -51,7 +51,8 @@ export function parseCommandInput(input: string): ParsedCommand { commandName: "", args: [], fullInput, - error: error instanceof Error ? error.message : "Failed to parse global flags", + error: + error instanceof Error ? error.message : "Failed to parse global flags", }; } diff --git a/src/lib/global-flags.test.ts b/src/lib/global-flags.test.ts index 6d6c755..352c078 100644 --- a/src/lib/global-flags.test.ts +++ b/src/lib/global-flags.test.ts @@ -1,183 +1,287 @@ -import { describe, it, expect } from 'vitest'; -import { extractGlobalFlagsFromTokens, isGlobalFlag } from './global-flags'; +import { describe, it, expect } from "vitest"; +import { extractGlobalFlagsFromTokens, isGlobalFlag } from "./global-flags"; -describe('extractGlobalFlagsFromTokens', () => { - describe('basic extraction', () => { - it('should extract --title flag at end', () => { - const result = extractGlobalFlagsFromTokens(['profile', 'alice', '--title', 'My Window']); - expect(result.globalFlags.windowProps?.title).toBe('My Window'); - expect(result.remainingTokens).toEqual(['profile', 'alice']); +describe("extractGlobalFlagsFromTokens", () => { + describe("basic extraction", () => { + it("should extract --title flag at end", () => { + const result = extractGlobalFlagsFromTokens([ + "profile", + "alice", + "--title", + "My Window", + ]); + expect(result.globalFlags.windowProps?.title).toBe("My Window"); + expect(result.remainingTokens).toEqual(["profile", "alice"]); }); - it('should extract --title flag at start', () => { - const result = extractGlobalFlagsFromTokens(['--title', 'My Window', 'profile', 'alice']); - expect(result.globalFlags.windowProps?.title).toBe('My Window'); - expect(result.remainingTokens).toEqual(['profile', 'alice']); + it("should extract --title flag at start", () => { + const result = extractGlobalFlagsFromTokens([ + "--title", + "My Window", + "profile", + "alice", + ]); + expect(result.globalFlags.windowProps?.title).toBe("My Window"); + expect(result.remainingTokens).toEqual(["profile", "alice"]); }); - it('should extract --title flag in middle', () => { - const result = extractGlobalFlagsFromTokens(['profile', '--title', 'My Window', 'alice']); - expect(result.globalFlags.windowProps?.title).toBe('My Window'); - expect(result.remainingTokens).toEqual(['profile', 'alice']); + it("should extract --title flag in middle", () => { + const result = extractGlobalFlagsFromTokens([ + "profile", + "--title", + "My Window", + "alice", + ]); + expect(result.globalFlags.windowProps?.title).toBe("My Window"); + expect(result.remainingTokens).toEqual(["profile", "alice"]); }); - it('should handle command with no global flags', () => { - const result = extractGlobalFlagsFromTokens(['profile', 'alice']); + it("should handle command with no global flags", () => { + const result = extractGlobalFlagsFromTokens(["profile", "alice"]); expect(result.globalFlags).toEqual({}); - expect(result.remainingTokens).toEqual(['profile', 'alice']); + expect(result.remainingTokens).toEqual(["profile", "alice"]); }); - it('should handle empty token array', () => { + it("should handle empty token array", () => { const result = extractGlobalFlagsFromTokens([]); expect(result.globalFlags).toEqual({}); expect(result.remainingTokens).toEqual([]); }); }); - describe('duplicate flags', () => { - it('should use last value when --title specified multiple times', () => { + describe("duplicate flags", () => { + it("should use last value when --title specified multiple times", () => { const result = extractGlobalFlagsFromTokens([ - '--title', 'First', - 'profile', 'alice', - '--title', 'Second' + "--title", + "First", + "profile", + "alice", + "--title", + "Second", ]); - expect(result.globalFlags.windowProps?.title).toBe('Second'); - expect(result.remainingTokens).toEqual(['profile', 'alice']); + expect(result.globalFlags.windowProps?.title).toBe("Second"); + expect(result.remainingTokens).toEqual(["profile", "alice"]); }); }); - describe('error handling', () => { - it('should error when --title has no value', () => { - expect(() => extractGlobalFlagsFromTokens(['profile', '--title'])) - .toThrow('Flag --title requires a value'); + describe("error handling", () => { + it("should error when --title has no value", () => { + expect(() => + extractGlobalFlagsFromTokens(["profile", "--title"]), + ).toThrow("Flag --title requires a value"); }); - it('should error when --title value is another flag', () => { - expect(() => extractGlobalFlagsFromTokens(['--title', '--other-flag', 'profile'])) - .toThrow('Flag --title requires a value'); + it("should error when --title value is another flag", () => { + expect(() => + extractGlobalFlagsFromTokens(["--title", "--other-flag", "profile"]), + ).toThrow("Flag --title requires a value"); }); }); - describe('sanitization', () => { - it('should strip control characters', () => { - const result = extractGlobalFlagsFromTokens(['--title', 'My\nWindow\tTitle', 'profile']); - expect(result.globalFlags.windowProps?.title).toBe('MyWindowTitle'); + describe("sanitization", () => { + it("should strip control characters", () => { + const result = extractGlobalFlagsFromTokens([ + "--title", + "My\nWindow\tTitle", + "profile", + ]); + expect(result.globalFlags.windowProps?.title).toBe("MyWindowTitle"); }); - it('should strip null bytes', () => { - const result = extractGlobalFlagsFromTokens(['--title', 'My\x00Window', 'profile']); - expect(result.globalFlags.windowProps?.title).toBe('MyWindow'); + it("should strip null bytes", () => { + const result = extractGlobalFlagsFromTokens([ + "--title", + "My\x00Window", + "profile", + ]); + expect(result.globalFlags.windowProps?.title).toBe("MyWindow"); }); - it('should preserve Unicode characters', () => { - const result = extractGlobalFlagsFromTokens(['--title', 'Profile 👤 Alice', 'profile']); - expect(result.globalFlags.windowProps?.title).toBe('Profile 👤 Alice'); + it("should preserve Unicode characters", () => { + const result = extractGlobalFlagsFromTokens([ + "--title", + "Profile 👤 Alice", + "profile", + ]); + expect(result.globalFlags.windowProps?.title).toBe("Profile 👤 Alice"); }); - it('should preserve emoji', () => { - const result = extractGlobalFlagsFromTokens(['--title', '🎯 Important', 'profile']); - expect(result.globalFlags.windowProps?.title).toBe('🎯 Important'); + it("should preserve emoji", () => { + const result = extractGlobalFlagsFromTokens([ + "--title", + "🎯 Important", + "profile", + ]); + expect(result.globalFlags.windowProps?.title).toBe("🎯 Important"); }); - it('should preserve CJK characters', () => { - const result = extractGlobalFlagsFromTokens(['--title', '日本語タイトル', 'profile']); - expect(result.globalFlags.windowProps?.title).toBe('日本語タイトル'); + it("should preserve CJK characters", () => { + const result = extractGlobalFlagsFromTokens([ + "--title", + "日本語タイトル", + "profile", + ]); + expect(result.globalFlags.windowProps?.title).toBe("日本語タイトル"); }); - it('should preserve Arabic/RTL characters', () => { - const result = extractGlobalFlagsFromTokens(['--title', 'محمد', 'profile']); - expect(result.globalFlags.windowProps?.title).toBe('محمد'); + it("should preserve Arabic/RTL characters", () => { + const result = extractGlobalFlagsFromTokens([ + "--title", + "محمد", + "profile", + ]); + expect(result.globalFlags.windowProps?.title).toBe("محمد"); }); - it('should trim whitespace', () => { - const result = extractGlobalFlagsFromTokens(['--title', ' My Window ', 'profile']); - expect(result.globalFlags.windowProps?.title).toBe('My Window'); + it("should trim whitespace", () => { + const result = extractGlobalFlagsFromTokens([ + "--title", + " My Window ", + "profile", + ]); + expect(result.globalFlags.windowProps?.title).toBe("My Window"); }); - it('should fallback when title is empty after sanitization', () => { - const result = extractGlobalFlagsFromTokens(['--title', ' ', 'profile']); + it("should fallback when title is empty after sanitization", () => { + const result = extractGlobalFlagsFromTokens([ + "--title", + " ", + "profile", + ]); expect(result.globalFlags.windowProps?.title).toBeUndefined(); }); - it('should fallback when title is only control characters', () => { - const result = extractGlobalFlagsFromTokens(['--title', '\n\t\r', 'profile']); + it("should fallback when title is only control characters", () => { + const result = extractGlobalFlagsFromTokens([ + "--title", + "\n\t\r", + "profile", + ]); expect(result.globalFlags.windowProps?.title).toBeUndefined(); }); - it('should limit title to 200 characters', () => { - const longTitle = 'a'.repeat(300); - const result = extractGlobalFlagsFromTokens(['--title', longTitle, 'profile']); + it("should limit title to 200 characters", () => { + const longTitle = "a".repeat(300); + const result = extractGlobalFlagsFromTokens([ + "--title", + longTitle, + "profile", + ]); expect(result.globalFlags.windowProps?.title).toHaveLength(200); }); }); - describe('complex command scenarios', () => { - it('should preserve command flags', () => { + describe("complex command scenarios", () => { + it("should preserve command flags", () => { const result = extractGlobalFlagsFromTokens([ - 'req', '-k', '1', '-a', 'alice@nostr.com', '--title', 'My Feed' + "req", + "-k", + "1", + "-a", + "alice@nostr.com", + "--title", + "My Feed", + ]); + expect(result.globalFlags.windowProps?.title).toBe("My Feed"); + expect(result.remainingTokens).toEqual([ + "req", + "-k", + "1", + "-a", + "alice@nostr.com", ]); - expect(result.globalFlags.windowProps?.title).toBe('My Feed'); - expect(result.remainingTokens).toEqual(['req', '-k', '1', '-a', 'alice@nostr.com']); }); - it('should handle command with many flags', () => { + it("should handle command with many flags", () => { const result = extractGlobalFlagsFromTokens([ - 'req', '-k', '1,3,7', '-a', 'npub...', '-l', '50', '--title', 'Timeline' + "req", + "-k", + "1,3,7", + "-a", + "npub...", + "-l", + "50", + "--title", + "Timeline", + ]); + expect(result.globalFlags.windowProps?.title).toBe("Timeline"); + expect(result.remainingTokens).toEqual([ + "req", + "-k", + "1,3,7", + "-a", + "npub...", + "-l", + "50", ]); - expect(result.globalFlags.windowProps?.title).toBe('Timeline'); - expect(result.remainingTokens).toEqual(['req', '-k', '1,3,7', '-a', 'npub...', '-l', '50']); }); - it('should not interfere with command-specific --title (if any)', () => { + it("should not interfere with command-specific --title (if any)", () => { // If a future command uses --title for something else, this test would catch it // For now, just verify tokens are preserved const result = extractGlobalFlagsFromTokens([ - 'somecommand', 'arg1', '--title', 'Global Title', 'arg2' + "somecommand", + "arg1", + "--title", + "Global Title", + "arg2", ]); - expect(result.globalFlags.windowProps?.title).toBe('Global Title'); - expect(result.remainingTokens).toEqual(['somecommand', 'arg1', 'arg2']); + expect(result.globalFlags.windowProps?.title).toBe("Global Title"); + expect(result.remainingTokens).toEqual(["somecommand", "arg1", "arg2"]); }); }); - describe('real-world examples', () => { - it('profile with custom title', () => { + describe("real-world examples", () => { + it("profile with custom title", () => { const result = extractGlobalFlagsFromTokens([ - 'profile', 'npub1abc...', '--title', 'Alice (Competitor)' + "profile", + "npub1abc...", + "--title", + "Alice (Competitor)", ]); - expect(result.globalFlags.windowProps?.title).toBe('Alice (Competitor)'); - expect(result.remainingTokens).toEqual(['profile', 'npub1abc...']); + expect(result.globalFlags.windowProps?.title).toBe("Alice (Competitor)"); + expect(result.remainingTokens).toEqual(["profile", "npub1abc..."]); }); - it('req with custom title', () => { + it("req with custom title", () => { const result = extractGlobalFlagsFromTokens([ - 'req', '-k', '1', '-a', '$me', '--title', 'My Notes' + "req", + "-k", + "1", + "-a", + "$me", + "--title", + "My Notes", ]); - expect(result.globalFlags.windowProps?.title).toBe('My Notes'); - expect(result.remainingTokens).toEqual(['req', '-k', '1', '-a', '$me']); + expect(result.globalFlags.windowProps?.title).toBe("My Notes"); + expect(result.remainingTokens).toEqual(["req", "-k", "1", "-a", "$me"]); }); - it('nip with custom title', () => { + it("nip with custom title", () => { const result = extractGlobalFlagsFromTokens([ - 'nip', '01', '--title', 'Basic Protocol' + "nip", + "01", + "--title", + "Basic Protocol", ]); - expect(result.globalFlags.windowProps?.title).toBe('Basic Protocol'); - expect(result.remainingTokens).toEqual(['nip', '01']); + expect(result.globalFlags.windowProps?.title).toBe("Basic Protocol"); + expect(result.remainingTokens).toEqual(["nip", "01"]); }); }); }); -describe('isGlobalFlag', () => { - it('should recognize --title as global flag', () => { - expect(isGlobalFlag('--title')).toBe(true); +describe("isGlobalFlag", () => { + it("should recognize --title as global flag", () => { + expect(isGlobalFlag("--title")).toBe(true); }); - it('should not recognize command flags', () => { - expect(isGlobalFlag('-k')).toBe(false); - expect(isGlobalFlag('--kind')).toBe(false); + it("should not recognize command flags", () => { + expect(isGlobalFlag("-k")).toBe(false); + expect(isGlobalFlag("--kind")).toBe(false); }); - it('should not recognize regular arguments', () => { - expect(isGlobalFlag('profile')).toBe(false); - expect(isGlobalFlag('alice')).toBe(false); + it("should not recognize regular arguments", () => { + expect(isGlobalFlag("profile")).toBe(false); + expect(isGlobalFlag("alice")).toBe(false); }); }); diff --git a/src/lib/global-flags.ts b/src/lib/global-flags.ts index d2517c7..e1e69dd 100644 --- a/src/lib/global-flags.ts +++ b/src/lib/global-flags.ts @@ -17,14 +17,14 @@ export interface ExtractResult { remainingTokens: string[]; } -const RESERVED_GLOBAL_FLAGS = ['--title'] as const; +const RESERVED_GLOBAL_FLAGS = ["--title"] as const; /** * Sanitize a title string: strip control characters, limit length */ function sanitizeTitle(title: string): string | undefined { const sanitized = title - .replace(/[\x00-\x1F\x7F]/g, '') // Strip control chars (newlines, tabs, null bytes) + .replace(/[\x00-\x1F\x7F]/g, "") // Strip control chars (newlines, tabs, null bytes) .trim(); if (!sanitized) { @@ -55,12 +55,14 @@ export function extractGlobalFlagsFromTokens(tokens: string[]): ExtractResult { while (i < tokens.length) { const token = tokens[i]; - if (token === '--title') { + if (token === "--title") { // Extract title value (next token) const nextToken = tokens[i + 1]; - if (nextToken === undefined || nextToken.startsWith('--')) { - throw new Error('Flag --title requires a value. Usage: --title "Window Title"'); + if (nextToken === undefined || nextToken.startsWith("--")) { + throw new Error( + 'Flag --title requires a value. Usage: --title "Window Title"', + ); } const sanitized = sanitizeTitle(nextToken); diff --git a/src/lib/imeta.ts b/src/lib/imeta.ts index 87c40e3..f4de03f 100644 --- a/src/lib/imeta.ts +++ b/src/lib/imeta.ts @@ -162,9 +162,7 @@ export function formatFileSize(bytes?: string | number): string { * @param dim - dimensions string like "1920x1080" * @returns aspect ratio as string like "16/9" or undefined if invalid */ -export function getAspectRatioFromDimensions( - dim?: string, -): string | undefined { +export function getAspectRatioFromDimensions(dim?: string): string | undefined { if (!dim) return undefined; const match = dim.match(/^(\d+)x(\d+)$/); diff --git a/src/lib/live-activity.ts b/src/lib/live-activity.ts new file mode 100644 index 0000000..18d4cc2 --- /dev/null +++ b/src/lib/live-activity.ts @@ -0,0 +1,142 @@ +import type { NostrEvent } from "@/types/nostr"; +import type { + ParsedLiveActivity, + LiveParticipant, + LiveStatus, +} from "@/types/live-activity"; +import { getTagValue } from "applesauce-core/helpers"; + +/** + * Helper to get all values for a given tag name + */ +function getTagValues(event: NostrEvent, tagName: string): string[] { + return event.tags.filter((t) => t[0] === tagName).map((t) => t[1] || ""); +} + +/** + * Parse a kind:30311 live activity event + */ +export function parseLiveActivity(event: NostrEvent): ParsedLiveActivity { + // Parse participants (p tags: [pubkey, relay?, role?, proof?]) + const participants: LiveParticipant[] = event.tags + .filter((t) => t[0] === "p") + .map((t) => ({ + pubkey: t[1], + relay: t[2] || undefined, + role: t[3] || "Participant", + proof: t[4] || undefined, + })); + + // Parse numeric fields + const parseNum = (val?: string): number | undefined => { + return val ? parseInt(val, 10) : undefined; + }; + + return { + event, + identifier: getTagValue(event, "d") || "", + title: getTagValue(event, "title"), + summary: getTagValue(event, "summary"), + image: getTagValue(event, "image"), + streaming: getTagValue(event, "streaming"), + recording: getTagValue(event, "recording"), + starts: parseNum(getTagValue(event, "starts")), + ends: parseNum(getTagValue(event, "ends")), + status: getTagValue(event, "status") as LiveStatus | undefined, + currentParticipants: parseNum(getTagValue(event, "current_participants")), + totalParticipants: parseNum(getTagValue(event, "total_participants")), + participants, + hashtags: getTagValues(event, "t"), + relays: getTagValues(event, "relays"), + lastUpdate: event.created_at || Date.now() / 1000, + }; +} + +/** + * Get live status with optional timeout detection + * Events without updates for 1hr may be considered ended + */ +export function getLiveStatus( + event: NostrEvent, + considerTimeout = true, +): LiveStatus { + const parsed = parseLiveActivity(event); + + // Explicit status from tags + if (parsed.status) { + // If status is 'live' but hasn't been updated in 1hr, consider ended + if (parsed.status === "live" && considerTimeout) { + const now = Date.now() / 1000; + const oneHourAgo = now - 3600; + if (parsed.lastUpdate < oneHourAgo) { + return "ended"; + } + } + return parsed.status; + } + + // Infer status from timestamps + const now = Date.now() / 1000; + if (parsed.ends && now > parsed.ends) { + return "ended"; + } + if (parsed.starts && now > parsed.starts) { + return "live"; + } + return "planned"; +} + +/** + * Get the host of a live activity + * Returns the first participant with "Host" role, or event author as fallback + */ +export function getLiveHost(event: NostrEvent): string { + const parsed = parseLiveActivity(event); + const host = parsed.participants.find((p) => p.role.toLowerCase() === "host"); + return host?.pubkey || event.pubkey; +} + +/** + * Get streaming URL (if available) + */ +export function getStreamingUrl(event: NostrEvent): string | undefined { + return parseLiveActivity(event).streaming; +} + +/** + * Get recording URL (if available) + */ +export function getRecordingUrl(event: NostrEvent): string | undefined { + return parseLiveActivity(event).recording; +} + +/** + * Format start time as relative or absolute + */ +export function formatStartTime( + starts?: number, + status?: LiveStatus, +): string | undefined { + if (!starts) return undefined; + + const now = Date.now() / 1000; + const diff = starts - now; + + if (status === "planned" && diff > 0) { + // Future event - show countdown + const hours = Math.floor(diff / 3600); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `in ${days}d`; + } else if (hours > 0) { + return `in ${hours}h`; + } else { + const minutes = Math.floor(diff / 60); + return `in ${minutes}m`; + } + } + + // Past event - show date + return new Date(starts * 1000).toLocaleDateString(); +} diff --git a/src/lib/req-parser.test.ts b/src/lib/req-parser.test.ts index fab8a38..96eba91 100644 --- a/src/lib/req-parser.test.ts +++ b/src/lib/req-parser.test.ts @@ -586,7 +586,14 @@ describe("parseReqCommand", () => { it("should work with other filters", () => { const now = Math.floor(Date.now() / 1000); - const result = parseReqCommand(["-k", "1", "--since", "7d", "--until", "now"]); + const result = parseReqCommand([ + "-k", + "1", + "--since", + "7d", + "--until", + "now", + ]); expect(result.filter.kinds).toEqual([1]); expect(result.filter.since).toBeLessThan(now); @@ -646,7 +653,12 @@ describe("parseReqCommand", () => { it("should work with mixed relative and unix timestamps", () => { const now = Math.floor(Date.now() / 1000); - const result = parseReqCommand(["--since", "7d", "--until", "1234567890"]); + const result = parseReqCommand([ + "--since", + "7d", + "--until", + "1234567890", + ]); expect(result.filter.since).toBeDefined(); expect(result.filter.since).toBeLessThan(now); expect(result.filter.until).toBe(1234567890); diff --git a/src/lib/req-parser.ts b/src/lib/req-parser.ts index 1d79c05..9ed43d2 100644 --- a/src/lib/req-parser.ts +++ b/src/lib/req-parser.ts @@ -394,7 +394,9 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { nip05Authors: nip05Authors.size > 0 ? Array.from(nip05Authors) : undefined, nip05PTags: nip05PTags.size > 0 ? Array.from(nip05PTags) : undefined, nip05PTagsUppercase: - nip05PTagsUppercase.size > 0 ? Array.from(nip05PTagsUppercase) : undefined, + nip05PTagsUppercase.size > 0 + ? Array.from(nip05PTagsUppercase) + : undefined, needsAccount, }; } diff --git a/src/services/loaders.test.ts b/src/services/loaders.test.ts index 00f8cc1..368335b 100644 --- a/src/services/loaders.test.ts +++ b/src/services/loaders.test.ts @@ -30,7 +30,7 @@ vi.mock("applesauce-loaders/loaders", () => ({ }), // Return pointer so we can inspect it in tests _testPointer: pointer, - }) as any + }) as any, ), createAddressLoader: vi.fn(() => () => ({ subscribe: () => {} })), createTimelineLoader: vi.fn(), @@ -75,7 +75,9 @@ describe("eventLoader", () => { expect(result).toBeDefined(); expect((result as any)._testPointer.id).toBe("test123"); // mergeRelaySets normalizes URLs with trailing slash - expect((result as any)._testPointer.relays).toContain("wss://relay.nostr.band/"); + expect((result as any)._testPointer.relays).toContain( + "wss://relay.nostr.band/", + ); }); it("should handle EventPointer with relay hints", () => { @@ -87,7 +89,9 @@ describe("eventLoader", () => { const result = eventLoader(pointer); // mergeRelaySets normalizes URLs with trailing slash - expect((result as any)._testPointer.relays).toContain("wss://relay.example.com/"); + expect((result as any)._testPointer.relays).toContain( + "wss://relay.example.com/", + ); }); it("should handle undefined context gracefully", () => { @@ -95,7 +99,9 @@ describe("eventLoader", () => { expect(result).toBeDefined(); // mergeRelaySets normalizes URLs with trailing slash - expect((result as any)._testPointer.relays).toContain("wss://relay.nostr.band/"); + expect((result as any)._testPointer.relays).toContain( + "wss://relay.nostr.band/", + ); }); }); @@ -108,10 +114,10 @@ describe("eventLoader", () => { const result = eventLoader({ id: "test123" }, "author-pubkey"); expect(relayListCache.getOutboxRelaysSync).toHaveBeenCalledWith( - "author-pubkey" + "author-pubkey", ); expect((result as any)._testPointer.relays).toContain( - "wss://author-relay.com/" + "wss://author-relay.com/", ); }); @@ -130,7 +136,9 @@ describe("eventLoader", () => { expect(relays).toContain("wss://cached2.com/"); expect(relays).toContain("wss://cached3.com/"); // Should be limited to top 3 cached relays - expect(relays.filter((r: string) => r.startsWith("wss://cached")).length).toBeLessThanOrEqual(3); + expect( + relays.filter((r: string) => r.startsWith("wss://cached")).length, + ).toBeLessThanOrEqual(3); }); }); @@ -190,7 +198,7 @@ describe("eventLoader", () => { const result = eventLoader({ id: "parent123" }, event); expect(relayListCache.getOutboxRelaysSync).toHaveBeenCalledWith( - "mentioned-author-pubkey" + "mentioned-author-pubkey", ); const relays = (result as any)._testPointer.relays; expect(relays).toContain("wss://author-outbox.com/"); @@ -292,7 +300,9 @@ describe("eventLoader", () => { const relays = (result as any)._testPointer.relays; // Should only appear once despite being in 4 sources - const count = relays.filter((r: string) => r === "wss://duplicate.com/").length; + const count = relays.filter( + (r: string) => r === "wss://duplicate.com/", + ).length; expect(count).toBe(1); }); }); @@ -305,7 +315,9 @@ describe("eventLoader", () => { expect(result).toBeDefined(); // mergeRelaySets normalizes aggregator relays with trailing slash - expect((result as any)._testPointer.relays).toContain("wss://relay.nostr.band/"); + expect((result as any)._testPointer.relays).toContain( + "wss://relay.nostr.band/", + ); }); it("should handle invalid e tags gracefully", () => { @@ -343,10 +355,10 @@ describe("eventLoader", () => { expect(eventStore.getEvent).toHaveBeenCalledWith("test123"); expect(relayListCache.getOutboxRelaysSync).toHaveBeenCalledWith( - "existing-author" + "existing-author", ); expect((result as any)._testPointer.relays).toContain( - "wss://existing-author-relay.com/" + "wss://existing-author-relay.com/", ); }); @@ -385,7 +397,7 @@ describe("eventLoader", () => { // Count how many cached relays made it through const cachedCount = relays.filter((r: string) => - r.startsWith("wss://cached") + r.startsWith("wss://cached"), ).length; // Should be exactly 3 (top 3 cached relays) diff --git a/src/services/loaders.ts b/src/services/loaders.ts index f976639..a3010cb 100644 --- a/src/services/loaders.ts +++ b/src/services/loaders.ts @@ -86,7 +86,7 @@ const baseEventLoader = createEventLoader(pool, { */ export function eventLoader( pointer: EventPointer | { id: string }, - context?: string | NostrEvent + context?: string | NostrEvent, ): Observable { // Extract context information let authorHint: string | undefined; @@ -117,7 +117,8 @@ export function eventLoader( // Check if event already exists in store const existingEvent = eventStore.getEvent(pointer.id); if (existingEvent) { - cachedOutboxRelays = relayListCache.getOutboxRelaysSync(existingEvent.pubkey) || []; + cachedOutboxRelays = + relayListCache.getOutboxRelaysSync(existingEvent.pubkey) || []; } // If not in store but we have author hint (from reply "p" tag) @@ -131,12 +132,12 @@ export function eventLoader( // Merge all relay sources with priority ordering // mergeRelaySets handles deduplication, normalization, and invalid URL filtering const allRelays = mergeRelaySets( - directHints, // Priority 1: Direct hints (most specific) - seenRelays, // Priority 2: Where reply was seen (high confidence) - topCachedRelays, // Priority 3: Author's outbox (NIP-65 standard) - rTags, // Priority 4: Conversation context - eTagRelays, // Priority 5: Other event references - AGGREGATOR_RELAYS // Priority 6: Fallback + directHints, // Priority 1: Direct hints (most specific) + seenRelays, // Priority 2: Where reply was seen (high confidence) + topCachedRelays, // Priority 3: Author's outbox (NIP-65 standard) + rTags, // Priority 4: Conversation context + eTagRelays, // Priority 5: Other event references + AGGREGATOR_RELAYS, // Priority 6: Fallback ); // Build enhanced pointer with all relay sources @@ -160,7 +161,7 @@ export function eventLoader( `[eventLoader] Fetching ${pointer.id.slice(0, 8)} from ${allRelays.length} relays ` + `(direct=${directHints.length} seen=${seenRelays?.size || 0} cached=${topCachedRelays.length} ` + `r=${rTags.length} e=${eTagRelays.length} agg=${AGGREGATOR_RELAYS.length}, ` + - `${duplicatesRemoved} duplicates removed)` + `${duplicatesRemoved} duplicates removed)`, ); return baseEventLoader(enhancedPointer); diff --git a/src/services/relay-list-cache.ts b/src/services/relay-list-cache.ts index af8d36a..13a91aa 100644 --- a/src/services/relay-list-cache.ts +++ b/src/services/relay-list-cache.ts @@ -39,7 +39,9 @@ class RelayListCache { this.set(event); }); - console.log("[RelayListCache] Subscribed to EventStore for kind:10002 events"); + console.log( + "[RelayListCache] Subscribed to EventStore for kind:10002 events", + ); } /** @@ -105,9 +107,7 @@ class RelayListCache { try { return normalizeRelayURL(url); } catch { - console.warn( - `[RelayListCache] Invalid read relay URL: ${url}`, - ); + console.warn(`[RelayListCache] Invalid read relay URL: ${url}`); return null; } }) @@ -118,9 +118,7 @@ class RelayListCache { try { return normalizeRelayURL(url); } catch { - console.warn( - `[RelayListCache] Invalid write relay URL: ${url}`, - ); + console.warn(`[RelayListCache] Invalid write relay URL: ${url}`); return null; } }) diff --git a/src/services/relay-selection.ts b/src/services/relay-selection.ts index 02d3ac8..93aaed0 100644 --- a/src/services/relay-selection.ts +++ b/src/services/relay-selection.ts @@ -47,7 +47,10 @@ async function fetchRelayList( ); } catch (err) { // Timeout or error - continue with fallback - console.debug(`[RelaySelection] Failed to fetch relay list for ${pubkey.slice(0, 8)}`, err); + console.debug( + `[RelaySelection] Failed to fetch relay list for ${pubkey.slice(0, 8)}`, + err, + ); } } @@ -110,7 +113,9 @@ async function getOutboxRelaysForPubkey( } // Cache miss - get from EventStore - const event = eventStore.getReplaceable(10002, pubkey, "") as NostrEvent | undefined; + const event = eventStore.getReplaceable(10002, pubkey, "") as + | NostrEvent + | undefined; if (!event) { console.debug( `[RelaySelection] No relay list found for ${pubkey.slice(0, 8)} (not in cache or store)`, @@ -131,7 +136,9 @@ async function getOutboxRelaysForPubkey( try { return normalizeRelayURL(url); } catch { - console.warn(`[RelaySelection] Invalid relay URL in kind:10002: ${url}`); + console.warn( + `[RelaySelection] Invalid relay URL in kind:10002: ${url}`, + ); return null; } }) @@ -147,18 +154,24 @@ async function getOutboxRelaysForPubkey( // Edge case: If all relays filtered out, keep some anyway for redundancy if (healthy.length === 0 && sanitized.length > 0) { console.debug( - `[RelaySelection] All relays unhealthy for ${pubkey.slice(0, 8)}, keeping sanitized relays` + `[RelaySelection] All relays unhealthy for ${pubkey.slice(0, 8)}, keeping sanitized relays`, ); return sanitized; } return healthy; } catch (err) { - console.warn(`[RelaySelection] Liveness filtering failed, using sanitized relays:`, err); + console.warn( + `[RelaySelection] Liveness filtering failed, using sanitized relays:`, + err, + ); return sanitized; } } catch (err) { - console.warn(`[RelaySelection] Error getting outbox relays for ${pubkey.slice(0, 8)}:`, err); + console.warn( + `[RelaySelection] Error getting outbox relays for ${pubkey.slice(0, 8)}:`, + err, + ); return []; } } @@ -198,7 +211,9 @@ async function getInboxRelaysForPubkey( } // Cache miss - get from EventStore - const event = eventStore.getReplaceable(10002, pubkey, "") as NostrEvent | undefined; + const event = eventStore.getReplaceable(10002, pubkey, "") as + | NostrEvent + | undefined; if (!event) { console.debug( `[RelaySelection] No relay list found for ${pubkey.slice(0, 8)} (not in cache or store)`, @@ -219,7 +234,9 @@ async function getInboxRelaysForPubkey( try { return normalizeRelayURL(url); } catch { - console.warn(`[RelaySelection] Invalid relay URL in kind:10002: ${url}`); + console.warn( + `[RelaySelection] Invalid relay URL in kind:10002: ${url}`, + ); return null; } }) @@ -235,18 +252,24 @@ async function getInboxRelaysForPubkey( // Edge case: If all relays filtered out, keep some anyway for redundancy if (healthy.length === 0 && sanitized.length > 0) { console.debug( - `[RelaySelection] All relays unhealthy for ${pubkey.slice(0, 8)}, keeping sanitized relays` + `[RelaySelection] All relays unhealthy for ${pubkey.slice(0, 8)}, keeping sanitized relays`, ); return sanitized; } return healthy; } catch (err) { - console.warn(`[RelaySelection] Liveness filtering failed, using sanitized relays:`, err); + console.warn( + `[RelaySelection] Liveness filtering failed, using sanitized relays:`, + err, + ); return sanitized; } } catch (err) { - console.warn(`[RelaySelection] Error getting inbox relays for ${pubkey.slice(0, 8)}:`, err); + console.warn( + `[RelaySelection] Error getting inbox relays for ${pubkey.slice(0, 8)}:`, + err, + ); return []; } } @@ -265,7 +288,10 @@ function buildReasoning( pTagPointers: ProfilePointer[], ): RelaySelectionReasoning[] { // Group pubkeys by relay, tracking writers and readers separately - const relayMap = new Map; readers: Set }>(); + const relayMap = new Map< + string, + { writers: Set; readers: Set } + >(); for (const pointer of selectedPointers) { const isAuthor = authorPointers.some((p) => p.pubkey === pointer.pubkey); @@ -289,12 +315,14 @@ function buildReasoning( } // Convert to reasoning array - return Array.from(relayMap.entries()).map(([relay, { writers, readers }]) => ({ - relay, - writers: Array.from(writers), - readers: Array.from(readers), - isFallback: false, - })); + return Array.from(relayMap.entries()).map( + ([relay, { writers, readers }]) => ({ + relay, + writers: Array.from(writers), + readers: Array.from(readers), + isFallback: false, + }), + ); } /** @@ -357,7 +385,9 @@ export async function selectRelaysForFilter( // If no pubkeys, return fallback immediately if (authors.length === 0 && pTags.length === 0) { - console.debug("[RelaySelection] No authors or #p tags, using fallback relays"); + console.debug( + "[RelaySelection] No authors or #p tags, using fallback relays", + ); return createFallbackResult(fallbackRelays); } @@ -381,7 +411,7 @@ export async function selectRelaysForFilter( pubkey, relays: relays.slice(0, maxRelaysPerUser), }; - }) + }), ); const pTagPointers: ProfilePointer[] = await Promise.all( @@ -391,18 +421,24 @@ export async function selectRelaysForFilter( pubkey, relays: relays.slice(0, maxRelaysPerUser), }; - }) + }), ); // Add fallbacks for users with no relays const processedAuthorPointers = authorPointers.map((pointer) => ({ ...pointer, - relays: (pointer.relays && pointer.relays.length > 0) ? pointer.relays : fallbackRelays, + relays: + pointer.relays && pointer.relays.length > 0 + ? pointer.relays + : fallbackRelays, })); const processedPTagPointers = pTagPointers.map((pointer) => ({ ...pointer, - relays: (pointer.relays && pointer.relays.length > 0) ? pointer.relays : fallbackRelays, + relays: + pointer.relays && pointer.relays.length > 0 + ? pointer.relays + : fallbackRelays, })); const allPointers = [...processedAuthorPointers, ...processedPTagPointers]; @@ -418,7 +454,9 @@ export async function selectRelaysForFilter( // If all users have no relays, return fallback result if (fallbackCount === allPointers.length) { - console.debug("[RelaySelection] All users have no relay lists, using fallback"); + console.debug( + "[RelaySelection] All users have no relay lists, using fallback", + ); return createFallbackResult(fallbackRelays); } @@ -476,8 +514,8 @@ export async function selectRelaysForFilter( selectedPointers = [...selectedAuthors, ...selectedPTags]; console.debug( - `[RelaySelection] Selected ${selectedAuthors.flatMap(p => p.relays).length} write relays from ${authors.length} authors, ` + - `${selectedPTags.flatMap(p => p.relays).length} read relays from ${pTags.length} p-tags`, + `[RelaySelection] Selected ${selectedAuthors.flatMap((p) => p.relays).length} write relays from ${authors.length} authors, ` + + `${selectedPTags.flatMap((p) => p.relays).length} read relays from ${pTags.length} p-tags`, ); } else { // Optimize relay selection for efficient coverage @@ -487,17 +525,23 @@ export async function selectRelaysForFilter( }); console.debug( - `[RelaySelection] Selected relays from ${allPointers.length} ${allPointers.length === 1 ? 'user' : 'users'}`, + `[RelaySelection] Selected relays from ${allPointers.length} ${allPointers.length === 1 ? "user" : "users"}`, ); } // Extract unique relays - const relays = Array.from(new Set(selectedPointers.flatMap((p) => p.relays || []))); + const relays = Array.from( + new Set(selectedPointers.flatMap((p) => p.relays || [])), + ); console.debug(`[RelaySelection] Total: ${relays.length} unique relays`); // Build reasoning - const reasoning = buildReasoning(selectedPointers, authorPointers, pTagPointers); + const reasoning = buildReasoning( + selectedPointers, + authorPointers, + pTagPointers, + ); return { relays, diff --git a/src/types/live-activity.ts b/src/types/live-activity.ts new file mode 100644 index 0000000..ca947f1 --- /dev/null +++ b/src/types/live-activity.ts @@ -0,0 +1,50 @@ +import type { NostrEvent } from "./nostr"; + +export type LiveStatus = "planned" | "live" | "ended"; + +export type ParticipantRole = + | "Host" + | "Speaker" + | "Moderator" + | "Participant" + | string; + +export interface LiveParticipant { + pubkey: string; + relay?: string; + role: ParticipantRole; + proof?: string; +} + +export interface ParsedLiveActivity { + event: NostrEvent; + + // Required + identifier: string; // 'd' tag + + // Core metadata + title?: string; + summary?: string; + image?: string; + + // Streaming + streaming?: string; // Primary streaming URL + recording?: string; // Recording URL (after event ends) + + // Timing + starts?: number; // Unix timestamp + ends?: number; // Unix timestamp + status?: LiveStatus; + + // Participants + currentParticipants?: number; + totalParticipants?: number; + participants: LiveParticipant[]; + + // Additional + hashtags: string[]; // 't' tags + relays: string[]; // 'relays' tag values + + // Computed + lastUpdate: number; // event.created_at +}