diff --git a/package-lock.json b/package-lock.json index 205d624a..c9ad7828 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3159,6 +3159,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3172,6 +3173,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3185,6 +3187,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3198,6 +3201,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3211,6 +3215,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3224,6 +3229,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3237,6 +3243,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3250,6 +3257,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3263,6 +3271,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3276,6 +3285,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3289,6 +3299,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3302,6 +3313,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3315,6 +3327,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3328,6 +3341,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3341,6 +3355,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3354,6 +3369,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3367,6 +3383,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3380,6 +3397,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3393,6 +3411,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3406,6 +3425,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3419,6 +3439,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3432,6 +3453,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3445,6 +3467,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3458,6 +3481,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3471,6 +3495,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/src/App.tsx b/src/App.tsx index d9c722fe..f42a53d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,31 +1,28 @@ // NOTE: This file should normally not be modified unless you are adding a new provider. // To add new routes, edit the AppRouter.tsx file. +import { Capacitor } from "@capacitor/core"; +import { StatusBar, Style } from "@capacitor/status-bar"; +import { NostrLoginProvider } from "@nostrify/react/login"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createHead, UnheadProvider } from '@unhead/react/client'; -import { InferSeoMetaPlugin } from '@unhead/addons'; -import { useEffect } from 'react'; -import NostrProvider from '@/components/NostrProvider'; -import { NostrSync } from '@/components/NostrSync'; -import { NativeNotifications } from '@/components/NativeNotifications'; - -import { InitialSyncGate } from '@/components/InitialSyncGate'; +import { InferSeoMetaPlugin } from "@unhead/addons"; +import { createHead, UnheadProvider } from "@unhead/react/client"; +import { useEffect } from "react"; +import { AppProvider } from "@/components/AppProvider"; +import { InitialSyncGate } from "@/components/InitialSyncGate"; +import { NativeNotifications } from "@/components/NativeNotifications"; +import NostrProvider from "@/components/NostrProvider"; +import { NostrSync } from "@/components/NostrSync"; +import { PlausibleProvider } from "@/components/PlausibleProvider"; +import { SentryProvider } from "@/components/SentryProvider"; import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { NostrLoginProvider } from '@nostrify/react/login'; -import { AppProvider } from '@/components/AppProvider'; -import { SentryProvider } from '@/components/SentryProvider'; -import { PlausibleProvider } from '@/components/PlausibleProvider'; -import { NWCProvider } from '@/contexts/NWCContext'; -import { AppConfig } from '@/contexts/AppContext'; -import AppRouter from './AppRouter'; -import { StatusBar, Style } from '@capacitor/status-bar'; -import { Capacitor } from '@capacitor/core'; +import type { AppConfig } from "@/contexts/AppContext"; +import { NWCProvider } from "@/contexts/NWCContext"; +import AppRouter from "./AppRouter"; const head = createHead({ - plugins: [ - InferSeoMetaPlugin(), - ], + plugins: [InferSeoMetaPlugin()], }); const queryClient = new QueryClient({ @@ -40,9 +37,9 @@ const queryClient = new QueryClient({ /** Hardcoded fallback values. Always provides every required field. */ const hardcodedConfig: AppConfig = { - appName: 'Ditto', - appId: 'ditto', - homePage: 'feed', + appName: "Ditto", + appId: "ditto", + homePage: "feed", magicMouse: false, theme: "system", autoShareTheme: true, @@ -98,6 +95,8 @@ const hardcodedConfig: AppConfig = { showPodcasts: false, feedIncludePodcastEpisodes: false, feedIncludePodcastTrailers: false, + showDevelopment: false, + feedIncludeDevelopment: false, showBadges: false, showBadgeDefinitions: true, showProfileBadges: true, @@ -105,17 +104,34 @@ const hardcodedConfig: AppConfig = { feedIncludeProfileBadges: false, followsFeedShowReplies: true, }, - sidebarOrder: ['feed', 'notifications', 'search', 'bookmarks', 'profile', 'photos', 'videos', 'themes', 'theme', 'settings', 'help'], - nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea', - blossomServers: ['https://blossom.ditto.pub/', 'https://blossom.dreamith.to/', 'https://blossom.primal.net/'], - faviconUrl: 'https://fetch.ditto.pub/favicon/{hostname}', - linkPreviewUrl: 'https://fetch.ditto.pub/link/{url}', - corsProxy: 'https://proxy.shakespeare.diy/?url={href}', - contentWarningPolicy: 'blur', - sentryDsn: import.meta.env.VITE_SENTRY_DSN || '', + sidebarOrder: [ + "feed", + "notifications", + "search", + "bookmarks", + "profile", + "photos", + "videos", + "themes", + "theme", + "settings", + "help", + ], + nip85StatsPubkey: + "5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea", + blossomServers: [ + "https://blossom.ditto.pub/", + "https://blossom.dreamith.to/", + "https://blossom.primal.net/", + ], + faviconUrl: "https://fetch.ditto.pub/favicon/{hostname}", + linkPreviewUrl: "https://fetch.ditto.pub/link/{url}", + corsProxy: "https://proxy.shakespeare.diy/?url={href}", + contentWarningPolicy: "blur", + sentryDsn: import.meta.env.VITE_SENTRY_DSN || "", sentryEnabled: true, - plausibleDomain: import.meta.env.VITE_PLAUSIBLE_DOMAIN || '', - plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || '', + plausibleDomain: import.meta.env.VITE_PLAUSIBLE_DOMAIN || "", + plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "", savedFeeds: [], }; @@ -125,7 +141,9 @@ const hardcodedConfig: AppConfig = { */ const defaultConfig: AppConfig = { ...hardcodedConfig, - ...(typeof __DITTO_CONFIG__ !== 'undefined' && __DITTO_CONFIG__ ? __DITTO_CONFIG__ : {}), + ...(typeof __DITTO_CONFIG__ !== "undefined" && __DITTO_CONFIG__ + ? __DITTO_CONFIG__ + : {}), }; export function App() { @@ -147,7 +165,7 @@ export function App() { - + diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 947490e7..2cebae1a 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -1,130 +1,209 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; -import { getExtraKindDef } from "./lib/extraKinds"; +import { AudioNavigationGuard } from "@/components/AudioNavigationGuard"; +import { MinimizedAudioBar } from "@/components/MinimizedAudioBar"; +import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext"; import { sidebarItemIcon } from "@/lib/sidebarItems"; -import { ScrollToTop } from "./components/ScrollToTop"; import { MainLayout } from "./components/MainLayout"; +import { ScrollToTop } from "./components/ScrollToTop"; import { useCurrentUser } from "./hooks/useCurrentUser"; import { useProfileUrl } from "./hooks/useProfileUrl"; -import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext"; -import { MinimizedAudioBar } from "@/components/MinimizedAudioBar"; -import { AudioNavigationGuard } from "@/components/AudioNavigationGuard"; -import Index from "./pages/Index"; -import NotFound from "./pages/NotFound"; -import { HomePage } from "./pages/HomePage"; -import { NIP19Page } from "./pages/NIP19Page"; -import { NotificationsPage } from "./pages/NotificationsPage"; -import { SearchPage } from "./pages/SearchPage"; -import { TrendsPage } from "./pages/TrendsPage"; -import { SettingsPage } from "./pages/SettingsPage"; -import { ProfileSettings } from "./pages/ProfileSettings"; -import { ContentSettingsPage } from "./pages/ContentSettingsPage"; -import { ContentPage } from "./pages/ContentPage"; -import { WalletSettingsPage } from "./pages/WalletSettingsPage"; -import { NotificationSettings } from "./pages/NotificationSettings"; +import { getExtraKindDef } from "./lib/extraKinds"; import { AdvancedSettingsPage } from "./pages/AdvancedSettingsPage"; -import { MagicSettingsPage } from "./pages/MagicSettingsPage"; -import { NetworkSettingsPage } from "./pages/NetworkSettingsPage"; -import { HashtagPage } from "./pages/HashtagPage"; -import { DomainFeedPage } from "./pages/DomainFeedPage"; -import { BookmarksPage } from "./pages/BookmarksPage"; -import { KindFeedPage } from "./pages/KindFeedPage"; -import { VideosFeedPage } from "./pages/VideosFeedPage"; -import { PhotosFeedPage } from "./pages/PhotosFeedPage"; -import { VinesFeedPage } from "./pages/VinesFeedPage"; -import { EventsFeedPage } from "./pages/EventsFeedPage"; -import { WebxdcFeedPage } from "./pages/WebxdcFeedPage"; -import { TreasuresPage } from "./pages/TreasuresPage"; -import { ThemesPage } from "./pages/ThemesPage"; -import { ExternalContentPage } from "./pages/ExternalContentPage"; import { AIChatPage } from "./pages/AIChatPage"; -import { WorldPage } from "./pages/WorldPage"; -import { MusicFeedPage } from "./pages/MusicFeedPage"; -import { PodcastsFeedPage } from "./pages/PodcastsFeedPage"; -import { BooksPage } from "./pages/BooksPage"; -import { RelayPage } from "./pages/RelayPage"; -import { UserListsPage } from "./pages/UserListsPage"; -import { HelpPage } from "./pages/HelpPage"; import { BadgesFeedPage } from "./pages/BadgesFeedPage"; +import { BookmarksPage } from "./pages/BookmarksPage"; +import { BooksPage } from "./pages/BooksPage"; +import { ContentPage } from "./pages/ContentPage"; +import { ContentSettingsPage } from "./pages/ContentSettingsPage"; +import { DomainFeedPage } from "./pages/DomainFeedPage"; +import { EventsFeedPage } from "./pages/EventsFeedPage"; +import { ExternalContentPage } from "./pages/ExternalContentPage"; +import { HashtagPage } from "./pages/HashtagPage"; +import { HelpPage } from "./pages/HelpPage"; +import { HomePage } from "./pages/HomePage"; +import Index from "./pages/Index"; +import { KindFeedPage } from "./pages/KindFeedPage"; +import { MagicSettingsPage } from "./pages/MagicSettingsPage"; +import { MusicFeedPage } from "./pages/MusicFeedPage"; +import { NetworkSettingsPage } from "./pages/NetworkSettingsPage"; +import { NIP19Page } from "./pages/NIP19Page"; +import NotFound from "./pages/NotFound"; +import { NotificationSettings } from "./pages/NotificationSettings"; +import { NotificationsPage } from "./pages/NotificationsPage"; +import { PhotosFeedPage } from "./pages/PhotosFeedPage"; +import { PodcastsFeedPage } from "./pages/PodcastsFeedPage"; +import { ProfileSettings } from "./pages/ProfileSettings"; +import { RelayPage } from "./pages/RelayPage"; +import { SearchPage } from "./pages/SearchPage"; +import { SettingsPage } from "./pages/SettingsPage"; +import { ThemesPage } from "./pages/ThemesPage"; +import { TreasuresPage } from "./pages/TreasuresPage"; +import { TrendsPage } from "./pages/TrendsPage"; +import { UserListsPage } from "./pages/UserListsPage"; +import { VideosFeedPage } from "./pages/VideosFeedPage"; +import { VinesFeedPage } from "./pages/VinesFeedPage"; +import { WalletSettingsPage } from "./pages/WalletSettingsPage"; +import { WebxdcFeedPage } from "./pages/WebxdcFeedPage"; +import { WorldPage } from "./pages/WorldPage"; - -const pollsDef = getExtraKindDef('polls')!; -const colorsDef = getExtraKindDef('colors')!; -const packsDef = getExtraKindDef('packs')!; -const articlesDef = getExtraKindDef('articles')!; -const decksDef = getExtraKindDef('decks')!; -const emojisDef = getExtraKindDef('emojis')!; +const pollsDef = getExtraKindDef("polls")!; +const colorsDef = getExtraKindDef("colors")!; +const packsDef = getExtraKindDef("packs")!; +const articlesDef = getExtraKindDef("articles")!; +const decksDef = getExtraKindDef("decks")!; +const emojisDef = getExtraKindDef("emojis")!; +const developmentDef = getExtraKindDef("development")!; /** Redirects /profile to the user's canonical profile URL (nip05 or npub). */ function ProfileRedirect() { const { user, metadata } = useCurrentUser(); - const profileUrl = useProfileUrl(user?.pubkey ?? '', metadata); + const profileUrl = useProfileUrl(user?.pubkey ?? "", metadata); if (!user) return ; return ; } - export function AppRouter() { return ( - - - - - - {/* All routes share the persistent MainLayout (sidebar + nav) */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* /streams redirects to /videos for backward compatibility */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + + + {/* All routes share the persistent MainLayout (sidebar + nav) */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* /streams redirects to /videos for backward compatibility */} + } + /> + } /> + } /> + } /> + + } + /> + } /> + + } + /> + + } + /> + } /> + + } + /> + + } + /> + + } + /> + + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } /> - {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} - } /> - {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} - } /> - - - + {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} + } /> + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} + } /> + + + ); } diff --git a/src/components/CustomNipCard.tsx b/src/components/CustomNipCard.tsx new file mode 100644 index 00000000..1d5abd39 --- /dev/null +++ b/src/components/CustomNipCard.tsx @@ -0,0 +1,145 @@ +import type { NostrEvent } from "@nostrify/nostrify"; +import { FileCode, Wand2 } from "lucide-react"; +import Markdown from "react-markdown"; +import rehypeSanitize from "rehype-sanitize"; +import { Badge } from "@/components/ui/badge"; + +interface CustomNipCardProps { + event: NostrEvent; + /** If true, show a truncated preview instead of the full NIP content. Defaults to true. */ + preview?: boolean; +} + +/** Extracts the first meaningful paragraph from markdown content. */ +function extractFirstParagraph( + content: string, + maxLength: number = 200, +): string { + if (!content) return ""; + + const lines = content + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + + for (const line of lines) { + // Skip markdown headers, rules, code fences, lists, blockquotes + if (line.startsWith("#")) continue; + if (line.match(/^[-*_]{3,}$/)) continue; + if (line.startsWith("```")) continue; + if (line.match(/^[-*+]\s/)) continue; + if (line.match(/^\d+\.\s/)) continue; + if (line.startsWith(">")) continue; + + if (line.length > 10) { + const cleaned = line + .replace(/\*\*(.*?)\*\*/g, "$1") + .replace(/\*(.*?)\*/g, "$1") + .replace(/`(.*?)`/g, "$1") + .replace(/\[(.*?)\]\(.*?\)/g, "$1") + .trim(); + + if (cleaned.length > maxLength) { + const truncated = cleaned.slice(0, maxLength); + const lastSpace = truncated.lastIndexOf(" "); + return `${ + lastSpace > maxLength * 0.7 + ? truncated.slice(0, lastSpace) + : truncated + }...`; + } + return cleaned; + } + } + + const fallback = content.replace(/\n/g, " ").trim(); + return fallback.length > maxLength + ? `${fallback.slice(0, maxLength).trim()}...` + : fallback; +} + +/** Renders a NostrHub kind 30817 custom NIP proposal card (NostrHub-style). */ +export function CustomNipCard({ event, preview = true }: CustomNipCardProps) { + const title = event.tags.find(([n]) => n === "title")?.[1]; + const dTag = event.tags.find(([n]) => n === "d")?.[1] ?? ""; + const relatedKinds = event.tags.filter(([n]) => n === "k").map(([, v]) => v); + const hasShakespeare = event.tags.some( + ([n, v]) => n === "t" && v === "shakespeare", + ); + const contentPreview = preview + ? extractFirstParagraph(event.content, 200) + : ""; + + const displayTitle = title || `NIP: ${dTag}`; + + return ( +
+ {/* Title */} +
+ +
+ + {displayTitle} + + {preview && contentPreview && ( +

+ {contentPreview} +

+ )} +
+
+ + {/* Full markdown content — detail view only */} + {!preview && event.content && ( +
+ {event.content} +
+ )} + + {/* Related Kinds section */} + {relatedKinds.length > 0 && ( +
+

Kinds

+
+ {relatedKinds + .slice(0, preview ? 6 : relatedKinds.length) + .map((k) => ( + + Kind {k} + + ))} + {preview && relatedKinds.length > 6 && ( + + +{relatedKinds.length - 6} more + + )} +
+
+ )} + + {/* Action buttons */} + {hasShakespeare && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/GitRepoCard.tsx b/src/components/GitRepoCard.tsx new file mode 100644 index 00000000..06de8fe2 --- /dev/null +++ b/src/components/GitRepoCard.tsx @@ -0,0 +1,189 @@ +import type { NostrEvent } from "@nostrify/nostrify"; +import { Copy, ExternalLink, GitBranch, Globe, Wand2 } from "lucide-react"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +interface GitRepoCardProps { + event: NostrEvent; +} + +/** Derive a favicon URL from a website URL. */ +function getFaviconUrl(webUrl: string): string | undefined { + try { + const origin = new URL(webUrl).origin; + return `${origin}/favicon.ico`; + } catch { + return undefined; + } +} + +/** Renders a NIP-34 kind 30617 event. Shakespeare apps show as app cards; others as repo cards. */ +export function GitRepoCard({ event }: GitRepoCardProps) { + const name = event.tags.find(([n]) => n === "name")?.[1]; + const description = event.tags.find(([n]) => n === "description")?.[1]; + const webUrls = event.tags.filter(([n]) => n === "web").map(([, v]) => v); + const cloneUrls = event.tags.filter(([n]) => n === "clone").map(([, v]) => v); + const hashtags = event.tags + .filter(([n]) => n === "t") + .map(([, v]) => v) + .filter((t) => t !== "personal-fork" && t !== "shakespeare"); + const isPersonalFork = event.tags.some( + ([n, v]) => n === "t" && v === "personal-fork", + ); + const hasShakespeare = event.tags.some( + ([n, v]) => n === "t" && v === "shakespeare", + ); + const dTag = event.tags.find(([n]) => n === "d")?.[1] ?? ""; + + // Shakespeare + web URL = this is a deployed application, not a repo + const isApp = hasShakespeare && !!webUrls[0]; + const faviconUrl = isApp ? getFaviconUrl(webUrls[0]) : undefined; + + const displayName = name || dTag; + + const [faviconError, setFaviconError] = useState(false); + + const handleCopy = (url: string) => { + navigator.clipboard.writeText(url); + }; + + const shakespeareUrl = cloneUrls[0] + ? `https://shakespeare.diy/clone?url=${encodeURIComponent(cloneUrls[0])}` + : "https://shakespeare.diy"; + + return ( +
+ {/* Header: icon/favicon + title */} +
+ {isApp && faviconUrl && !faviconError ? ( + setFaviconError(true)} + /> + ) : ( + + )} +
+
+ + {displayName} + + {isPersonalFork && ( + + Fork + + )} +
+ {description && ( +

+ {description} +

+ )} +
+
+ + {/* Tags section */} + {hashtags.length > 0 && ( +
+

Tags

+
+ {hashtags.slice(0, 6).map((tag) => ( + + {tag} + + ))} + {hashtags.length > 6 && ( + + +{hashtags.length - 6} more + + )} +
+
+ )} + + {/* Clone URL section — hidden for apps */} + {!isApp && cloneUrls[0] && ( +
+

Clone

+
+ + {cloneUrls[0]} + + +
+
+ )} + + {/* Action buttons */} +
+ {hasShakespeare && ( + + )} + {isApp ? ( + + ) : webUrls[0] ? ( + + ) : !hasShakespeare && cloneUrls[0] ? ( + + ) : null} +
+
+ ); +} diff --git a/src/components/InitialSyncGate.tsx b/src/components/InitialSyncGate.tsx index 517990ce..8d795d70 100644 --- a/src/components/InitialSyncGate.tsx +++ b/src/components/InitialSyncGate.tsx @@ -1,43 +1,48 @@ -import { type ReactNode, useState, useCallback, useMemo, useEffect, useRef } from 'react'; -import { IntroImage } from '@/components/IntroImage'; -import { nip19, generateSecretKey, getPublicKey } from 'nostr-tools'; -import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; -import { useNostr } from '@nostrify/react'; -import { ProfileCard } from '@/components/ProfileCard'; -import { ImageCropDialog } from '@/components/ImageCropDialog'; -import { DittoLogo } from '@/components/DittoLogo'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; -import { Skeleton } from '@/components/ui/skeleton'; -import { useAppContext } from '@/hooks/useAppContext'; -import { useTheme } from '@/hooks/useTheme'; -import { ThemeGrid } from '@/components/ThemeSelector'; -import { useInitialSync, type SyncPhase } from '@/hooks/useInitialSync'; -import { useEncryptedSettings } from '@/hooks/useEncryptedSettings'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useNostrPublish } from '@/hooks/useNostrPublish'; -import { useLoginActions } from '@/hooks/useLoginActions'; -import { useUploadFile } from '@/hooks/useUploadFile'; -import { useQueryClient } from '@tanstack/react-query'; -import { useAuthors } from '@/hooks/useAuthors'; -import { genUserName } from '@/lib/genUserName'; -import { toast } from '@/hooks/useToast'; -import { cn } from '@/lib/utils'; +import type { NostrEvent, NostrMetadata } from "@nostrify/nostrify"; +import { useNostr } from "@nostrify/react"; +import { useQueryClient } from "@tanstack/react-query"; import { Check, ChevronRight, + Download, Eye, EyeOff, - Users, - UserPlus, - Loader2, Heart, - Download, -} from 'lucide-react'; - - -import { OnboardingContext } from '@/hooks/useOnboarding'; + Loader2, + UserPlus, + Users, +} from "lucide-react"; +import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { DittoLogo } from "@/components/DittoLogo"; +import { ImageCropDialog } from "@/components/ImageCropDialog"; +import { IntroImage } from "@/components/IntroImage"; +import { ProfileCard } from "@/components/ProfileCard"; +import { ThemeGrid } from "@/components/ThemeSelector"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAppContext } from "@/hooks/useAppContext"; +import { useAuthors } from "@/hooks/useAuthors"; +import { useCurrentUser } from "@/hooks/useCurrentUser"; +import { useEncryptedSettings } from "@/hooks/useEncryptedSettings"; +import { type SyncPhase, useInitialSync } from "@/hooks/useInitialSync"; +import { useLoginActions } from "@/hooks/useLoginActions"; +import { useNostrPublish } from "@/hooks/useNostrPublish"; +import { OnboardingContext } from "@/hooks/useOnboarding"; +import { useTheme } from "@/hooks/useTheme"; +import { toast } from "@/hooks/useToast"; +import { useUploadFile } from "@/hooks/useUploadFile"; +import { genUserName } from "@/lib/genUserName"; +import { cn } from "@/lib/utils"; // --------------------------------------------------------------------------- // InitialSyncGate @@ -93,7 +98,7 @@ export function InitialSyncGate({ children }: InitialSyncGateProps) { } // Normal logged-in sync flow - if (phase === 'syncing' || phase === 'found') { + if (phase === "syncing" || phase === "found") { return ( @@ -101,7 +106,7 @@ export function InitialSyncGate({ children }: InitialSyncGateProps) { ); } - if (phase === 'not-found') { + if (phase === "not-found") { return ( {preloadApp &&
{children}
} @@ -144,17 +149,19 @@ function SyncScreen({ phase }: { phase: SyncPhase }) {

- {phase === 'found' ? 'Settings restored' : 'Syncing your settings...'} + {phase === "found" + ? "Settings restored" + : "Syncing your settings..."}

- {phase === 'found' - ? 'Welcome back! Loading your experience...' - : 'Checking for your preferences across devices'} + {phase === "found" + ? "Welcome back! Loading your experience..." + : "Checking for your preferences across devices"}

- {phase === 'syncing' && ( + {phase === "syncing" && (
{[0, 1, 2].map((i) => (
)} - {phase === 'found' && ( + {phase === "found" && (
All set @@ -182,19 +189,36 @@ function SyncScreen({ phase }: { phase: SyncPhase }) { // --------------------------------------------------------------------------- /** Suggested follow packs shown to new users with empty follow lists. */ -const SUGGESTED_PACKS: { kind: number; pubkey: string; identifier: string }[] = [ - { kind: 39089, pubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d', identifier: 'k4p5w0n22suf' }, -]; +const SUGGESTED_PACKS: { kind: number; pubkey: string; identifier: string }[] = + [ + { + kind: 39089, + pubkey: + "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d", + identifier: "k4p5w0n22suf", + }, + ]; // Steps for signup (includes keygen + profile) vs. settings-only (existing login) -type SignupStep = 'keygen' | 'download' | 'profile'; -type SettingsStep = 'theme' | 'follows' | 'outro'; +type SignupStep = "keygen" | "download" | "profile"; +type SettingsStep = "theme" | "follows" | "outro"; type Step = SignupStep | SettingsStep; -const SIGNUP_STEPS: Step[] = ['theme', 'keygen', 'download', 'profile', 'follows', 'outro']; -const SETTINGS_STEPS: Step[] = ['theme', 'follows', 'outro']; +const SIGNUP_STEPS: Step[] = [ + "theme", + "keygen", + "download", + "profile", + "follows", + "outro", +]; +const SETTINGS_STEPS: Step[] = ["theme", "follows", "outro"]; -function SetupQuestionnaire({ onComplete, onPreload, isSignup = false }: { +function SetupQuestionnaire({ + onComplete, + onPreload, + isSignup = false, +}: { onComplete: () => void; onPreload: () => void; isSignup?: boolean; @@ -212,10 +236,10 @@ function SetupQuestionnaire({ onComplete, onPreload, isSignup = false }: { const [hasFollows, setHasFollows] = useState(null); // Signup-specific state - const [nsec, setNsec] = useState(''); + const [nsec, setNsec] = useState(""); const stepIndex = steps.indexOf(step); - const progress = ((stepIndex) / (steps.length - 1)) * 100; + const progress = (stepIndex / (steps.length - 1)) * 100; const goTo = useCallback((target: Step) => setStep(target), []); @@ -233,8 +257,6 @@ function SetupQuestionnaire({ onComplete, onPreload, isSignup = false }: { } }, [step, steps]); - - // Keygen handler const handleGenerate = useCallback(() => { const sk = generateSecretKey(); @@ -247,18 +269,18 @@ function SetupQuestionnaire({ onComplete, onPreload, isSignup = false }: { const handleDownloadAndLogin = useCallback(() => { try { const decoded = nip19.decode(nsec); - if (decoded.type !== 'nsec') throw new Error('Invalid nsec'); + if (decoded.type !== "nsec") throw new Error("Invalid nsec"); const pubkey = getPublicKey(decoded.data); const npub = nip19.npubEncode(pubkey); - const filename = `nostr-${location.hostname.replaceAll(/\./g, '-')}-${npub.slice(5, 9)}.nsec.txt`; + const filename = `nostr-${location.hostname.replaceAll(/\./g, "-")}-${npub.slice(5, 9)}.nsec.txt`; - const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' }); + const blob = new Blob([nsec], { type: "text/plain; charset=utf-8" }); const url = globalThis.URL.createObjectURL(blob); - const a = document.createElement('a'); + const a = document.createElement("a"); a.href = url; a.download = filename; - a.style.display = 'none'; + a.style.display = "none"; document.body.appendChild(a); a.click(); globalThis.URL.revokeObjectURL(url); @@ -269,9 +291,10 @@ function SetupQuestionnaire({ onComplete, onPreload, isSignup = false }: { next(); } catch { toast({ - title: 'Download failed', - description: 'Could not download the key file. Please copy it manually.', - variant: 'destructive', + title: "Download failed", + description: + "Could not download the key file. Please copy it manually.", + variant: "destructive", }); } }, [nsec, login, next]); @@ -327,6 +350,8 @@ function SetupQuestionnaire({ onComplete, onPreload, isSignup = false }: { showPodcasts: false, feedIncludePodcastEpisodes: false, feedIncludePodcastTrailers: false, + showDevelopment: false, + feedIncludeDevelopment: false, showBadges: false, showBadgeDefinitions: true, showProfileBadges: true, @@ -338,17 +363,17 @@ function SetupQuestionnaire({ onComplete, onPreload, isSignup = false }: { updateConfig((current) => ({ ...current, feedSettings, - contentWarningPolicy: 'blur', + contentWarningPolicy: "blur", })); if (user?.signer.nip44) { try { await updateSettings.mutateAsync({ feedSettings, - contentWarningPolicy: 'blur', + contentWarningPolicy: "blur", }); } catch (error) { - console.warn('Failed to save initial settings to Nostr:', error); + console.warn("Failed to save initial settings to Nostr:", error); } } @@ -361,7 +386,7 @@ function SetupQuestionnaire({ onComplete, onPreload, isSignup = false }: { { signal: AbortSignal.timeout(5000) }, ); if (events.length > 0) { - const pTags = events[0].tags.filter(([n]) => n === 'p'); + const pTags = events[0].tags.filter(([n]) => n === "p"); userHasFollows = pTags.length > 0; } } catch { @@ -373,9 +398,9 @@ function SetupQuestionnaire({ onComplete, onPreload, isSignup = false }: { setIsSaving(false); if (userHasFollows) { - goTo('outro'); + goTo("outro"); } else { - goTo('follows'); + goTo("follows"); } }, [updateConfig, updateSettings, user, nostr, goTo]); @@ -393,41 +418,37 @@ function SetupQuestionnaire({ onComplete, onPreload, isSignup = false }: {
{/* Signup steps */} - {step === 'keygen' && ( - - )} + {step === "keygen" && } - {step === 'download' && ( + {step === "download" && ( )} - {step === 'profile' && ( + {step === "profile" && ( )} {/* Settings steps */} - {step === 'theme' && ( + {step === "theme" && ( )} - {step === 'follows' && hasFollows === false && ( + {step === "follows" && hasFollows === false && ( { if (didFollow) onPreload(); - goTo('outro'); + goTo("outro"); }} onBack={back} /> )} - {step === 'outro' && ( - - )} + {step === "outro" && }
@@ -448,7 +469,8 @@ function KeygenStep({ onGenerate }: { onGenerate: () => void }) { Create your account

- Your identity on Nostr is a cryptographic key pair. We'll generate one for you now. + Your identity on Nostr is a cryptographic key pair. We'll generate one + for you now.

@@ -464,21 +486,30 @@ function KeygenStep({ onGenerate }: { onGenerate: () => void }) { ); } -function DownloadStep({ nsec, onDownload }: { nsec: string; onDownload: () => void }) { +function DownloadStep({ + nsec, + onDownload, +}: { + nsec: string; + onDownload: () => void; +}) { const [showKey, setShowKey] = useState(false); return (
-

Save your secret key

+

+ Save your secret key +

- This is your only way to access your account. Download it and keep it somewhere safe. + This is your only way to access your account. Download it and keep it + somewhere safe.

vo Important

- This key is your only means of accessing your account. If you lose it, there is no way to recover it. Download it now to continue. + This key is your only means of accessing your account. If you lose it, + there is no way to recover it. Download it now to continue.

@@ -519,46 +551,77 @@ function DownloadStep({ nsec, onDownload }: { nsec: string; onDownload: () => vo ); } -function ProfileStep({ onNext, isSaving = false }: { onNext: () => void; isSaving?: boolean }) { +function ProfileStep({ + onNext, + isSaving = false, +}: { + onNext: () => void; + isSaving?: boolean; +}) { const { user } = useCurrentUser(); const queryClient = useQueryClient(); - const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); + const { mutateAsync: publishEvent, isPending: isPublishing } = + useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const pickInputRef = useRef(null); - const pendingField = useRef<'picture' | 'banner'>('picture'); + const pendingField = useRef<"picture" | "banner">("picture"); const [profileData, setProfileData] = useState>({ - name: '', about: '', picture: '', banner: '', website: '', + name: "", + about: "", + picture: "", + banner: "", + website: "", }); - const [extraFields, setExtraFields] = useState>([]); - const [cropState, setCropState] = useState<{ imageSrc: string; aspect: number; field: 'picture' | 'banner' } | null>(null); + const [extraFields, setExtraFields] = useState< + Array<{ label: string; value: string }> + >([]); + const [cropState, setCropState] = useState<{ + imageSrc: string; + aspect: number; + field: "picture" | "banner"; + } | null>(null); - const handlePickImage = useCallback((field: 'picture' | 'banner') => { + const handlePickImage = useCallback((field: "picture" | "banner") => { pendingField.current = field; pickInputRef.current?.click(); }, []); - const handleFileChosen = useCallback((e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - e.target.value = ''; - const field = pendingField.current; - setCropState({ imageSrc: URL.createObjectURL(file), aspect: field === 'picture' ? 1 : 3, field }); - }, []); + const handleFileChosen = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ""; + const field = pendingField.current; + setCropState({ + imageSrc: URL.createObjectURL(file), + aspect: field === "picture" ? 1 : 3, + field, + }); + }, + [], + ); - const handleCropConfirm = useCallback(async (blob: Blob) => { - if (!cropState) return; - const { field, imageSrc } = cropState; - URL.revokeObjectURL(imageSrc); - setCropState(null); - try { - const file = new File([blob], `${field}.jpg`, { type: 'image/jpeg' }); - const [[, url]] = await uploadFile(file); - setProfileData((prev) => ({ ...prev, [field]: url })); - } catch { - toast({ title: 'Upload failed', description: 'Please try again.', variant: 'destructive' }); - } - }, [cropState, uploadFile]); + const handleCropConfirm = useCallback( + async (blob: Blob) => { + if (!cropState) return; + const { field, imageSrc } = cropState; + URL.revokeObjectURL(imageSrc); + setCropState(null); + try { + const file = new File([blob], `${field}.jpg`, { type: "image/jpeg" }); + const [[, url]] = await uploadFile(file); + setProfileData((prev) => ({ ...prev, [field]: url })); + } catch { + toast({ + title: "Upload failed", + description: "Please try again.", + variant: "destructive", + }); + } + }, + [cropState, uploadFile], + ); const handleCropCancel = useCallback(() => { if (cropState) URL.revokeObjectURL(cropState.imageSrc); @@ -567,17 +630,26 @@ function ProfileStep({ onNext, isSaving = false }: { onNext: () => void; isSavin const handlePublishProfile = useCallback(async () => { if (!user) return; - const hasData = Object.values(profileData).some((v) => v) || extraFields.length > 0; + const hasData = + Object.values(profileData).some((v) => v) || extraFields.length > 0; if (hasData) { try { const data: Record = { ...profileData }; - const validFields = extraFields.filter((f) => f.label.trim() && f.value.trim()); - if (validFields.length > 0) data.fields = validFields.map((f) => [f.label, f.value]); + const validFields = extraFields.filter( + (f) => f.label.trim() && f.value.trim(), + ); + if (validFields.length > 0) + data.fields = validFields.map((f) => [f.label, f.value]); await publishEvent({ kind: 0, content: JSON.stringify(data) }); - queryClient.invalidateQueries({ queryKey: ['logins'] }); - queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] }); + queryClient.invalidateQueries({ queryKey: ["logins"] }); + queryClient.invalidateQueries({ queryKey: ["author", user.pubkey] }); } catch { - toast({ title: 'Profile failed', description: 'Your account was created but profile setup failed. You can update it later.', variant: 'destructive' }); + toast({ + title: "Profile failed", + description: + "Your account was created but profile setup failed. You can update it later.", + variant: "destructive", + }); } } onNext(); @@ -588,29 +660,43 @@ function ProfileStep({ onNext, isSaving = false }: { onNext: () => void; isSavin
-

Set up your profile

+

+ Set up your profile +

Tell people a bit about yourself. You can always change this later.

- + {cropState && ( )} -
+
setProfileData((prev) => ({ ...prev, ...patch }))} + onChange={(patch) => + setProfileData((prev) => ({ ...prev, ...patch })) + } onPickImage={handlePickImage} showNip05={false} extraFields={extraFields} @@ -625,11 +711,28 @@ function ProfileStep({ onNext, isSaving = false }: { onNext: () => void; isSavin )}
- -
@@ -665,36 +768,65 @@ function ThemeStep({ )} {/* Center content — semi-transparent on desktop when bg is active */} -
+
-

Choose your look

-

Pick a theme that feels right.

+

+ Choose your look +

+

+ Pick a theme that feels right. +

{isFirst ? ( - ) : (
- -
@@ -711,15 +843,21 @@ function ThemeStep({ /** Parse a follow pack event into structured data. */ function parsePackEvent(event: NostrEvent) { const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1]; - const title = getTag('title') || getTag('name') || 'Untitled Pack'; - const description = getTag('description') || getTag('summary') || ''; - const image = getTag('image') || getTag('thumb') || getTag('banner'); - const pubkeys = event.tags.filter(([n]) => n === 'p').map(([, pk]) => pk); + const title = getTag("title") || getTag("name") || "Untitled Pack"; + const description = getTag("description") || getTag("summary") || ""; + const image = getTag("image") || getTag("thumb") || getTag("banner"); + const pubkeys = event.tags.filter(([n]) => n === "p").map(([, pk]) => pk); return { title, description, image, pubkeys }; } -function FollowsStep({ onNext, onBack }: { onNext: (didFollow: boolean) => void; onBack: () => void }) { +function FollowsStep({ + onNext, + onBack, +}: { + onNext: (didFollow: boolean) => void; + onBack: () => void; +}) { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { mutateAsync: publishEvent } = useNostrPublish(); @@ -738,70 +876,88 @@ function FollowsStep({ onNext, onBack }: { onNext: (didFollow: boolean) => void; const filters = SUGGESTED_PACKS.map((p) => ({ kinds: [p.kind], authors: [p.pubkey], - '#d': [p.identifier], + "#d": [p.identifier], limit: 1, })); - const events = await nostr.query(filters, { signal: AbortSignal.timeout(8000) }); + const events = await nostr.query(filters, { + signal: AbortSignal.timeout(8000), + }); if (!cancelled) { setPacks(events); } } catch (error) { - console.warn('Failed to fetch suggested follow packs:', error); + console.warn("Failed to fetch suggested follow packs:", error); } finally { if (!cancelled) setLoading(false); } }; fetchPacks(); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [nostr]); - const handleFollowAll = useCallback(async (pack: NostrEvent) => { - if (!user) return; + const handleFollowAll = useCallback( + async (pack: NostrEvent) => { + if (!user) return; - const packId = pack.id; - setFollowingPack(packId); + const packId = pack.id; + setFollowingPack(packId); - try { - const packPubkeys = pack.tags.filter(([n]) => n === 'p').map(([, pk]) => pk); + try { + const packPubkeys = pack.tags + .filter(([n]) => n === "p") + .map(([, pk]) => pk); - // Fetch current follow list - const followEvents: NostrEvent[] = await nostr.query( - [{ kinds: [3], authors: [user.pubkey], limit: 1 }], - { signal: AbortSignal.timeout(10_000) }, - ).catch((): NostrEvent[] => []); + // Fetch current follow list + const followEvents: NostrEvent[] = await nostr + .query([{ kinds: [3], authors: [user.pubkey], limit: 1 }], { + signal: AbortSignal.timeout(10_000), + }) + .catch((): NostrEvent[] => []); - const latestEvent = followEvents.length > 0 - ? followEvents.reduce((latest, current) => current.created_at > latest.created_at ? current : latest) - : null; + const latestEvent = + followEvents.length > 0 + ? followEvents.reduce((latest, current) => + current.created_at > latest.created_at ? current : latest, + ) + : null; - const existingFollows = latestEvent - ? latestEvent.tags.filter(([name]) => name === 'p').map(([, pk]) => pk) - : []; + const existingFollows = latestEvent + ? latestEvent.tags + .filter(([name]) => name === "p") + .map(([, pk]) => pk) + : []; - const allFollows = [...new Set([...existingFollows, ...packPubkeys])]; + const allFollows = [...new Set([...existingFollows, ...packPubkeys])]; - await publishEvent({ - kind: 3, - content: latestEvent?.content ?? '', - tags: allFollows.map((pk) => ['p', pk]), - }); + await publishEvent({ + kind: 3, + content: latestEvent?.content ?? "", + tags: allFollows.map((pk) => ["p", pk]), + }); - setFollowedPacks((prev) => new Set([...prev, packId])); - } catch (error) { - console.error('Failed to follow pack:', error); - } finally { - setFollowingPack(null); - } - }, [user, nostr, publishEvent]); + setFollowedPacks((prev) => new Set([...prev, packId])); + } catch (error) { + console.error("Failed to follow pack:", error); + } finally { + setFollowingPack(null); + } + }, + [user, nostr, publishEvent], + ); return (
-

Find your people

+

+ Find your people +

- Your feed is empty! Follow some people to get started. Here are some curated packs to help you find interesting voices. + Your feed is empty! Follow some people to get started. Here are some + curated packs to help you find interesting voices.

@@ -812,7 +968,8 @@ function FollowsStep({ onNext, onBack }: { onNext: (didFollow: boolean) => void; )) ) : packs.length === 0 ? (

- Couldn't load suggestions right now. You can find follow packs later in the app. + Couldn't load suggestions right now. You can find follow packs later + in the app.

) : ( packs.map((pack) => ( @@ -839,7 +996,7 @@ function FollowsStep({ onNext, onBack }: { onNext: (didFollow: boolean) => void; onClick={() => onNext(followedPacks.size > 0)} className="flex-1 rounded-full h-11 gap-1.5" > - {followedPacks.size > 0 ? 'Continue' : 'Skip for now'} + {followedPacks.size > 0 ? "Continue" : "Skip for now"}
@@ -859,7 +1016,10 @@ function PackCard({ isFollowing: boolean; onFollowAll: () => void; }) { - const { title, description, pubkeys } = useMemo(() => parsePackEvent(event), [event]); + const { title, description, pubkeys } = useMemo( + () => parsePackEvent(event), + [event], + ); // Show first 6 member avatars const previewPubkeys = useMemo(() => pubkeys.slice(0, 6), [pubkeys]); @@ -873,7 +1033,9 @@ function PackCard({

{title}

{description && ( -

{description}

+

+ {description} +

)}
@@ -908,7 +1070,7 @@ function PackCard({
{Array.from({ length: 6 }).map((_, i) => ( - + ))}
@@ -1000,12 +1165,10 @@ function OutroStep({ onComplete }: { onComplete: () => void }) {
-

- You're all set -

+

You're all set

- That's it! Go find something wonderful, share something fun, - and make yourself at home. + That's it! Go find something wonderful, share something fun, and make + yourself at home.

@@ -1028,7 +1191,7 @@ function OutroStep({ onComplete }: { onComplete: () => void }) { function _StepNav({ onBack, onNext, - nextLabel = 'Continue', + nextLabel = "Continue", }: { onBack: () => void; onNext: () => void; @@ -1043,10 +1206,7 @@ function _StepNav({ > Back - diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index e2d03371..169adbc9 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -1,65 +1,87 @@ -import { Link } from 'react-router-dom'; -import { MessageCircle, Zap, MoreHorizontal, Share2, Play, Radio, Users, Palette, SmilePlus, Award } from 'lucide-react'; -import { RepostIcon } from '@/components/icons/RepostIcon'; -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; -import { Badge } from '@/components/ui/badge'; -import { Skeleton } from '@/components/ui/skeleton'; -import { NoteContent } from '@/components/NoteContent'; -import { isSingleImagePost } from '@/lib/noteContent'; -import { ImageGallery } from '@/components/ImageGallery'; -import { VideoPlayer } from '@/components/VideoPlayer'; -import { NoteMedia } from '@/components/NoteMedia'; -import { ReactionButton } from '@/components/ReactionButton'; -import { RepostMenu } from '@/components/RepostMenu'; -import { PollContent } from '@/components/PollContent'; -import { GeocacheContent } from '@/components/GeocacheContent'; -import { FoundLogContent } from '@/components/FoundLogContent'; -import { ColorMomentContent, ColorMomentEyeButton } from '@/components/ColorMomentContent'; -import { FollowPackContent } from '@/components/FollowPackContent'; -import { ArticleContent } from '@/components/ArticleContent'; -import { type ImetaEntry, parseImetaMap } from '@/lib/imeta'; -import { MagicDeckContent } from '@/components/MagicDeckContent'; -import { EmojiPackContent } from '@/components/EmojiPackContent'; -import { BadgeContent } from '@/components/BadgeContent'; -import { ProfileBadgesContent } from '@/components/ProfileBadgesContent'; -import { FileMetadataContent } from '@/components/FileMetadataContent'; -import { LiveStreamPlayer } from '@/components/LiveStreamPlayer'; -import { ChestIcon } from '@/components/icons/ChestIcon'; -import { CardsIcon } from '@/components/icons/CardsIcon'; -import { ReplyContext } from '@/components/ReplyContext'; -import { CommentContext } from '@/components/CommentContext'; -import { Nip05Badge } from '@/components/Nip05Badge'; -import { ProfileHoverCard } from '@/components/ProfileHoverCard'; -import { EmojifiedText, ReactionEmoji } from '@/components/CustomEmoji'; -import { useAuthor } from '@/hooks/useAuthor'; -import { useNip05Verify } from '@/hooks/useNip05Verify'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useEventStats } from '@/hooks/useTrending'; -import { getDisplayName } from '@/lib/getDisplayName'; -import { genUserName } from '@/lib/genUserName'; - -import { useProfileUrl } from '@/hooks/useProfileUrl'; -import { useOpenPost } from '@/hooks/useOpenPost'; -import { timeAgo } from '@/lib/timeAgo'; -import { canZap } from '@/lib/canZap'; -import { cn } from '@/lib/utils'; -import { nip19 } from 'nostr-tools'; -import { useMemo, useState, useRef, useEffect, useCallback } from 'react'; -import type { NostrEvent } from '@nostrify/nostrify'; -import { NoteMoreMenu } from '@/components/NoteMoreMenu'; -import { ReplyComposeModal } from '@/components/ReplyComposeModal'; -import { ZapDialog } from '@/components/ZapDialog'; -import { ContentWarningGuard } from '@/components/ContentWarningGuard'; -import { getContentWarning } from '@/lib/contentWarning'; -import { ThemeContent } from '@/components/ThemeContent'; -import { VoiceMessagePlayer } from '@/components/VoiceMessagePlayer'; -import { MusicTrackContent, MusicPlaylistContent, PodcastEpisodeContent, PodcastTrailerContent } from '@/components/AudioKindContent'; -import { CalendarEventContent } from '@/components/CalendarEventContent'; -import { useAppContext } from '@/hooks/useAppContext'; -import { getParentEventId, isReplyEvent } from '@/lib/nostrEvents'; -import { shareOrCopy } from '@/lib/share'; -import { toast } from '@/hooks/useToast'; -import { extractVideoUrls, extractAudioUrls } from '@/lib/mediaUrls'; +import type { NostrEvent } from "@nostrify/nostrify"; +import { + Award, + MessageCircle, + MoreHorizontal, + Palette, + Play, + Radio, + Share2, + SmilePlus, + Users, + Zap, +} from "lucide-react"; +import { nip19 } from "nostr-tools"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { ArticleContent } from "@/components/ArticleContent"; +import { + MusicPlaylistContent, + MusicTrackContent, + PodcastEpisodeContent, + PodcastTrailerContent, +} from "@/components/AudioKindContent"; +import { BadgeContent } from "@/components/BadgeContent"; +import { CalendarEventContent } from "@/components/CalendarEventContent"; +import { + ColorMomentContent, + ColorMomentEyeButton, +} from "@/components/ColorMomentContent"; +import { CommentContext } from "@/components/CommentContext"; +import { ContentWarningGuard } from "@/components/ContentWarningGuard"; +import { EmojifiedText, ReactionEmoji } from "@/components/CustomEmoji"; +import { CustomNipCard } from "@/components/CustomNipCard"; +import { EmojiPackContent } from "@/components/EmojiPackContent"; +import { FileMetadataContent } from "@/components/FileMetadataContent"; +import { FollowPackContent } from "@/components/FollowPackContent"; +import { FoundLogContent } from "@/components/FoundLogContent"; +import { GeocacheContent } from "@/components/GeocacheContent"; +import { GitRepoCard } from "@/components/GitRepoCard"; +import { ImageGallery } from "@/components/ImageGallery"; +import { CardsIcon } from "@/components/icons/CardsIcon"; +import { ChestIcon } from "@/components/icons/ChestIcon"; +import { RepostIcon } from "@/components/icons/RepostIcon"; +import { LiveStreamPlayer } from "@/components/LiveStreamPlayer"; +import { MagicDeckContent } from "@/components/MagicDeckContent"; +import { Nip05Badge } from "@/components/Nip05Badge"; +import { NoteContent } from "@/components/NoteContent"; +import { NoteMedia } from "@/components/NoteMedia"; +import { NoteMoreMenu } from "@/components/NoteMoreMenu"; +import { PatchCard } from "@/components/PatchCard"; +import { PollContent } from "@/components/PollContent"; +import { ProfileBadgesContent } from "@/components/ProfileBadgesContent"; +import { ProfileHoverCard } from "@/components/ProfileHoverCard"; +import { PullRequestCard } from "@/components/PullRequestCard"; +import { ReactionButton } from "@/components/ReactionButton"; +import { ReplyComposeModal } from "@/components/ReplyComposeModal"; +import { ReplyContext } from "@/components/ReplyContext"; +import { RepostMenu } from "@/components/RepostMenu"; +import { ThemeContent } from "@/components/ThemeContent"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { VideoPlayer } from "@/components/VideoPlayer"; +import { VoiceMessagePlayer } from "@/components/VoiceMessagePlayer"; +import { ZapDialog } from "@/components/ZapDialog"; +import { useAppContext } from "@/hooks/useAppContext"; +import { useAuthor } from "@/hooks/useAuthor"; +import { useCurrentUser } from "@/hooks/useCurrentUser"; +import { useNip05Verify } from "@/hooks/useNip05Verify"; +import { useOpenPost } from "@/hooks/useOpenPost"; +import { useProfileUrl } from "@/hooks/useProfileUrl"; +import { toast } from "@/hooks/useToast"; +import { useEventStats } from "@/hooks/useTrending"; +import { canZap } from "@/lib/canZap"; +import { getContentWarning } from "@/lib/contentWarning"; +import { genUserName } from "@/lib/genUserName"; +import { getDisplayName } from "@/lib/getDisplayName"; +import { type ImetaEntry, parseImetaMap } from "@/lib/imeta"; +import { extractAudioUrls, extractVideoUrls } from "@/lib/mediaUrls"; +import { getParentEventId, isReplyEvent } from "@/lib/nostrEvents"; +import { isSingleImagePost } from "@/lib/noteContent"; +import { shareOrCopy } from "@/lib/share"; +import { timeAgo } from "@/lib/timeAgo"; +import { cn } from "@/lib/utils"; interface NoteCardProps { event: NostrEvent; @@ -76,33 +98,30 @@ interface NoteCardProps { /** Formats a sats amount into a compact human-readable string. */ function formatSats(sats: number): string { - if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; - if (sats >= 1_000) return `${(sats / 1_000).toFixed(1).replace(/\.0$/, '')}K`; + if (sats >= 1_000_000) + return `${(sats / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + if (sats >= 1_000) return `${(sats / 1_000).toFixed(1).replace(/\.0$/, "")}K`; return sats.toString(); } - - /** Gets a tag value by name. */ function getTag(tags: string[][], name: string): string | undefined { return tags.find(([n]) => n === name)?.[1]; } - - /** Parse single imeta tag into structured object (legacy, for kind 34236 vines). */ function parseImeta(tags: string[][]): { url?: string; thumbnail?: string } { - const imetaTag = tags.find(([name]) => name === 'imeta'); + const imetaTag = tags.find(([name]) => name === "imeta"); if (!imetaTag) return {}; const result: Record = {}; for (let i = 1; i < imetaTag.length; i++) { const part = imetaTag[i]; - const spaceIdx = part.indexOf(' '); + const spaceIdx = part.indexOf(" "); if (spaceIdx === -1) continue; const key = part.slice(0, spaceIdx); const value = part.slice(spaceIdx + 1); - if (key === 'url') result.url = value; - else if (key === 'image') result.thumbnail = value; + if (key === "url") result.url = value; + else if (key === "image") result.thumbnail = value; } return result; } @@ -111,30 +130,41 @@ function parseImeta(tags: string[][]): { url?: string; thumbnail?: string } { function encodeEventId(event: NostrEvent): string { // Addressable events use naddr if (event.kind >= 30000 && event.kind < 40000) { - const dTag = getTag(event.tags, 'd'); + const dTag = getTag(event.tags, "d"); if (dTag) { - return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag }); + return nip19.naddrEncode({ + kind: event.kind, + pubkey: event.pubkey, + identifier: dTag, + }); } } return nip19.neventEncode({ id: event.id, author: event.pubkey }); } /** d-tags reserved by NIP-51 for other purposes — hide these kind 30000 events. */ -const DEPRECATED_DTAGS = new Set(['mute', 'pin', 'bookmark', 'communities']); +const DEPRECATED_DTAGS = new Set(["mute", "pin", "bookmark", "communities"]); /** Returns true if a kind 30000 event is a deprecated/junk list that should be hidden. */ function isDeprecatedFollowSet(event: NostrEvent): boolean { if (event.kind !== 30000) return false; - const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? ''; + const dTag = event.tags.find(([n]) => n === "d")?.[1] ?? ""; if (DEPRECATED_DTAGS.has(dTag)) return true; // Filter empty lists with no p-tags or title - const hasPTags = event.tags.some(([n]) => n === 'p'); - const hasTitle = event.tags.some(([n]) => n === 'title' || n === 'name'); + const hasPTags = event.tags.some(([n]) => n === "p"); + const hasTitle = event.tags.some(([n]) => n === "title" || n === "name"); if (!hasPTags && !hasTitle) return true; return false; } -export function NoteCard({ event, className, repostedBy, compact, threaded, threadedLast }: NoteCardProps) { +export function NoteCard({ + event, + className, + repostedBy, + compact, + threaded, + threadedLast, +}: NoteCardProps) { const { config } = useAppContext(); const { user } = useCurrentUser(); const author = useAuthor(event.pubkey); @@ -142,7 +172,10 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre const metadata = author.data?.metadata; const displayName = getDisplayName(metadata, event.pubkey); const nip05 = metadata?.nip05; - const { data: nip05Verified, isPending: nip05Pending } = useNip05Verify(nip05, event.pubkey); + const { data: nip05Verified, isPending: nip05Pending } = useNip05Verify( + nip05, + event.pubkey, + ); const profileUrl = useProfileUrl(event.pubkey, metadata); const encodedId = useMemo(() => encodeEventId(event), [event]); const { data: stats } = useEventStats(event.id); @@ -152,17 +185,19 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre // Check if the current user can zap this event's author const canZapAuthor = user && canZap(metadata); - const { onClick: openPost, onAuxClick: auxOpenPost } = useOpenPost(`/${encodedId}`); + const { onClick: openPost, onAuxClick: auxOpenPost } = useOpenPost( + `/${encodedId}`, + ); // Handler to navigate to post detail, but only if click didn't originate from a modal const handleCardClick = (e: React.MouseEvent) => { const target = e.target as HTMLElement; if ( target.closest('[role="dialog"]') || - target.closest('[data-radix-dialog-overlay]') || - target.closest('[data-radix-dialog-content]') || - target.closest('[data-vaul-drawer]') || - target.closest('[data-vaul-drawer-overlay]') || + target.closest("[data-radix-dialog-overlay]") || + target.closest("[data-radix-dialog-content]") || + target.closest("[data-vaul-drawer]") || + target.closest("[data-vaul-drawer-overlay]") || target.closest('[data-testid="zap-modal"]') ) { return; @@ -174,10 +209,10 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre const target = e.target as HTMLElement; if ( target.closest('[role="dialog"]') || - target.closest('[data-radix-dialog-overlay]') || - target.closest('[data-radix-dialog-content]') || - target.closest('[data-vaul-drawer]') || - target.closest('[data-vaul-drawer-overlay]') || + target.closest("[data-radix-dialog-overlay]") || + target.closest("[data-radix-dialog-content]") || + target.closest("[data-vaul-drawer]") || + target.closest("[data-vaul-drawer-overlay]") || target.closest('[data-testid="zap-modal"]') ) { return; @@ -213,58 +248,101 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre const isMusicPlaylist = event.kind === 34139; const isPodcastEpisode = event.kind === 30054; const isPodcastTrailer = event.kind === 30055; - const isAudioKind = isMusicTrack || isMusicPlaylist || isPodcastEpisode || isPodcastTrailer; - const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle && !isMagicDeck && !isStream && !isFileMetadata && !isTheme && !isVoiceMessage && !isCalendarEvent && !isEmojiPack && !isBadge && !isReaction && !isPhoto && !isVideo && !isAudioKind; + const isAudioKind = + isMusicTrack || isMusicPlaylist || isPodcastEpisode || isPodcastTrailer; + const isGitRepo = event.kind === 30617; + const isPatch = event.kind === 1617; + const isPullRequest = event.kind === 1618; + const isCustomNip = event.kind === 30817; + const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip; + const isTextNote = + !isVine && + !isPoll && + !isGeocache && + !isFoundLog && + !isColor && + !isFollowPack && + !isArticle && + !isMagicDeck && + !isStream && + !isFileMetadata && + !isTheme && + !isVoiceMessage && + !isCalendarEvent && + !isEmojiPack && + !isBadge && + !isReaction && + !isPhoto && + !isVideo && + !isAudioKind && + !isDevKind; // Kind 1 specific — images now render inline in NoteContent, only videos go to NoteMedia - const videos = useMemo(() => isTextNote ? extractVideoUrls(event.content) : [], [event.content, isTextNote]); + const videos = useMemo( + () => (isTextNote ? extractVideoUrls(event.content) : []), + [event.content, isTextNote], + ); const audios = useMemo(() => { if (!isTextNote) return []; // Prefer imeta-declared audio over URL scraping const imetaAudios = Array.from(parseImetaMap(event.tags).values()) - .filter((e) => e.mime?.startsWith('audio/')) + .filter((e) => e.mime?.startsWith("audio/")) .map((e) => e.url); if (imetaAudios.length > 0) return imetaAudios; return extractAudioUrls(event.content); }, [event.content, event.tags, isTextNote]); - const imetaMap = useMemo(() => isTextNote ? parseImetaMap(event.tags) : new Map(), [event.tags, isTextNote]); + const imetaMap = useMemo( + () => + isTextNote ? parseImetaMap(event.tags) : new Map(), + [event.tags, isTextNote], + ); // Extract webxdc attachments from imeta tags const webxdcApps = useMemo(() => { if (!isTextNote) return []; return Array.from(imetaMap.values()).filter( - (entry) => entry.mime === 'application/x-webxdc' || entry.mime === 'application/vnd.webxdc+zip', + (entry) => + entry.mime === "application/x-webxdc" || + entry.mime === "application/vnd.webxdc+zip", ); }, [imetaMap, isTextNote]); const isComment = event.kind === 1111; const isReply = isTextNote && !isComment && isReplyEvent(event); - + // Find all people being replied to (for "Replying to @user1 and @user2") const replyToPubkeys = useMemo(() => { if (!isTextNote || !isReply) return []; - + // Get all p tags that aren't marked as mentions - const pTags = event.tags.filter(([name, , , marker]) => name === 'p' && marker !== 'mention'); - + const pTags = event.tags.filter( + ([name, , , marker]) => name === "p" && marker !== "mention", + ); + if (pTags.length > 0) { // Remove duplicates and filter out undefined/empty pubkeys - return [...new Set(pTags.map(([, pubkey]) => pubkey).filter(Boolean))] as string[]; + return [ + ...new Set(pTags.map(([, pubkey]) => pubkey).filter(Boolean)), + ] as string[]; } - + // Fallback: if all p tags are mentions, use all p tags anyway - const allPTags = event.tags.filter(([name]) => name === 'p'); + const allPTags = event.tags.filter(([name]) => name === "p"); if (allPTags.length > 0) { - return [...new Set(allPTags.map(([, pubkey]) => pubkey).filter(Boolean))] as string[]; + return [ + ...new Set(allPTags.map(([, pubkey]) => pubkey).filter(Boolean)), + ] as string[]; } // Self-reply fallback: when replying to own post, no p tags are added (the // author's own pubkey is excluded during compose). Try to extract the parent // author from the reply/root e-tag's 5th element (NIP-10 pubkey hint), and // ultimately fall back to the event author (self-reply). - const eTags = event.tags.filter(([name, , , marker]) => name === 'e' && marker !== 'mention'); - const replyTag = eTags.find(([, , , marker]) => marker === 'reply'); - const rootTag = eTags.find(([, , , marker]) => marker === 'root'); - const parentAuthor = (replyTag?.[4] || rootTag?.[4] || event.pubkey); + const eTags = event.tags.filter( + ([name, , , marker]) => name === "e" && marker !== "mention", + ); + const replyTag = eTags.find(([, , , marker]) => marker === "reply"); + const rootTag = eTags.find(([, , , marker]) => marker === "root"); + const parentAuthor = replyTag?.[4] || rootTag?.[4] || event.pubkey; return [parentAuthor]; }, [event.tags, isTextNote, isReply, event.pubkey]); @@ -275,9 +353,14 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre }, [event, isReply]); // Kind 34236 specific - const imeta = useMemo(() => isVine ? parseImeta(event.tags) : undefined, [event.tags, isVine]); - const vineTitle = isVine ? getTag(event.tags, 'title') : undefined; - const hashtags = isVine ? event.tags.filter(([n]) => n === 't').map(([, v]) => v) : []; + const imeta = useMemo( + () => (isVine ? parseImeta(event.tags) : undefined), + [event.tags, isVine], + ); + const vineTitle = isVine ? getTag(event.tags, "title") : undefined; + const hashtags = isVine + ? event.tags.filter(([n]) => n === "t").map(([, v]) => v) + : []; // Filter out deprecated/junk kind 30000 events if (isDeprecatedFollowSet(event)) { @@ -285,15 +368,21 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre } // NIP-36: If the event has a content-warning and the policy is "hide", skip rendering entirely - if (getContentWarning(event) !== undefined && config.contentWarningPolicy === 'hide') { + if ( + getContentWarning(event) !== undefined && + config.contentWarningPolicy === "hide" + ) { return null; } // Hide magic decks tagged t:unlisted and geocaches tagged t:hidden - if (isMagicDeck && event.tags.some(([n, v]) => n === 't' && v === 'unlisted')) { + if ( + isMagicDeck && + event.tags.some(([n, v]) => n === "t" && v === "unlisted") + ) { return null; } - if (isGeocache && event.tags.some(([n, v]) => n === 't' && v === 'hidden')) { + if (isGeocache && event.tags.some(([n, v]) => n === "t" && v === "hidden")) { return null; } @@ -314,7 +403,11 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre ) : isVine ? ( <> - {vineTitle &&

{vineTitle}

} + {vineTitle && ( +

+ {vineTitle} +

+ )} ) : isPoll ? ( @@ -355,8 +448,22 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre ) : isPodcastTrailer ? ( + ) : isGitRepo ? ( + + ) : isPatch ? ( + + ) : isPullRequest ? ( + + ) : isCustomNip ? ( + ) : ( - + )} @@ -378,18 +485,26 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre onClick={(e) => e.stopPropagation()} > {author.data?.event ? ( - {displayName} - ) : displayName} + + {displayName} + + ) : ( + displayName + )} {metadata?.bot && ( - 🤖 + + 🤖 + )}
{nip05 && nip05Pending && } {nip05 && nip05Pending && ·} - {nip05 && nip05Verified && } + {nip05 && nip05Verified && ( + + )} {nip05 && nip05Verified && ·} {timeAgo(event.created_at)} @@ -400,11 +515,20 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre // Shared avatar element const avatarElement = author.isLoading ? ( - + ) : ( - e.stopPropagation()}> - + e.stopPropagation()} + > + {displayName[0]?.toUpperCase()} @@ -420,20 +544,29 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre {(isReposted: boolean) => ( )} @@ -452,7 +585,11 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre title="Zap" > - {stats?.zapAmount ? {formatSats(stats.zapAmount)} : null} + {stats?.zapAmount ? ( + + {formatSats(stats.zapAmount)} + + ) : null} )} @@ -464,7 +601,7 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre e.stopPropagation(); const url = `${window.location.origin}/${encodedId}`; const result = await shareOrCopy(url); - if (result === 'copied') toast({ title: 'Link copied to clipboard' }); + if (result === "copied") toast({ title: "Link copied to clipboard" }); }} > @@ -473,7 +610,10 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre @@ -487,8 +627,8 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre return (
{/* Reaction emoji bubble instead of avatar */}
- +
- {threaded &&
} + {threaded && ( +
+ )}
-
+
{author.isLoading ? ( ) : ( - e.stopPropagation()}> + e.stopPropagation()} + > - + {displayName[0]?.toUpperCase()} @@ -522,15 +680,25 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre ) : ( - e.stopPropagation()}> + e.stopPropagation()} + > {author.data?.event ? ( - {displayName} - ) : displayName} + + {displayName} + + ) : ( + displayName + )} )} reacted - {timeAgo(event.created_at)} + + {timeAgo(event.created_at)} +
@@ -542,7 +710,7 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre return (
{/* Large reaction emoji */}
- +
{/* Author + "reacted" label */} @@ -564,7 +736,11 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre ) : ( <> - e.stopPropagation()}> + e.stopPropagation()} + > @@ -574,14 +750,24 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre - e.stopPropagation()}> + e.stopPropagation()} + > {author.data?.event ? ( - {displayName} - ) : displayName} + + {displayName} + + ) : ( + displayName + )} reacted - {timeAgo(event.created_at)} + + {timeAgo(event.created_at)} + )}
@@ -595,8 +781,8 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre return (
{avatarElement} - {threaded &&
} + {threaded && ( +
+ )}
-
+
{authorInfo} {contentBlock} {actionButtons} - - + +
@@ -622,30 +818,49 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre // ── Normal layout ── return (
+ className={cn( + "px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden", + className, + )} + onClick={handleCardClick} + onAuxClick={handleAuxClick} + > {/* Action header — repost takes priority, otherwise derived from event kind */} {repostedBy ? ( - - ) : KIND_HEADER_MAP[event.kind] && (() => { - const cfg = KIND_HEADER_MAP[event.kind]; - const isLive = event.kind === 30311 && getTag(event.tags, 'status') === 'live'; - return ( - - ); - })()} + + ) : ( + KIND_HEADER_MAP[event.kind] && + (() => { + const cfg = KIND_HEADER_MAP[event.kind]; + const isLive = + event.kind === 30311 && getTag(event.tags, "status") === "live"; + return ( + + ); + })() + )} {/* Header: avatar + name/handle stacked */}
@@ -660,8 +875,16 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre {!compact && ( <> {actionButtons} - - + + )}
@@ -672,7 +895,13 @@ const MAX_HEIGHT = 400; // px — posts taller than this get truncated /** Truncates long text note content with a "Read more" fade + button. * Media attachments are also hidden behind the truncation and revealed on expand. */ -function TruncatedNoteContent({ event, videos, audios = [], imetaMap, webxdcApps = [] }: { +function TruncatedNoteContent({ + event, + videos, + audios = [], + imetaMap, + webxdcApps = [], +}: { event: NostrEvent; videos: string[]; audios?: string[]; @@ -692,18 +921,21 @@ function TruncatedNoteContent({ event, videos, audios = [], imetaMap, webxdcApps useEffect(() => { measure(); - window.addEventListener('resize', measure); - return () => window.removeEventListener('resize', measure); + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); }, [measure]); // Re-measure after images load — scrollHeight is unreliable before images have rendered. useEffect(() => { const el = contentRef.current; if (!el) return; - const imgs = el.querySelectorAll('img'); + const imgs = el.querySelectorAll("img"); if (imgs.length === 0) return; - imgs.forEach((img) => img.addEventListener('load', measure, { once: true })); - return () => imgs.forEach((img) => img.removeEventListener('load', measure)); + imgs.forEach((img) => + img.addEventListener("load", measure, { once: true }), + ); + return () => + imgs.forEach((img) => img.removeEventListener("load", measure)); }, [measure]); const showMedia = !overflows || expanded; @@ -712,7 +944,11 @@ function TruncatedNoteContent({ event, videos, audios = [], imetaMap, webxdcApps
@@ -723,13 +959,22 @@ function TruncatedNoteContent({ event, videos, audios = [], imetaMap, webxdcApps {overflows && ( )} {showMedia && ( - + )}
); @@ -738,17 +983,24 @@ function TruncatedNoteContent({ event, videos, audios = [], imetaMap, webxdcApps // ── NIP-68 Photo content (kind 20) ──────────────────────────────────────────── /** Parse all imeta image URLs from NIP-68 photo events. */ -function parsePhotoUrls(tags: string[][]): Array<{ url: string; alt?: string; blurhash?: string }> { +function parsePhotoUrls( + tags: string[][], +): Array<{ url: string; alt?: string; blurhash?: string }> { const results: Array<{ url: string; alt?: string; blurhash?: string }> = []; for (const tag of tags) { - if (tag[0] !== 'imeta') continue; + if (tag[0] !== "imeta") continue; const parts: Record = {}; for (let i = 1; i < tag.length; i++) { const p = tag[i]; - const sp = p.indexOf(' '); + const sp = p.indexOf(" "); if (sp !== -1) parts[p.slice(0, sp)] = p.slice(sp + 1); } - if (parts.url) results.push({ url: parts.url, alt: parts.alt, blurhash: parts.blurhash }); + if (parts.url) + results.push({ + url: parts.url, + alt: parts.alt, + blurhash: parts.blurhash, + }); } return results; } @@ -756,9 +1008,9 @@ function parsePhotoUrls(tags: string[][]): Array<{ url: string; alt?: string; bl /** Inline photo gallery for NIP-68 kind 20 events. */ function PhotoContent({ event }: { event: NostrEvent }) { const photos = useMemo(() => parsePhotoUrls(event.tags), [event.tags]); - const title = getTag(event.tags, 'title'); + const title = getTag(event.tags, "title"); const description = event.content; - const hashtags = event.tags.filter(([n]) => n === 't').map(([, v]) => v); + const hashtags = event.tags.filter(([n]) => n === "t").map(([, v]) => v); // Build imetaMap with dim + blurhash so ImageGallery can show blurhash placeholders const imetaMap = useMemo(() => { @@ -774,12 +1026,26 @@ function PhotoContent({ event }: { event: NostrEvent }) { return (
{title &&

{title}

} - p.url)} maxVisible={4} maxGridHeight="480px" imetaMap={imetaMap} /> - {description &&

{description}

} + p.url)} + maxVisible={4} + maxGridHeight="480px" + imetaMap={imetaMap} + /> + {description && ( +

+ {description} +

+ )} {hashtags.length > 0 && (
{hashtags.slice(0, 5).map((tag) => ( - e.stopPropagation()}> + e.stopPropagation()} + > #{tag} ))} @@ -792,51 +1058,70 @@ function PhotoContent({ event }: { event: NostrEvent }) { // ── NIP-71 Video content (kinds 21 & 22) ────────────────────────────────────── /** Parse the primary video url and thumbnail from NIP-71 imeta tags. */ -function parseVideoImeta(tags: string[][]): { url?: string; thumbnail?: string; duration?: string } { +function parseVideoImeta(tags: string[][]): { + url?: string; + thumbnail?: string; + duration?: string; +} { for (const tag of tags) { - if (tag[0] !== 'imeta') continue; + if (tag[0] !== "imeta") continue; const parts: Record = {}; for (let i = 1; i < tag.length; i++) { const p = tag[i]; - const sp = p.indexOf(' '); + const sp = p.indexOf(" "); if (sp !== -1) parts[p.slice(0, sp)] = p.slice(sp + 1); } - if (parts.url) return { url: parts.url, thumbnail: parts.image, duration: parts.duration }; + if (parts.url) + return { + url: parts.url, + thumbnail: parts.image, + duration: parts.duration, + }; } // Fallback to plain url/thumb tags return { - url: tags.find(([n]) => n === 'url')?.[1], - thumbnail: tags.find(([n]) => n === 'thumb')?.[1] ?? tags.find(([n]) => n === 'image')?.[1], + url: tags.find(([n]) => n === "url")?.[1], + thumbnail: + tags.find(([n]) => n === "thumb")?.[1] ?? + tags.find(([n]) => n === "image")?.[1], }; } /** Format seconds into MM:SS / HH:MM:SS. */ function fmtDuration(seconds: string | undefined): string | undefined { - const s = parseFloat(seconds ?? ''); + const s = parseFloat(seconds ?? ""); if (isNaN(s) || s <= 0) return undefined; const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = Math.floor(s % 60); - const mm = String(m).padStart(2, '0'); - const ss = String(sec).padStart(2, '0'); + const mm = String(m).padStart(2, "0"); + const ss = String(sec).padStart(2, "0"); return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; } /** Inline video player for NIP-71 kind 21/22 events. */ function VideoContent({ event }: { event: NostrEvent }) { - const { url, thumbnail, duration } = useMemo(() => parseVideoImeta(event.tags), [event.tags]); - const title = getTag(event.tags, 'title'); + const { url, thumbnail, duration } = useMemo( + () => parseVideoImeta(event.tags), + [event.tags], + ); + const title = getTag(event.tags, "title"); const description = event.content; const isShort = event.kind === 22; const formattedDuration = fmtDuration(duration); - const hashtags = event.tags.filter(([n]) => n === 't').map(([, v]) => v); + const hashtags = event.tags.filter(([n]) => n === "t").map(([, v]) => v); if (!url) return null; return (
{title &&

{title}

} -
+
{formattedDuration && (
@@ -845,15 +1130,26 @@ function VideoContent({ event }: { event: NostrEvent }) { )} {isShort && (
- Short + + Short +
)}
- {description &&

{description}

} + {description && ( +

+ {description} +

+ )} {hashtags.length > 0 && (
{hashtags.slice(0, 5).map((tag) => ( - e.stopPropagation()}> + e.stopPropagation()} + > #{tag} ))} @@ -864,7 +1160,13 @@ function VideoContent({ event }: { event: NostrEvent }) { } /** Media content for kind 34236 vine events — rendered at full card width. */ -function VineMedia({ imeta, hashtags }: { imeta?: { url?: string; thumbnail?: string }; hashtags: string[] }) { +function VineMedia({ + imeta, + hashtags, +}: { + imeta?: { url?: string; thumbnail?: string }; + hashtags: string[]; +}) { const videoRef = useRef(null); const containerRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); @@ -951,32 +1253,49 @@ function VineMedia({ imeta, hashtags }: { imeta?: { url?: string; thumbnail?: st /** Stream status badge config. */ function getStreamStatusConfig(status: string | undefined) { switch (status) { - case 'live': - return { label: 'LIVE', className: 'bg-red-600 hover:bg-red-600 text-white border-red-600' }; - case 'ended': - return { label: 'ENDED', className: 'bg-muted text-muted-foreground border-border' }; - case 'planned': - return { label: 'PLANNED', className: 'bg-blue-600/90 hover:bg-blue-600/90 text-white border-blue-600' }; + case "live": + return { + label: "LIVE", + className: "bg-red-600 hover:bg-red-600 text-white border-red-600", + }; + case "ended": + return { + label: "ENDED", + className: "bg-muted text-muted-foreground border-border", + }; + case "planned": + return { + label: "PLANNED", + className: + "bg-blue-600/90 hover:bg-blue-600/90 text-white border-blue-600", + }; default: - return { label: status?.toUpperCase() || 'UNKNOWN', className: 'bg-muted text-muted-foreground border-border' }; + return { + label: status?.toUpperCase() || "UNKNOWN", + className: "bg-muted text-muted-foreground border-border", + }; } } /** Inline content for kind 30311 live stream events. */ function StreamContent({ event }: { event: NostrEvent }) { - const title = getTag(event.tags, 'title') || 'Untitled Stream'; - const summary = getTag(event.tags, 'summary'); - const imageUrl = getTag(event.tags, 'image'); - const streamingUrl = getTag(event.tags, 'streaming'); - const status = getTag(event.tags, 'status'); - const currentParticipants = getTag(event.tags, 'current_participants'); + const title = getTag(event.tags, "title") || "Untitled Stream"; + const summary = getTag(event.tags, "summary"); + const imageUrl = getTag(event.tags, "image"); + const streamingUrl = getTag(event.tags, "streaming"); + const status = getTag(event.tags, "status"); + const currentParticipants = getTag(event.tags, "current_participants"); const statusConfig = getStreamStatusConfig(status); - const isLive = status === 'live' && !!streamingUrl; + const isLive = status === "live" && !!streamingUrl; const encodedId = useMemo(() => { - const dTag = getTag(event.tags, 'd') || ''; - return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag }); + const dTag = getTag(event.tags, "d") || ""; + return nip19.naddrEncode({ + kind: event.kind, + pubkey: event.pubkey, + identifier: dTag, + }); }, [event]); const { onClick: openPost } = useOpenPost(`/${encodedId}`); @@ -988,10 +1307,17 @@ function StreamContent({ event }: { event: NostrEvent }) { {isLive ? ( // Inline live player — clicks on the player are intercepted so they don't navigate away
e.stopPropagation()}> - + {/* Status + viewer overlay on top of the player */}
- +
{statusConfig.label} @@ -1011,11 +1337,15 @@ function StreamContent({ event }: { event: NostrEvent }) { className="w-full h-full object-cover" loading="lazy" onError={(e) => { - (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; + (e.currentTarget.parentElement as HTMLElement).style.display = + "none"; }} />
- + {statusConfig.label}
@@ -1030,8 +1360,13 @@ function StreamContent({ event }: { event: NostrEvent }) { // No image, no live stream — show a minimal placeholder with status
- - {status === 'live' &&
} + + {status === "live" && ( +
+ )} {statusConfig.label} {currentParticipants && ( @@ -1059,7 +1394,9 @@ function StreamContent({ event }: { event: NostrEvent }) { {title} {summary && ( -

{summary}

+

+ {summary} +

)}
@@ -1093,23 +1430,73 @@ interface KindHeaderConfig { } const KIND_HEADER_MAP: Record = { - 37516: { icon: ChestIcon, action: 'hid a', noun: 'treasure', nounRoute: '/treasures' }, - 7516: { icon: ChestIcon, action: 'found a', noun: 'treasure', nounRoute: '/treasures' }, - 37381: { icon: CardsIcon, action: 'shared a', noun: 'deck', nounRoute: '/decks' }, - 36767: { icon: Palette, action: 'shared a', noun: 'theme', nounRoute: '/themes' }, - 16767: { icon: Palette, action: 'updated their', noun: 'theme', nounRoute: '/themes' }, - 30030: { icon: SmilePlus, action: 'shared an', noun: 'emoji pack', nounRoute: '/emojis' }, - 30009: { icon: Award, action: 'created a', noun: 'badge', nounRoute: '/badges' }, - 30008: { icon: Award, action: 'updated their', noun: 'badges', nounRoute: '/badges' }, + 37516: { + icon: ChestIcon, + action: "hid a", + noun: "treasure", + nounRoute: "/treasures", + }, + 7516: { + icon: ChestIcon, + action: "found a", + noun: "treasure", + nounRoute: "/treasures", + }, + 37381: { + icon: CardsIcon, + action: "shared a", + noun: "deck", + nounRoute: "/decks", + }, + 36767: { + icon: Palette, + action: "shared a", + noun: "theme", + nounRoute: "/themes", + }, + 16767: { + icon: Palette, + action: "updated their", + noun: "theme", + nounRoute: "/themes", + }, + 30030: { + icon: SmilePlus, + action: "shared an", + noun: "emoji pack", + nounRoute: "/emojis", + }, + 30009: { + icon: Award, + action: "created a", + noun: "badge", + nounRoute: "/badges", + }, + 30008: { + icon: Award, + action: "updated their", + noun: "badges", + nounRoute: "/badges", + }, 30311: { icon: Radio, iconClassName: undefined, // computed dynamically below - action: (tags) => tags.find(([n]) => n === 'status')?.[1] === 'live' ? 'is streaming' : 'streamed', + action: (tags) => + tags.find(([n]) => n === "status")?.[1] === "live" + ? "is streaming" + : "streamed", }, }; /** Generic action header: icon · [author name] [action] [linked noun] */ -function EventActionHeader({ pubkey, icon: Icon, iconClassName, action, noun, nounRoute }: EventActionHeaderProps) { +function EventActionHeader({ + pubkey, + icon: Icon, + iconClassName, + action, + noun, + nounRoute, +}: EventActionHeaderProps) { const author = useAuthor(pubkey); const name = author.data?.metadata?.name || genUserName(pubkey); const url = useProfileUrl(pubkey, author.data?.metadata); @@ -1117,7 +1504,12 @@ function EventActionHeader({ pubkey, icon: Icon, iconClassName, action, noun, no return (
- +
{author.isLoading ? ( @@ -1128,14 +1520,20 @@ function EventActionHeader({ pubkey, icon: Icon, iconClassName, action, noun, no className="font-medium hover:underline mr-1 truncate" onClick={(e) => e.stopPropagation()} > - {author.data?.event ? {name} : name} + {author.data?.event ? ( + + {name} + + ) : ( + name + )} )} - + {action} {noun && nounRoute && ( <> - {' '} + {" "} )} - {noun && !nounRoute && <>{' '}{noun}} + {noun && !nounRoute && <> {noun}}
); } - - diff --git a/src/components/PatchCard.tsx b/src/components/PatchCard.tsx new file mode 100644 index 00000000..d809c1c3 --- /dev/null +++ b/src/components/PatchCard.tsx @@ -0,0 +1,235 @@ +import type { NostrEvent } from "@nostrify/nostrify"; +import { FileText, GitCommit, User, Wand2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; + +interface PatchCardProps { + event: NostrEvent; + /** If true, show a compact preview. If false, show the full patch content. Defaults to true. */ + preview?: boolean; +} + +/** Parse the git format-patch content into structured parts. */ +function parsePatchContent(content: string) { + const lines = content.split("\n"); + let subject = ""; + let commitMessage = ""; + let diff = ""; + + // Extract subject from first line or Subject: header + const firstLine = lines[0]?.trim() ?? ""; + if (firstLine.startsWith("Subject:")) { + subject = firstLine.replace(/^Subject:\s*(\[PATCH[^\]]*\])?\s*/, ""); + } else { + subject = firstLine; + } + + // Find the diff start (lines starting with "---" followed by diff content, or "diff --git") + let diffStartIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith("diff --git ")) { + diffStartIdx = i; + break; + } + } + + if (diffStartIdx > 0) { + // Everything between subject and diff is the commit message + // Skip blank lines and email-style headers + const messageLines: string[] = []; + for (let i = 1; i < diffStartIdx; i++) { + const line = lines[i]; + // Skip email-style headers (From:, Date:, Subject:, etc.) + if (/^[A-Z][a-z-]+:/.test(line) && messageLines.length === 0) continue; + // Skip the "---" separator before diff stats + if (line === "---") continue; + // Skip diff stat lines (e.g. " file.ts | 5 ++---") + if (/^\s+\S+.*\|.*\d+/.test(line)) continue; + // Skip the summary line (e.g. "2 files changed, 10 insertions(+)") + if (/^\s*\d+ files? changed/.test(line)) continue; + messageLines.push(line); + } + commitMessage = messageLines.join("\n").trim(); + diff = lines.slice(diffStartIdx).join("\n"); + } else { + // No diff found — treat everything after the first line as the message + commitMessage = lines.slice(1).join("\n").trim(); + } + + return { subject, commitMessage, diff }; +} + +/** Renders a NIP-34 kind 1617 patch event card. */ +export function PatchCard({ event, preview = true }: PatchCardProps) { + const { subject, commitMessage, diff } = parsePatchContent(event.content); + + const isRoot = event.tags.some(([n, v]) => n === "t" && v === "root"); + const isRevision = event.tags.some( + ([n, v]) => n === "t" && v === "root-revision", + ); + const hasShakespeare = event.tags.some( + ([n, v]) => n === "t" && v === "shakespeare", + ); + const repoTag = event.tags.find(([n]) => n === "a")?.[1]; + const repoName = repoTag?.split(":")[2] ?? ""; + const commitId = event.tags.find(([n]) => n === "commit")?.[1]; + const parentCommit = event.tags.find(([n]) => n === "parent-commit")?.[1]; + const committerTag = event.tags.find(([n]) => n === "committer"); + const hashtags = event.tags + .filter(([n]) => n === "t") + .map(([, v]) => v) + .filter( + (t) => t !== "root" && t !== "root-revision" && t !== "shakespeare", + ); + + return ( +
+ {/* Title + status badges */} +
+ +
+
+ + {subject || "Patch"} + + {isRoot && ( + + root + + )} + {isRevision && ( + + revision + + )} +
+ {repoName && ( +

{repoName}

+ )} +
+
+ + {/* Commit metadata — detail view only */} + {!preview && (commitId || parentCommit || committerTag) && ( +
+ {commitId && ( +
+ + Commit + + {commitId.slice(0, 12)} + +
+ )} + {parentCommit && ( +
+ + Parent + + {parentCommit.slice(0, 12)} + +
+ )} + {committerTag && ( +
+ + Committer + {committerTag[1]} + {committerTag[2] && ( + + <{committerTag[2]}> + + )} +
+ )} +
+ )} + + {/* Commit message — detail view only */} + {!preview && commitMessage && ( +
+

Message

+
+ {commitMessage} +
+
+ )} + + {/* Diff — detail view only */} + {!preview && diff && ( +
+

Diff

+
+
+							{diff.split("\n").map((line, i) => {
+								let lineClass = "text-foreground";
+								if (line.startsWith("+") && !line.startsWith("+++"))
+									lineClass = "text-green-600 dark:text-green-400";
+								else if (line.startsWith("-") && !line.startsWith("---"))
+									lineClass = "text-red-600 dark:text-red-400";
+								else if (line.startsWith("@@"))
+									lineClass = "text-blue-600 dark:text-blue-400";
+								else if (line.startsWith("diff --git"))
+									lineClass = "text-muted-foreground font-semibold";
+								return (
+									// biome-ignore lint/suspicious/noArrayIndexKey: diff lines are static and never reorder
+									
+ {line} +
+ ); + })} +
+
+
+ )} + + {/* Tags section */} + {hashtags.length > 0 && ( +
+

Tags

+
+ {hashtags.slice(0, preview ? 6 : hashtags.length).map((tag) => ( + + {tag} + + ))} + {preview && hashtags.length > 6 && ( + + +{hashtags.length - 6} more + + )} +
+
+ )} + + {/* Action buttons */} + {hasShakespeare && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/PullRequestCard.tsx b/src/components/PullRequestCard.tsx new file mode 100644 index 00000000..0865bb67 --- /dev/null +++ b/src/components/PullRequestCard.tsx @@ -0,0 +1,201 @@ +import type { NostrEvent } from "@nostrify/nostrify"; +import { + Copy, + GitBranch, + GitCommit, + GitPullRequest, + Wand2, +} from "lucide-react"; +import Markdown from "react-markdown"; +import rehypeSanitize from "rehype-sanitize"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +interface PullRequestCardProps { + event: NostrEvent; + /** If true, show a compact preview. If false, show the full PR content. Defaults to true. */ + preview?: boolean; +} + +/** Renders a NIP-34 kind 1618 pull request event card. */ +export function PullRequestCard({ + event, + preview = true, +}: PullRequestCardProps) { + const subject = event.tags.find(([n]) => n === "subject")?.[1]; + const branchName = event.tags.find(([n]) => n === "branch-name")?.[1]; + const repoTag = event.tags.find(([n]) => n === "a")?.[1]; + const repoName = repoTag?.split(":")[2] ?? ""; + const cloneUrls = event.tags.filter(([n]) => n === "clone").map(([, v]) => v); + const hasShakespeare = event.tags.some( + ([n, v]) => n === "t" && v === "shakespeare", + ); + const commitTip = event.tags.find(([n]) => n === "c")?.[1]; + const mergeBase = event.tags.find(([n]) => n === "merge-base")?.[1]; + const labels = event.tags + .filter(([n]) => n === "t") + .map(([, v]) => v) + .filter((t) => t !== "shakespeare"); + + const title = + subject || event.content.split("\n")[0]?.trim() || "Pull Request"; + const hasDescription = event.content.trim().length > 0; + + const handleCopy = (url: string) => { + navigator.clipboard.writeText(url); + }; + + const shakespeareUrl = cloneUrls[0] + ? `https://shakespeare.diy/clone?url=${encodeURIComponent(cloneUrls[0])}` + : "https://shakespeare.diy"; + + return ( +
+ {/* Title + branch info */} +
+ +
+ + {title} + +
+ {repoName && {repoName}} + {repoName && branchName && ( + / + )} + {branchName && ( + + {branchName} + + )} +
+
+
+ + {/* Branch & commit metadata — detail view only */} + {!preview && (commitTip || mergeBase || branchName) && ( +
+ {branchName && ( +
+ + Branch + + {branchName} + +
+ )} + {commitTip && ( +
+ + Tip + + {commitTip.slice(0, 12)} + +
+ )} + {mergeBase && ( +
+ + Base + + {mergeBase.slice(0, 12)} + +
+ )} +
+ )} + + {/* PR description — detail view only, rendered as markdown */} + {!preview && hasDescription && ( +
+

+ Description +

+
+ + {event.content} + +
+
+ )} + + {/* Labels section */} + {labels.length > 0 && ( +
+

Labels

+
+ {labels.slice(0, preview ? 6 : labels.length).map((label) => ( + + {label} + + ))} + {preview && labels.length > 6 && ( + + +{labels.length - 6} more + + )} +
+
+ )} + + {/* Clone URL section */} + {cloneUrls[0] && ( +
+

Clone

+
+ + {cloneUrls[0]} + + +
+
+ )} + + {/* Action buttons */} +
+ {hasShakespeare && ( + + )} + {!hasShakespeare && cloneUrls[0] && ( + + )} +
+
+ ); +} diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index 4935d1a3..986b2541 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -120,6 +120,10 @@ export interface FeedSettings { feedIncludePodcastEpisodes: boolean; /** Include podcast trailers (kind 30055) in the follows/global feed */ feedIncludePodcastTrailers: boolean; + /** Show Development (NIP-34 repos, patches, PRs, custom NIPs, app submissions) link in sidebar */ + showDevelopment: boolean; + /** Include Development content in the follows/global feed */ + feedIncludeDevelopment: boolean; /** Show Badges (NIP-58 kind 30009) link in sidebar */ showBadges: boolean; /** Show badge definitions (kind 30009) on the Badges page */ @@ -214,7 +218,9 @@ export interface AppContextType { /** Current application configuration */ config: AppConfig; /** Update configuration using a callback that receives current config and returns new config */ - updateConfig: (updater: (currentConfig: Partial) => Partial) => void; + updateConfig: ( + updater: (currentConfig: Partial) => Partial, + ) => void; } export const AppContext = createContext(undefined); diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index 4004774c..25db7998 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -24,18 +24,19 @@ export interface ExtraKindSite { } /** Section labels for grouping extra kinds in settings UI. */ -export type ExtraKindSection = 'feed' | 'media' | 'social' | 'whimsy'; +export type ExtraKindSection = 'feed' | 'media' | 'social' | 'development' | 'whimsy'; /** Display labels for each section. */ export const SECTION_LABELS: Record = { feed: 'Feed', media: 'Media', social: 'Social', + development: 'Development', whimsy: 'Whimsy', }; /** Ordered list of sections for the "Other Stuff" settings UI. */ -export const SECTION_ORDER: ExtraKindSection[] = ['media', 'social', 'whimsy']; +export const SECTION_ORDER: ExtraKindSection[] = ['media', 'social', 'development', 'whimsy']; /** Metadata for an extra (non-kind-1) content type. */ export interface ExtraKindDef { @@ -443,6 +444,21 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ }, ], }, + // Development + { + kind: 30617, + id: 'development', + showKey: 'showDevelopment', + feedKey: 'feedIncludeDevelopment', + extraFeedKinds: [1617, 1618, 30817], + label: 'Development', + description: 'Git repos, patches, PRs, and custom NIPs', + route: 'development', + addressable: true, + section: 'development', + blurb: 'Nostr-native git repositories, patches, pull requests, custom NIPs, and published applications.', + sites: [{ url: 'https://gitworkshop.dev', name: 'Gitworkshop' }, { url: 'https://nostrhub.io', name: 'NostrHub' }], + }, ]; /** Lookup an ExtraKindDef by its `id` field. */ diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 713da11a..933df50d 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -171,6 +171,8 @@ export const FeedSettingsSchema = z.looseObject({ showPodcasts: z.boolean().optional(), feedIncludePodcastEpisodes: z.boolean().optional(), feedIncludePodcastTrailers: z.boolean().optional(), + showDevelopment: z.boolean().optional(), + feedIncludeDevelopment: z.boolean().optional(), }); /** Schema for a NIP-01 filter object (lenient — allows variable placeholder strings). */ diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index 7e2cc8ff..7e2fb910 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -1,19 +1,46 @@ import { - Bell, Search, TrendingUp, User, Bookmark, Settings, Palette, Scroll, - Clapperboard, BarChart3, PartyPopper, BookOpen, BookMarked, Sparkles, Blocks, - MessageSquare, Repeat2, MessageSquareMore, Mic, Smile, Bot, SmilePlus, Camera, Film, Earth, Calendar, - Music, Podcast, HelpCircle, Award, -} from 'lucide-react'; -import { PlanetIcon } from '@/components/icons/PlanetIcon'; -import { ChestIcon } from '@/components/icons/ChestIcon'; -import { CardsIcon } from '@/components/icons/CardsIcon'; + Award, + BarChart3, + Bell, + Blocks, + BookMarked, + Bookmark, + BookOpen, + Bot, + CalendarDays, + Camera, + Clapperboard, + Code, + Earth, + Film, + HelpCircle, + MessageSquare, + MessageSquareMore, + Mic, + Music, + Palette, + PartyPopper, + Podcast, + Repeat2, + Scroll, + Search, + Settings, + Smile, + SmilePlus, + Sparkles, + TrendingUp, + User, +} from "lucide-react"; +import { CardsIcon } from "@/components/icons/CardsIcon"; +import { ChestIcon } from "@/components/icons/ChestIcon"; +import { PlanetIcon } from "@/components/icons/PlanetIcon"; // ── Types ───────────────────────────────────────────────────────────────────── type IconComponent = React.ComponentType<{ className?: string }>; /** Sentinel ID used to represent a visual divider in the sidebar order. */ -export const SIDEBAR_DIVIDER_ID = 'divider'; +export const SIDEBAR_DIVIDER_ID = "divider"; /** Returns true if the given sidebar order ID is a divider sentinel. */ export function isSidebarDivider(id: string): boolean { @@ -45,36 +72,67 @@ export interface SidebarItemDef { */ export const SIDEBAR_ITEMS: SidebarItemDef[] = [ // System pages - { id: 'feed', label: 'Feed', path: '/feed', icon: PlanetIcon }, - { id: 'notifications', label: 'Notifications', path: '/notifications', icon: Bell, requiresAuth: true }, - { id: 'search', label: 'Search', path: '/search', icon: Search }, - { id: 'trends', label: 'Trends', path: '/trends', icon: TrendingUp }, - { id: 'bookmarks', label: 'Bookmarks', path: '/bookmarks', icon: Bookmark, requiresAuth: true }, - { id: 'profile', label: 'Profile', path: '/profile', icon: User, requiresAuth: true }, - { id: 'lists', label: 'Lists', path: '/lists', icon: Scroll, requiresAuth: true }, - { id: 'settings', label: 'Settings', path: '/settings', icon: Settings }, - { id: 'ai-chat', label: 'AI Chat', path: '/ai-chat', icon: Bot, requiresAuth: true }, - { id: 'help', label: 'Help', path: '/help', icon: HelpCircle }, + { id: "feed", label: "Feed", path: "/feed", icon: PlanetIcon }, + { + id: "notifications", + label: "Notifications", + path: "/notifications", + icon: Bell, + requiresAuth: true, + }, + { id: "search", label: "Search", path: "/search", icon: Search }, + { id: "trends", label: "Trends", path: "/trends", icon: TrendingUp }, + { + id: "bookmarks", + label: "Bookmarks", + path: "/bookmarks", + icon: Bookmark, + requiresAuth: true, + }, + { + id: "profile", + label: "Profile", + path: "/profile", + icon: User, + requiresAuth: true, + }, + { + id: "lists", + label: "Lists", + path: "/lists", + icon: Scroll, + requiresAuth: true, + }, + { id: "settings", label: "Settings", path: "/settings", icon: Settings }, + { + id: "ai-chat", + label: "AI Chat", + path: "/ai-chat", + icon: Bot, + requiresAuth: true, + }, + { id: "help", label: "Help", path: "/help", icon: HelpCircle }, // Content types - { id: 'events', label: 'Events', path: '/events', icon: Calendar }, - { id: 'photos', label: 'Photos', path: '/photos', icon: Camera }, - { id: 'videos', label: 'Videos', path: '/videos', icon: Film }, - { id: 'articles', label: 'Articles', path: '/articles', icon: BookOpen }, - { id: 'books', label: 'Books', path: '/books', icon: BookMarked }, - { id: 'vines', label: 'Vines', path: '/vines', icon: Clapperboard }, - { id: 'music', label: 'Music', path: '/music', icon: Music }, - { id: 'podcasts', label: 'Podcasts', path: '/podcasts', icon: Podcast }, + { id: "events", label: "Events", path: "/events", icon: CalendarDays }, + { id: "photos", label: "Photos", path: "/photos", icon: Camera }, + { id: "videos", label: "Videos", path: "/videos", icon: Film }, + { id: "articles", label: "Articles", path: "/articles", icon: BookOpen }, + { id: "books", label: "Books", path: "/books", icon: BookMarked }, + { id: "vines", label: "Vines", path: "/vines", icon: Clapperboard }, + { id: "music", label: "Music", path: "/music", icon: Music }, + { id: "podcasts", label: "Podcasts", path: "/podcasts", icon: Podcast }, - { id: 'webxdc', label: 'Webxdc', path: '/webxdc', icon: Blocks }, - { id: 'themes', label: 'Themes', path: '/themes', icon: Sparkles }, - { id: 'polls', label: 'Polls', path: '/polls', icon: BarChart3 }, - { id: 'packs', label: 'Follow Packs', path: '/packs', icon: PartyPopper }, - { id: 'colors', label: 'Color Moments', path: '/colors', icon: Palette }, - { id: 'decks', label: 'Magic Decks', path: '/decks', icon: CardsIcon }, - { id: 'treasures', label: 'Treasures', path: '/treasures', icon: ChestIcon }, - { id: 'emojis', label: 'Emojis', path: '/emojis', icon: SmilePlus }, - { id: 'badges', label: 'Badges', path: '/badges', icon: Award }, - { id: 'world', label: 'World', path: '/world', icon: Earth }, + { id: "webxdc", label: "Webxdc", path: "/webxdc", icon: Blocks }, + { id: "themes", label: "Themes", path: "/themes", icon: Sparkles }, + { id: "polls", label: "Polls", path: "/polls", icon: BarChart3 }, + { id: "packs", label: "Follow Packs", path: "/packs", icon: PartyPopper }, + { id: "colors", label: "Color Moments", path: "/colors", icon: Palette }, + { id: "decks", label: "Magic Decks", path: "/decks", icon: CardsIcon }, + { id: "treasures", label: "Treasures", path: "/treasures", icon: ChestIcon }, + { id: "emojis", label: "Emojis", path: "/emojis", icon: SmilePlus }, + { id: "development", label: "Development", path: "/development", icon: Code }, + { id: "badges", label: "Badges", path: "/badges", icon: Award }, + { id: "world", label: "World", path: "/world", icon: Earth }, ]; /** Set of all known sidebar item IDs for quick lookup. */ @@ -91,11 +149,13 @@ export const CONTENT_KIND_ICONS: Record = { posts: MessageSquare, comments: MessageSquareMore, reposts: Repeat2, - 'generic-reposts': Repeat2, + "generic-reposts": Repeat2, voice: Mic, - 'custom-emojis': Smile, + "custom-emojis": Smile, statuses: SmilePlus, - ...Object.fromEntries(SIDEBAR_ITEMS.filter((s) => s.icon).map((s) => [s.id, s.icon])), + ...Object.fromEntries( + SIDEBAR_ITEMS.filter((s) => s.icon).map((s) => [s.id, s.icon]), + ), }; // ── Lookups ─────────────────────────────────────────────────────────────────── @@ -106,7 +166,10 @@ export function getSidebarItem(id: string): SidebarItemDef | undefined { } /** Returns the icon element for a sidebar item ID at the given size. */ -export function sidebarItemIcon(id: string, size = 'size-6'): React.ReactElement { +export function sidebarItemIcon( + id: string, + size = "size-6", +): React.ReactElement { const Icon = SIDEBAR_ITEM_MAP.get(id)?.icon ?? Palette; return ; } @@ -117,22 +180,33 @@ export function itemLabel(id: string): string { } /** Lookup navigation path for a sidebar item ID. */ -export function itemPath(id: string, profilePath?: string, homePage?: string): string { - if (id === 'profile' && profilePath) return profilePath; - if (homePage && id === homePage) return '/'; +export function itemPath( + id: string, + profilePath?: string, + homePage?: string, +): string { + if (id === "profile" && profilePath) return profilePath; + if (homePage && id === homePage) return "/"; return SIDEBAR_ITEM_MAP.get(id)?.path ?? `/${id}`; } /** Check if a sidebar item is active given the current location. */ -export function isItemActive(id: string, pathname: string, _search: string, profilePath?: string, homePage?: string): boolean { - if (id === 'profile') return !!profilePath && pathname === profilePath; - if (id === 'settings') return pathname.startsWith('/settings'); +export function isItemActive( + id: string, + pathname: string, + _search: string, + profilePath?: string, + homePage?: string, +): boolean { + if (id === "profile") return !!profilePath && pathname === profilePath; + if (id === "settings") return pathname.startsWith("/settings"); const itemDef = SIDEBAR_ITEM_MAP.get(id); const itemPathname = itemDef?.path ?? `/${id}`; // Homepage item is active at both "/" and its own path - if (homePage && id === homePage) return pathname === '/' || pathname === itemPathname; + if (homePage && id === homePage) + return pathname === "/" || pathname === itemPathname; return pathname === itemPathname; } diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 9692ed1a..cf08e83f 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -1,51 +1,81 @@ -import { useMemo, useState, useCallback, useRef, useEffect } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { ArrowLeft, MessageCircle, Zap, MoreHorizontal, Share2, Radio, Loader2, AlertCircle, Copy, Check, ChevronRight, Star } from 'lucide-react'; -import { RepostIcon } from '@/components/icons/RepostIcon'; -import type { NostrEvent } from '@nostrify/nostrify'; -import { useNostr } from '@nostrify/react'; -import { nip19 } from 'nostr-tools'; -import { useQueryClient } from '@tanstack/react-query'; -import { useSeoMeta } from '@unhead/react'; - -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; -import { Skeleton } from '@/components/ui/skeleton'; -import { Button } from '@/components/ui/button'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { Input } from '@/components/ui/input'; -import { NoteContent } from '@/components/NoteContent'; -import { VideoPlayer } from '@/components/VideoPlayer'; -import { NoteCard } from '@/components/NoteCard'; -import { ThreadedReplyList } from '@/components/ThreadedReplyList'; -import { NoteMoreMenu } from '@/components/NoteMoreMenu'; -import { ReplyComposeModal } from '@/components/ReplyComposeModal'; -import { ReactionButton } from '@/components/ReactionButton'; -import { RepostMenu } from '@/components/RepostMenu'; -import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal'; -import { ZapDialog } from '@/components/ZapDialog'; -import { RenderResolvedEmoji, EmojifiedText, ReactionEmoji } from '@/components/CustomEmoji'; -import { PollContent } from '@/components/PollContent'; -import { GeocacheContent } from '@/components/GeocacheContent'; -import { FoundLogContent } from '@/components/FoundLogContent'; -import { ColorMomentContent, ColorMomentEyeButton } from '@/components/ColorMomentContent'; -import { FollowPackContent } from '@/components/FollowPackContent'; -import { FollowPackDetailContent } from '@/components/FollowPackDetailContent'; -import { ArticleContent } from '@/components/ArticleContent'; -import { MagicDeckContent } from '@/components/MagicDeckContent'; -import { FileMetadataContent } from '@/components/FileMetadataContent'; -import { ThemeContent } from '@/components/ThemeContent'; -import { VoiceMessagePlayer } from '@/components/VoiceMessagePlayer'; -import { CalendarEventDetailPage } from '@/components/CalendarEventDetailPage'; -import { LiveStreamPage } from '@/components/LiveStreamPage'; -import { MusicDetailContent } from '@/components/MusicDetailContent'; -import { PodcastDetailContent } from '@/components/PodcastDetailContent'; -import { BadgeDetailContent } from '@/components/BadgeDetailContent'; -import { WebxdcEmbed } from '@/components/WebxdcEmbed'; -import { AudioVisualizer } from '@/components/AudioVisualizer'; -import { extractAudioUrls } from '@/lib/mediaUrls'; -import { type ImetaEntry, parseImetaMap } from '@/lib/imeta'; -import { useEvent, useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; -import { useAppContext } from '@/hooks/useAppContext'; +import type { NostrEvent } from "@nostrify/nostrify"; +import { useNostr } from "@nostrify/react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useSeoMeta } from "@unhead/react"; +import { + AlertCircle, + ArrowLeft, + Check, + ChevronRight, + Copy, + Loader2, + MessageCircle, + MoreHorizontal, + Radio, + Share2, + Star, + Zap, +} from "lucide-react"; +import { nip19 } from "nostr-tools"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { ArticleContent } from "@/components/ArticleContent"; +import { AudioVisualizer } from "@/components/AudioVisualizer"; +import { BadgeDetailContent } from "@/components/BadgeDetailContent"; +import { CalendarEventDetailPage } from "@/components/CalendarEventDetailPage"; +import { + ColorMomentContent, + ColorMomentEyeButton, +} from "@/components/ColorMomentContent"; +import { + EmojifiedText, + ReactionEmoji, + RenderResolvedEmoji, +} from "@/components/CustomEmoji"; +import { CustomNipCard } from "@/components/CustomNipCard"; +import { FileMetadataContent } from "@/components/FileMetadataContent"; +import { FollowPackContent } from "@/components/FollowPackContent"; +import { FollowPackDetailContent } from "@/components/FollowPackDetailContent"; +import { FoundLogContent } from "@/components/FoundLogContent"; +import { GeocacheContent } from "@/components/GeocacheContent"; +import { GitRepoCard } from "@/components/GitRepoCard"; +import { + InteractionsModal, + type InteractionTab, +} from "@/components/InteractionsModal"; +import { RepostIcon } from "@/components/icons/RepostIcon"; +import { LiveStreamPage } from "@/components/LiveStreamPage"; +import { MagicDeckContent } from "@/components/MagicDeckContent"; +import { MusicDetailContent } from "@/components/MusicDetailContent"; +import { NoteCard } from "@/components/NoteCard"; +import { NoteContent } from "@/components/NoteContent"; +import { NoteMoreMenu } from "@/components/NoteMoreMenu"; +import { PatchCard } from "@/components/PatchCard"; +import { PodcastDetailContent } from "@/components/PodcastDetailContent"; +import { PollContent } from "@/components/PollContent"; +import { PullRequestCard } from "@/components/PullRequestCard"; +import { ReactionButton } from "@/components/ReactionButton"; +import { ReplyComposeModal } from "@/components/ReplyComposeModal"; +import { RepostMenu } from "@/components/RepostMenu"; +import { ThemeContent } from "@/components/ThemeContent"; +import { ThreadedReplyList } from "@/components/ThreadedReplyList"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { VideoPlayer } from "@/components/VideoPlayer"; +import { VoiceMessagePlayer } from "@/components/VoiceMessagePlayer"; +import { WebxdcEmbed } from "@/components/WebxdcEmbed"; +import { ZapDialog } from "@/components/ZapDialog"; +import { useAppContext } from "@/hooks/useAppContext"; +import { type AddrCoords, useAddrEvent, useEvent } from "@/hooks/useEvent"; +import { type ImetaEntry, parseImetaMap } from "@/lib/imeta"; +import { extractAudioUrls } from "@/lib/mediaUrls"; /** Kinds that get the full follow-pack detail view. */ const FOLLOW_PACK_KINDS = new Set([30000, 39089]); @@ -68,44 +98,51 @@ const BOOK_REVIEW_KIND = 31985; /** Map a kind number to a human-readable shell title for the loading state. */ function shellTitleForKind(kind?: number): string { - if (!kind) return 'Loading...'; - if (MUSIC_KINDS.has(kind)) return 'Track Details'; - if (PODCAST_KINDS.has(kind)) return 'Episode Details'; - if (CALENDAR_EVENT_KINDS.has(kind)) return 'Event Details'; - if (FOLLOW_PACK_KINDS.has(kind)) return 'Follow Pack'; - if (kind === LIVE_STREAM_KIND) return 'Live Stream'; - if (kind === BADGE_DEFINITION_KIND) return 'Badge Details'; - if (kind === BOOK_REVIEW_KIND) return 'Book Review'; - return 'Post Details'; + if (!kind) return "Loading..."; + if (MUSIC_KINDS.has(kind)) return "Track Details"; + if (PODCAST_KINDS.has(kind)) return "Episode Details"; + if (CALENDAR_EVENT_KINDS.has(kind)) return "Event Details"; + if (FOLLOW_PACK_KINDS.has(kind)) return "Follow Pack"; + if (kind === LIVE_STREAM_KIND) return "Live Stream"; + if (kind === 30617) return "Repository"; + if (kind === 1617) return "Patch"; + if (kind === 1618) return "Pull Request"; + if (kind === 30817) return "Custom NIP"; + if (kind === BADGE_DEFINITION_KIND) return "Badge Details"; + if (kind === BOOK_REVIEW_KIND) return "Book Review"; + return "Post Details"; } -import { useReplies } from '@/hooks/useReplies'; -import { useComments } from '@/hooks/useComments'; -import { CommentContext } from '@/components/CommentContext'; -import { useAuthor } from '@/hooks/useAuthor'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useMuteList } from '@/hooks/useMuteList'; -import { isEventMuted } from '@/lib/muteHelpers'; -import { useEventStats } from '@/hooks/useTrending'; -import { useEventInteractions } from '@/hooks/useEventInteractions'; -import { type ResolvedEmoji, isCustomEmoji } from '@/lib/customEmoji'; -import { getDisplayName } from '@/lib/getDisplayName'; - -import { canZap } from '@/lib/canZap'; -import { cn } from '@/lib/utils'; -import { Nip05Badge } from '@/components/Nip05Badge'; -import { ProfileHoverCard } from '@/components/ProfileHoverCard'; -import { useProfileUrl } from '@/hooks/useProfileUrl'; -import { ContentWarningGuard } from '@/components/ContentWarningGuard'; -import { MutedContentGuard } from '@/components/MutedContentGuard'; -import { ExternalContentPreview, ProfilePreview, CommunityPreview, AddressableEventPreview } from '@/components/ExternalContentHeader'; -import { getParentEventId, isReplyEvent } from '@/lib/nostrEvents'; -import { shareOrCopy } from '@/lib/share'; -import { toast } from '@/hooks/useToast'; -import { EmojiPackContent } from '@/components/EmojiPackContent'; -import { CommunityContent } from '@/components/CommunityContent'; -import { extractISBNFromEvent } from '@/lib/bookstr'; - +import { CommentContext } from "@/components/CommentContext"; +import { CommunityContent } from "@/components/CommunityContent"; +import { ContentWarningGuard } from "@/components/ContentWarningGuard"; +import { EmojiPackContent } from "@/components/EmojiPackContent"; +import { + AddressableEventPreview, + CommunityPreview, + ExternalContentPreview, + ProfilePreview, +} from "@/components/ExternalContentHeader"; +import { MutedContentGuard } from "@/components/MutedContentGuard"; +import { Nip05Badge } from "@/components/Nip05Badge"; +import { ProfileHoverCard } from "@/components/ProfileHoverCard"; +import { useAuthor } from "@/hooks/useAuthor"; +import { useComments } from "@/hooks/useComments"; +import { useCurrentUser } from "@/hooks/useCurrentUser"; +import { useEventInteractions } from "@/hooks/useEventInteractions"; +import { useMuteList } from "@/hooks/useMuteList"; +import { useProfileUrl } from "@/hooks/useProfileUrl"; +import { useReplies } from "@/hooks/useReplies"; +import { toast } from "@/hooks/useToast"; +import { useEventStats } from "@/hooks/useTrending"; +import { extractISBNFromEvent } from "@/lib/bookstr"; +import { canZap } from "@/lib/canZap"; +import { isCustomEmoji, type ResolvedEmoji } from "@/lib/customEmoji"; +import { getDisplayName } from "@/lib/getDisplayName"; +import { isEventMuted } from "@/lib/muteHelpers"; +import { getParentEventId, isReplyEvent } from "@/lib/nostrEvents"; +import { shareOrCopy } from "@/lib/share"; +import { cn } from "@/lib/utils"; interface PostDetailPageProps { eventId: string; @@ -120,8 +157,9 @@ interface AddrPostDetailPageProps { /** Formats a sats amount into a compact human-readable string. */ function formatSats(sats: number): string { - if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; - if (sats >= 1_000) return `${(sats / 1_000).toFixed(1).replace(/\.0$/, '')}K`; + if (sats >= 1_000_000) + return `${(sats / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + if (sats >= 1_000) return `${(sats / 1_000).toFixed(1).replace(/\.0$/, "")}K`; return sats.toString(); } @@ -131,28 +169,31 @@ function extractVideos(content: string): string[] { return content.match(urlRegex) || []; } - - /** Get the first value for a tag name. */ function getTag(tags: string[][], name: string): string | undefined { return tags.find(([n]) => n === name)?.[1]; } /** Parse single imeta tag into structured object (for kind 34236 vines). */ -function parseImeta(tags: string[][]): { url?: string; thumbnail?: string; dim?: string; blurhash?: string } { - const imetaTag = tags.find(([name]) => name === 'imeta'); +function parseImeta(tags: string[][]): { + url?: string; + thumbnail?: string; + dim?: string; + blurhash?: string; +} { + const imetaTag = tags.find(([name]) => name === "imeta"); if (!imetaTag) return {}; const result: Record = {}; for (let i = 1; i < imetaTag.length; i++) { const part = imetaTag[i]; - const spaceIdx = part.indexOf(' '); + const spaceIdx = part.indexOf(" "); if (spaceIdx === -1) continue; const key = part.slice(0, spaceIdx); const value = part.slice(spaceIdx + 1); - if (key === 'url') result.url = value; - else if (key === 'image') result.thumbnail = value; - else if (key === 'dim') result.dim = value; - else if (key === 'blurhash') result.blurhash = value; + if (key === "url") result.url = value; + else if (key === "image") result.thumbnail = value; + else if (key === "dim") result.dim = value; + else if (key === "blurhash") result.blurhash = value; } return result; } @@ -160,23 +201,34 @@ function parseImeta(tags: string[][]): { url?: string; thumbnail?: string; dim?: /** Formats a timestamp into a full date string like "Feb 16, 2026, 2:53 PM". */ function formatFullDate(timestamp: number): string { const date = new Date(timestamp * 1000); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: '2-digit', + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", hour12: true, }); } -export function PostDetailPage({ eventId, relays, authorHint }: PostDetailPageProps) { +export function PostDetailPage({ + eventId, + relays, + authorHint, +}: PostDetailPageProps) { const { config } = useAppContext(); - const { data: event, isLoading, isError } = useEvent(eventId, relays, authorHint); + const { + data: event, + isLoading, + isError, + } = useEvent(eventId, relays, authorHint); const [retryEvent, setRetryEvent] = useState(null); useSeoMeta({ - title: (event || retryEvent) ? `Post Details - ${config.appName}` : `Loading... - ${config.appName}`, + title: + event || retryEvent + ? `Post Details - ${config.appName}` + : `Loading... - ${config.appName}`, }); const resolvedEvent = event || retryEvent; @@ -194,7 +246,7 @@ export function PostDetailPage({ eventId, relays, authorHint }: PostDetailPagePr return ( @@ -223,7 +275,7 @@ export function AddrPostDetailPage({ addr, relays }: AddrPostDetailPageProps) { useSeoMeta({ title: resolvedEvent - ? `${resolvedEvent.tags.find(([n]) => n === 'title')?.[1] || resolvedEvent.tags.find(([n]) => n === 'name')?.[1] || loadingTitle} - ${config.appName}` + ? `${resolvedEvent.tags.find(([n]) => n === "title")?.[1] || resolvedEvent.tags.find(([n]) => n === "name")?.[1] || loadingTitle} - ${config.appName}` : `${loadingTitle} - ${config.appName}`, }); @@ -239,7 +291,7 @@ export function AddrPostDetailPage({ addr, relays }: AddrPostDetailPageProps) { return ( @@ -297,7 +349,7 @@ export function AddrPostDetailPage({ addr, relays }: AddrPostDetailPageProps) { } return ( - + @@ -305,14 +357,22 @@ export function AddrPostDetailPage({ addr, relays }: AddrPostDetailPageProps) { ); } -export function PostDetailShell({ children, title = 'Post Details' }: { children: React.ReactNode; title?: string }) { +export function PostDetailShell({ + children, + title = "Post Details", +}: { + children: React.ReactNode; + title?: string; +}) { const navigate = useNavigate(); return (
); @@ -369,7 +425,9 @@ function AuthorHintRow({ pubkey }: { pubkey: string }) { return (
- Author + + Author + {author.isLoading ? ( <> @@ -386,12 +444,21 @@ function AuthorHintRow({ pubkey }: { pubkey: string }) { {author.data?.event ? ( - {displayName} - ) : displayName} + + {displayName} + + ) : ( + displayName + )} {metadata?.nip05 && ( - + )} @@ -410,54 +477,60 @@ function EventNotFound({ onEventFound: (event: NostrEvent) => void; }) { const { nostr } = useNostr(); - const [relayUrl, setRelayUrl] = useState(''); + const [relayUrl, setRelayUrl] = useState(""); const [isRetrying, setIsRetrying] = useState(false); const [retryError, setRetryError] = useState(null); const [retryOpen, setRetryOpen] = useState(false); // Extract author pubkey from context if available - const authorPubkey = context.type === 'event' ? context.authorHint : context.addr.pubkey; + const authorPubkey = + context.type === "event" ? context.authorHint : context.addr.pubkey; - const handleRetry = useCallback(async (targetUrl: string) => { - const url = targetUrl.trim(); - if (!url) return; + const handleRetry = useCallback( + async (targetUrl: string) => { + const url = targetUrl.trim(); + if (!url) return; - if (!url.startsWith('wss://') && !url.startsWith('ws://')) { - setRetryError('Relay URL must start with wss:// or ws://'); - return; - } - - setIsRetrying(true); - setRetryError(null); - - try { - const relay = nostr.relay(url); - const signal = AbortSignal.timeout(8000); - - let filter; - if (context.type === 'event') { - filter = [{ ids: [context.eventId], limit: 1 }]; - } else { - filter = [{ - kinds: [context.addr.kind], - authors: [context.addr.pubkey], - '#d': [context.addr.identifier], - limit: 1, - }]; + if (!url.startsWith("wss://") && !url.startsWith("ws://")) { + setRetryError("Relay URL must start with wss:// or ws://"); + return; } - const events = await relay.query(filter, { signal }); - if (events.length > 0) { - onEventFound(events[0]); - } else { - setRetryError(`Event not found on ${url}`); + setIsRetrying(true); + setRetryError(null); + + try { + const relay = nostr.relay(url); + const signal = AbortSignal.timeout(8000); + + let filter; + if (context.type === "event") { + filter = [{ ids: [context.eventId], limit: 1 }]; + } else { + filter = [ + { + kinds: [context.addr.kind], + authors: [context.addr.pubkey], + "#d": [context.addr.identifier], + limit: 1, + }, + ]; + } + + const events = await relay.query(filter, { signal }); + if (events.length > 0) { + onEventFound(events[0]); + } else { + setRetryError(`Event not found on ${url}`); + } + } catch { + setRetryError(`Failed to connect to ${url}`); + } finally { + setIsRetrying(false); } - } catch { - setRetryError(`Failed to connect to ${url}`); - } finally { - setIsRetrying(false); - } - }, [nostr, context, onEventFound]); + }, + [nostr, context, onEventFound], + ); return (
@@ -480,7 +553,7 @@ function EventNotFound({

Event details

- {context.type === 'event' ? ( + {context.type === "event" ? (
ID @@ -490,16 +563,22 @@ function EventNotFound({ ) : (
- Kind + + Kind + {context.addr.kind}
- Pubkey + + Pubkey +
{context.addr.identifier && (
- d-tag + + d-tag +
)} @@ -508,7 +587,9 @@ function EventNotFound({ {context.relays && context.relays.length > 0 && (
Hints - {context.relays.join(', ')} + + {context.relays.join(", ")} +
)} {authorPubkey && } @@ -518,7 +599,9 @@ function EventNotFound({ @@ -531,7 +614,7 @@ function EventNotFound({ className="flex-1 font-mono text-xs h-9" disabled={isRetrying} onKeyDown={(e) => { - if (e.key === 'Enter') handleRetry(relayUrl); + if (e.key === "Enter") handleRetry(relayUrl); }} /> {(isReposted: boolean) => ( )} @@ -1111,279 +1298,373 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
- - + +
)} {/* Main post — expanded Ditto-style view */} - {!isReaction &&
- {/* Author row */} -
- {author.isLoading ? ( - <> - -
- - -
- - ) : ( - <> - - - - - - {displayName[0].toUpperCase()} - - - - - -
+ {!isReaction && ( +
+ {/* Author row */} +
+ {author.isLoading ? ( + <> + +
+ + +
+ + ) : ( + <> - - {author.data?.event ? ( - {displayName} - ) : displayName} + + + + + {displayName[0].toUpperCase()} + + - {nip05 && ( - + +
+ + + {author.data?.event ? ( + + {displayName} + + ) : ( + displayName + )} + + + {nip05 && ( + + )} +
+ + {metadata?.bot && ( + + 🤖 + )} -
- - {metadata?.bot && ( - 🤖 - )} - {isColor && } - - )} -
- - {/* Comment context for kind 1111 */} - {event.kind === 1111 && } - - {/* Star rating for book reviews (kind 31985) */} - {event.kind === BOOK_REVIEW_KIND && } - - {/* Post content — kind-based dispatch, guarded by NIP-36 content-warning */} - - {isVideo ? ( - - ) : isArticle ? ( - - ) : isMagicDeck ? ( - - ) : isFileMetadata ? ( - - ) : isTheme ? ( - - ) : isVoiceMessage ? ( - - ) : isCommunity ? ( - - ) : isVine || isPoll || isGeocache || isFoundLog || isColor || isFollowPack || isEmojiPack ? ( - <> - {isVine && } - {isPoll && } - {isGeocache && } - {isFoundLog && } - {isColor && } - {isFollowPack && } - {isEmojiPack && } - - ) : ( - <> -
- -
- {videos.map((url, i) => ( - - ))} - {audios.map((url, i) => ( - - ))} - {webxdcApps.map((app) => ( - - ))} - - )} -
- - {/* Stats row: "2 Reposts 1 👍" left, "Feb 16, 2026, 6:44 PM" right — Ditto style */} - {hasStats && ( -
- {stats?.reposts ? ( - - ) : null} - {stats?.quotes ? ( - - ) : null} - {stats?.reactions ? ( - - ) : null} - {stats?.zapCount ? ( - - ) : null} - - {clientTag?.[1] && (() => { - const clientValue = clientTag[1]; - let isHostname = false; - try { - const url = new URL(`https://${clientValue}`); - isHostname = url.hostname === clientValue; - } catch { - isHostname = false; - } - return ( - <> - {isHostname ? ( - e.stopPropagation()} - > - {clientValue} - - ) : ( - {clientValue} - )} - · - - ); - })()} - {formatFullDate(event.created_at)} - -
- )} - - {/* Date-only row if no stats */} - {!hasStats && ( -
- {clientTag?.[1] && ( - <> - {clientTag?.[1]} - · + {isColor && } )} - {formatFullDate(event.created_at)}
- )} - {/* Action buttons — Ditto style: distributed across full width */} -
- {/* Reply */} - + {/* Comment context for kind 1111 */} + {event.kind === 1111 && } - {/* Repost */} - - {(isReposted: boolean) => ( - - )} - - - {/* React */} - - - {/* Zap */} - {canZapAuthor && ( - - - + {/* Star rating for book reviews (kind 31985) */} + {event.kind === BOOK_REVIEW_KIND && ( + )} - {/* Share */} - + {/* Post content — kind-based dispatch, guarded by NIP-36 content-warning */} + + {isVideo ? ( + + ) : isArticle ? ( + + ) : isMagicDeck ? ( + + ) : isFileMetadata ? ( + + ) : isTheme ? ( + + ) : isVoiceMessage ? ( + + ) : isCommunity ? ( + + ) : isGitRepo ? ( +
+ +
+ ) : isPatch ? ( +
+ +
+ ) : isPullRequest ? ( +
+ +
+ ) : isCustomNip ? ( +
+ +
+ ) : isVine || + isPoll || + isGeocache || + isFoundLog || + isColor || + isFollowPack || + isEmojiPack ? ( + <> + {isVine && } + {isPoll && } + {isGeocache && } + {isFoundLog && } + {isColor && } + {isFollowPack && } + {isEmojiPack && } + + ) : ( + <> +
+ +
+ {videos.map((url, i) => ( + + ))} + {audios.map((url, i) => ( + + ))} + {webxdcApps.map((app) => ( + + ))} + + )} +
- {/* More */} - -
+ {/* Stats row: "2 Reposts 1 👍" left, "Feb 16, 2026, 6:44 PM" right — Ditto style */} + {hasStats && ( +
+ {stats?.reposts ? ( + + ) : null} + {stats?.quotes ? ( + + ) : null} + {stats?.reactions ? ( + + ) : null} + {stats?.zapCount ? ( + + ) : null} + + {clientTag?.[1] && + (() => { + const clientValue = clientTag[1]; + let isHostname = false; + try { + const url = new URL(`https://${clientValue}`); + isHostname = url.hostname === clientValue; + } catch { + isHostname = false; + } + return ( + <> + {isHostname ? ( + e.stopPropagation()} + > + {clientValue} + + ) : ( + {clientValue} + )} + · + + ); + })()} + {formatFullDate(event.created_at)} + +
+ )} - - - -
} + {/* Date-only row if no stats */} + {!hasStats && ( +
+ {clientTag?.[1] && ( + <> + {clientTag?.[1]} + · + + )} + {formatFullDate(event.created_at)} +
+ )} + + {/* Action buttons — Ditto style: distributed across full width */} +
+ {/* Reply */} + + + {/* Repost */} + + {(isReposted: boolean) => ( + + )} + + + {/* React */} + + + {/* Zap */} + {canZapAuthor && ( + + + + )} + + {/* Share */} + + + {/* More */} + +
+ + + + + + )} {/* Replies */}
@@ -1410,18 +1691,33 @@ function PostDetailContent({ event }: { event: NostrEvent }) { * Recursively fetches parent -> grandparent -> ... -> root, then renders * them top-down with thread connector lines. */ -function AncestorThread({ eventId, depth = 0, collapseAfter }: { eventId: string; depth?: number; collapseAfter?: number }) { +function AncestorThread({ + eventId, + depth = 0, + collapseAfter, +}: { + eventId: string; + depth?: number; + collapseAfter?: number; +}) { const { data: event, isLoading } = useEvent(eventId); const [expanded, setExpanded] = useState(false); // Determine this ancestor's own parent - const parentId = useMemo(() => event ? getParentEventId(event) : undefined, [event]); + const parentId = useMemo( + () => (event ? getParentEventId(event) : undefined), + [event], + ); // Cap recursion to avoid runaway chains const MAX_DEPTH = 20; // When collapseAfter is set and we've reached the limit, collapse remaining ancestors - const shouldCollapse = collapseAfter !== undefined && depth >= collapseAfter && parentId && !expanded; + const shouldCollapse = + collapseAfter !== undefined && + depth >= collapseAfter && + parentId && + !expanded; if (isLoading) { return ( @@ -1451,8 +1747,9 @@ function AncestorThread({ eventId, depth = 0, collapseAfter }: { eventId: string return ( <> {/* Render this event's parent first (if any), so ancestors appear top-down */} - {parentId && depth < MAX_DEPTH && ( - shouldCollapse ? ( + {parentId && + depth < MAX_DEPTH && + (shouldCollapse ? ( ) : ( - - ) - )} + + ))} ); } - export function PostDetailSkeleton() { return (
diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index 17631334..6f94a5c4 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -1,11 +1,11 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { createHead, UnheadProvider } from '@unhead/react/client'; -import { BrowserRouter } from 'react-router-dom'; -import { NostrLoginProvider } from '@nostrify/react/login'; -import NostrProvider from '@/components/NostrProvider'; -import { AppProvider } from '@/components/AppProvider'; -import { NWCProvider } from '@/contexts/NWCContext'; -import { AppConfig } from '@/contexts/AppContext'; +import { NostrLoginProvider } from "@nostrify/react/login"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createHead, UnheadProvider } from "@unhead/react/client"; +import { BrowserRouter } from "react-router-dom"; +import { AppProvider } from "@/components/AppProvider"; +import NostrProvider from "@/components/NostrProvider"; +import type { AppConfig } from "@/contexts/AppContext"; +import { NWCProvider } from "@/contexts/NWCContext"; interface TestAppProps { children: React.ReactNode; @@ -22,16 +22,14 @@ export function TestApp({ children }: TestAppProps) { }); const defaultConfig: AppConfig = { - appName: 'Ditto', - appId: 'ditto', - homePage: 'feed', - theme: 'light', + appName: "Ditto", + appId: "ditto", + homePage: "feed", + theme: "light", autoShareTheme: true, useAppRelays: true, relayMetadata: { - relays: [ - { url: 'wss://relay.primal.net', read: true, write: true }, - ], + relays: [{ url: "wss://relay.primal.net", read: true, write: true }], updatedAt: 0, }, feedSettings: { @@ -81,6 +79,8 @@ export function TestApp({ children }: TestAppProps) { showPodcasts: false, feedIncludePodcastEpisodes: false, feedIncludePodcastTrailers: false, + showDevelopment: false, + feedIncludeDevelopment: false, showBadges: false, showBadgeDefinitions: true, showProfileBadges: true, @@ -89,30 +89,33 @@ export function TestApp({ children }: TestAppProps) { followsFeedShowReplies: true, }, sidebarOrder: [], - nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea', - blossomServers: ['https://blossom.ditto.pub/', 'https://blossom.dreamith.to/', 'https://blossom.primal.net/'], - faviconUrl: 'https://fetch.ditto.pub/favicon/{hostname}', - linkPreviewUrl: 'https://fetch.ditto.pub/link/{url}', - corsProxy: 'https://proxy.shakespeare.diy/?url={href}', + nip85StatsPubkey: + "5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea", + blossomServers: [ + "https://blossom.ditto.pub/", + "https://blossom.dreamith.to/", + "https://blossom.primal.net/", + ], + faviconUrl: "https://fetch.ditto.pub/favicon/{hostname}", + linkPreviewUrl: "https://fetch.ditto.pub/link/{url}", + corsProxy: "https://proxy.shakespeare.diy/?url={href}", magicMouse: false, - contentWarningPolicy: 'blur', - sentryDsn: '', + contentWarningPolicy: "blur", + sentryDsn: "", sentryEnabled: false, - plausibleDomain: '', - plausibleEndpoint: '', + plausibleDomain: "", + plausibleEndpoint: "", savedFeeds: [], }; return ( - + - + - - {children} - + {children} @@ -122,4 +125,4 @@ export function TestApp({ children }: TestAppProps) { ); } -export default TestApp; \ No newline at end of file +export default TestApp;