Merge branch 'feature/development-feed' into 'main'

Development feed with NostrHub-style cards and Shakespeare integration

See merge request soapbox-pub/ditto!38
This commit is contained in:
Alex Gleason
2026-03-13 21:46:01 +00:00
15 changed files with 3076 additions and 1226 deletions
+25
View File
@@ -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": [
+53 -35
View File
@@ -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() {
<SentryProvider>
<PlausibleProvider>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='nostr:login'>
<NostrLoginProvider storageKey="nostr:login">
<NostrProvider>
<NostrSync />
<NativeNotifications />
+185 -106
View File
@@ -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 <Navigate to="/" replace />;
return <Navigate to={profileUrl} replace />;
}
export function AppRouter() {
return (
<AudioPlayerProvider>
<BrowserRouter>
<MinimizedAudioBar />
<AudioNavigationGuard />
<ScrollToTop />
<Routes>
{/* All routes share the persistent MainLayout (sidebar + nav) */}
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/feed" element={<Index />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/trends" element={<TrendsPage />} />
<Route path="/profile" element={<ProfileRedirect />} />
<Route path="/t/:tag" element={<HashtagPage />} />
<Route path="/feed/:domain" element={<DomainFeedPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/profile" element={<ProfileSettings />} />
<Route path="/settings/feed" element={<ContentSettingsPage />} />
<Route path="/settings/content" element={<ContentPage />} />
<Route path="/settings/wallet" element={<WalletSettingsPage />} />
<Route path="/settings/notifications" element={<NotificationSettings />} />
<Route path="/settings/advanced" element={<AdvancedSettingsPage />} />
<Route path="/settings/magic" element={<MagicSettingsPage />} />
<Route path="/settings/network" element={<NetworkSettingsPage />} />
<Route path="/lists" element={<UserListsPage />} />
<Route path="/events" element={<EventsFeedPage />} />
<Route path="/photos" element={<PhotosFeedPage />} />
<Route path="/videos" element={<VideosFeedPage />} />
{/* /streams redirects to /videos for backward compatibility */}
<Route path="/streams" element={<Navigate to="/videos" replace />} />
<Route path="/vines" element={<VinesFeedPage />} />
<Route path="/music" element={<MusicFeedPage />} />
<Route path="/podcasts" element={<PodcastsFeedPage />} />
<Route path="/polls" element={<KindFeedPage kind={pollsDef.kind} title={pollsDef.label} icon={sidebarItemIcon('polls', 'size-5')} />} />
<Route path="/treasures" element={<TreasuresPage />} />
<Route path="/colors" element={<KindFeedPage kind={colorsDef.kind} title={colorsDef.label} icon={sidebarItemIcon('colors', 'size-5')} />} />
<Route path="/packs" element={<KindFeedPage kind={packsDef.kind} title={packsDef.label} icon={sidebarItemIcon('packs', 'size-5')} />} />
<Route path="/webxdc" element={<WebxdcFeedPage />} />
<Route path="/articles" element={<KindFeedPage kind={articlesDef.kind} title={articlesDef.label} icon={sidebarItemIcon('articles', 'size-5')} />} />
<Route path="/decks" element={<KindFeedPage kind={decksDef.kind} title={decksDef.label} icon={sidebarItemIcon('decks', 'size-5')} />} />
<Route path="/emojis" element={<KindFeedPage kind={emojisDef.kind} title={emojisDef.label} icon={sidebarItemIcon('emojis', 'size-5')} />} />
<Route path="/themes" element={<ThemesPage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/world" element={<WorldPage />} />
<Route path="/badges" element={<BadgesFeedPage />} />
<Route path="/books" element={<BooksPage />} />
<Route path="/help" element={<HelpPage />} />
<Route path="/r/*" element={<RelayPage />} />
<Route path="/settings/lists" element={<Navigate to="/lists" replace />} />
<Route path="/i/*" element={<ExternalContentPage />} />
<BrowserRouter>
<MinimizedAudioBar />
<AudioNavigationGuard />
<ScrollToTop />
<Routes>
{/* All routes share the persistent MainLayout (sidebar + nav) */}
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/feed" element={<Index />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/trends" element={<TrendsPage />} />
<Route path="/profile" element={<ProfileRedirect />} />
<Route path="/t/:tag" element={<HashtagPage />} />
<Route path="/feed/:domain" element={<DomainFeedPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/profile" element={<ProfileSettings />} />
<Route path="/settings/feed" element={<ContentSettingsPage />} />
<Route path="/settings/content" element={<ContentPage />} />
<Route path="/settings/wallet" element={<WalletSettingsPage />} />
<Route
path="/settings/notifications"
element={<NotificationSettings />}
/>
<Route
path="/settings/advanced"
element={<AdvancedSettingsPage />}
/>
<Route path="/settings/magic" element={<MagicSettingsPage />} />
<Route path="/settings/network" element={<NetworkSettingsPage />} />
<Route path="/lists" element={<UserListsPage />} />
<Route path="/events" element={<EventsFeedPage />} />
<Route path="/photos" element={<PhotosFeedPage />} />
<Route path="/videos" element={<VideosFeedPage />} />
{/* /streams redirects to /videos for backward compatibility */}
<Route
path="/streams"
element={<Navigate to="/videos" replace />}
/>
<Route path="/vines" element={<VinesFeedPage />} />
<Route path="/music" element={<MusicFeedPage />} />
<Route path="/podcasts" element={<PodcastsFeedPage />} />
<Route
path="/polls"
element={
<KindFeedPage
kind={pollsDef.kind}
title={pollsDef.label}
icon={sidebarItemIcon("polls", "size-5")}
/>
}
/>
<Route path="/treasures" element={<TreasuresPage />} />
<Route
path="/colors"
element={
<KindFeedPage
kind={colorsDef.kind}
title={colorsDef.label}
icon={sidebarItemIcon("colors", "size-5")}
/>
}
/>
<Route
path="/packs"
element={
<KindFeedPage
kind={packsDef.kind}
title={packsDef.label}
icon={sidebarItemIcon("packs", "size-5")}
/>
}
/>
<Route path="/webxdc" element={<WebxdcFeedPage />} />
<Route
path="/articles"
element={
<KindFeedPage
kind={articlesDef.kind}
title={articlesDef.label}
icon={sidebarItemIcon("articles", "size-5")}
/>
}
/>
<Route
path="/decks"
element={
<KindFeedPage
kind={decksDef.kind}
title={decksDef.label}
icon={sidebarItemIcon("decks", "size-5")}
/>
}
/>
<Route
path="/emojis"
element={
<KindFeedPage
kind={emojisDef.kind}
title={emojisDef.label}
icon={sidebarItemIcon("emojis", "size-5")}
/>
}
/>
<Route
path="/development"
element={
<KindFeedPage
kind={[
developmentDef.kind,
...(developmentDef.extraFeedKinds ?? []),
]}
title={developmentDef.label}
icon={sidebarItemIcon("development", "size-5")}
showFAB={false}
/>
}
/>
<Route path="/themes" element={<ThemesPage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/world" element={<WorldPage />} />
<Route path="/badges" element={<BadgesFeedPage />} />
<Route path="/books" element={<BooksPage />} />
<Route path="/help" element={<HelpPage />} />
<Route path="/r/*" element={<RelayPage />} />
<Route
path="/settings/lists"
element={<Navigate to="/lists" replace />}
/>
<Route path="/i/*" element={<ExternalContentPage />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</BrowserRouter>
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</BrowserRouter>
</AudioPlayerProvider>
);
}
+145
View File
@@ -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 (
<div className="space-y-3 mt-1">
{/* Title */}
<div className="flex items-start gap-2">
<FileCode className="size-5 text-primary shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<span className="font-semibold text-base leading-tight">
{displayTitle}
</span>
{preview && contentPreview && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-3 leading-relaxed">
{contentPreview}
</p>
)}
</div>
</div>
{/* Full markdown content — detail view only */}
{!preview && event.content && (
<div className="prose prose-sm dark:prose-invert max-w-none prose-headings:font-bold prose-a:text-primary prose-img:rounded-lg">
<Markdown rehypePlugins={[rehypeSanitize]}>{event.content}</Markdown>
</div>
)}
{/* Related Kinds section */}
{relatedKinds.length > 0 && (
<div className="space-y-1.5">
<h4 className="text-sm font-medium text-muted-foreground">Kinds</h4>
<div className="flex flex-wrap gap-1.5">
{relatedKinds
.slice(0, preview ? 6 : relatedKinds.length)
.map((k) => (
<Badge key={k} variant="secondary" className="text-xs">
Kind {k}
</Badge>
))}
{preview && relatedKinds.length > 6 && (
<Badge
variant="outline"
className="text-xs text-muted-foreground"
>
+{relatedKinds.length - 6} more
</Badge>
)}
</div>
</div>
)}
{/* Action buttons */}
{hasShakespeare && (
<div className="flex flex-wrap gap-2 pt-1">
<button
type="button"
className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
onClick={(e) => {
e.stopPropagation();
window.open(
"https://shakespeare.diy",
"_blank",
"noopener,noreferrer",
);
}}
>
<Wand2 className="size-4" />
Edit with Shakespeare
</button>
</div>
)}
</div>
);
}
+189
View File
@@ -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 (
<div className="space-y-3 mt-1">
{/* Header: icon/favicon + title */}
<div className="flex items-start gap-3">
{isApp && faviconUrl && !faviconError ? (
<img
src={faviconUrl}
alt=""
className="size-10 rounded-lg object-cover shrink-0"
loading="lazy"
onError={() => setFaviconError(true)}
/>
) : (
<GitBranch className="size-5 text-primary shrink-0 mt-0.5" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-base leading-tight">
{displayName}
</span>
{isPersonalFork && (
<Badge variant="outline" className="text-xs px-2 py-0">
Fork
</Badge>
)}
</div>
{description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2 leading-relaxed">
{description}
</p>
)}
</div>
</div>
{/* Tags section */}
{hashtags.length > 0 && (
<div className="space-y-1.5">
<h4 className="text-sm font-medium text-muted-foreground">Tags</h4>
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, 6).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
{hashtags.length > 6 && (
<Badge
variant="outline"
className="text-xs text-muted-foreground"
>
+{hashtags.length - 6} more
</Badge>
)}
</div>
</div>
)}
{/* Clone URL section — hidden for apps */}
{!isApp && cloneUrls[0] && (
<div className="space-y-1.5">
<h4 className="text-sm font-medium text-muted-foreground">Clone</h4>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-2.5 py-1.5 rounded-md text-xs font-mono truncate">
{cloneUrls[0]}
</code>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={(e) => {
e.stopPropagation();
handleCopy(cloneUrls[0]);
}}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex flex-wrap gap-2 pt-1">
{hasShakespeare && (
<button
type="button"
className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
onClick={(e) => {
e.stopPropagation();
window.open(shakespeareUrl, "_blank", "noopener,noreferrer");
}}
>
<Wand2 className="size-4" />
Edit with Shakespeare
</button>
)}
{isApp ? (
<button
type="button"
className="flex items-center gap-2 px-4 py-2.5 rounded-full border text-sm font-medium hover:bg-secondary/60 transition-colors"
onClick={(e) => {
e.stopPropagation();
window.open(webUrls[0], "_blank", "noopener,noreferrer");
}}
>
<ExternalLink className="size-4" />
Open App
</button>
) : webUrls[0] ? (
<button
type="button"
className="flex items-center gap-2 px-4 py-2.5 rounded-full border text-sm font-medium hover:bg-secondary/60 transition-colors"
onClick={(e) => {
e.stopPropagation();
window.open(webUrls[0], "_blank", "noopener,noreferrer");
}}
>
<Globe className="size-4" />
Browse Repository
</button>
) : !hasShakespeare && cloneUrls[0] ? (
<button
type="button"
className="flex items-center gap-2 px-4 py-2.5 rounded-full border text-sm font-medium hover:bg-secondary/60 transition-colors"
onClick={(e) => {
e.stopPropagation();
handleCopy(cloneUrls[0]);
}}
>
<Copy className="size-4" />
Copy Clone URL
</button>
) : null}
</div>
</div>
);
}
+358 -198
View File
@@ -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 (
<OnboardingContext.Provider value={contextValue}>
<SyncScreen phase={phase} />
@@ -101,7 +106,7 @@ export function InitialSyncGate({ children }: InitialSyncGateProps) {
);
}
if (phase === 'not-found') {
if (phase === "not-found") {
return (
<OnboardingContext.Provider value={contextValue}>
{preloadApp && <div className="invisible">{children}</div>}
@@ -144,17 +149,19 @@ function SyncScreen({ phase }: { phase: SyncPhase }) {
<div className="space-y-1.5">
<p className="text-sm font-medium text-foreground">
{phase === 'found' ? 'Settings restored' : 'Syncing your settings...'}
{phase === "found"
? "Settings restored"
: "Syncing your settings..."}
</p>
<p className="text-xs text-muted-foreground">
{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"}
</p>
</div>
</div>
{phase === 'syncing' && (
{phase === "syncing" && (
<div className="flex gap-1.5">
{[0, 1, 2].map((i) => (
<div
@@ -166,7 +173,7 @@ function SyncScreen({ phase }: { phase: SyncPhase }) {
</div>
)}
{phase === 'found' && (
{phase === "found" && (
<div className="flex items-center gap-2 text-primary">
<Check className="w-4 h-4" />
<span className="text-sm font-medium">All set</span>
@@ -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<boolean | null>(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 }: {
<div className="flex-1 flex flex-col overflow-y-auto">
<div className="w-full max-w-md mx-auto my-auto px-6 py-12">
{/* Signup steps */}
{step === 'keygen' && (
<KeygenStep onGenerate={handleGenerate} />
)}
{step === "keygen" && <KeygenStep onGenerate={handleGenerate} />}
{step === 'download' && (
{step === "download" && (
<DownloadStep nsec={nsec} onDownload={handleDownloadAndLogin} />
)}
{step === 'profile' && (
{step === "profile" && (
<ProfileStep onNext={handleSaveAndContinue} isSaving={isSaving} />
)}
{/* Settings steps */}
{step === 'theme' && (
{step === "theme" && (
<ThemeStep
onNext={isSignup ? next : handleSaveAndContinue}
onBack={back}
isFirst={isSignup && steps.indexOf('theme') === 0}
isFirst={isSignup && steps.indexOf("theme") === 0}
isSaving={!isSignup && isSaving}
/>
)}
{step === 'follows' && hasFollows === false && (
{step === "follows" && hasFollows === false && (
<FollowsStep
onNext={(didFollow) => {
if (didFollow) onPreload();
goTo('outro');
goTo("outro");
}}
onBack={back}
/>
)}
{step === 'outro' && (
<OutroStep onComplete={onComplete} />
)}
{step === "outro" && <OutroStep onComplete={onComplete} />}
</div>
</div>
</div>
@@ -448,7 +469,8 @@ function KeygenStep({ onGenerate }: { onGenerate: () => void }) {
Create your account
</h1>
<p className="text-muted-foreground text-sm leading-relaxed max-w-xs mx-auto">
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.
</p>
</div>
@@ -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 (
<div className="flex flex-col gap-6 animate-in fade-in slide-in-from-right-4 duration-400">
<div className="space-y-2">
<h2 className="text-xl font-semibold tracking-tight">Save your secret key</h2>
<h2 className="text-xl font-semibold tracking-tight">
Save your secret key
</h2>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
<div className="relative">
<Input
type={showKey ? 'text' : 'password'}
type={showKey ? "text" : "password"}
value={nsec}
readOnly
className="pr-10 font-mono text-sm"
@@ -503,7 +534,8 @@ function DownloadStep({ nsec, onDownload }: { nsec: string; onDownload: () => vo
Important
</p>
<p className="text-xs text-amber-900 dark:text-amber-300">
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.
</p>
</div>
@@ -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<HTMLInputElement>(null);
const pendingField = useRef<'picture' | 'banner'>('picture');
const pendingField = useRef<"picture" | "banner">("picture");
const [profileData, setProfileData] = useState<Partial<NostrMetadata>>({
name: '', about: '', picture: '', banner: '', website: '',
name: "",
about: "",
picture: "",
banner: "",
website: "",
});
const [extraFields, setExtraFields] = useState<Array<{ label: string; value: string }>>([]);
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<string, unknown> = { ...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
<div className="flex items-center gap-4">
<IntroImage src="/profile-intro.png" />
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">Set up your profile</h2>
<h2 className="text-xl font-semibold tracking-tight">
Set up your profile
</h2>
<p className="text-sm text-muted-foreground">
Tell people a bit about yourself. You can always change this later.
</p>
</div>
</div>
<input ref={pickInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChosen} />
<input
ref={pickInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChosen}
/>
{cropState && (
<ImageCropDialog
open
imageSrc={cropState.imageSrc}
aspect={cropState.aspect}
title={cropState.field === 'picture' ? 'Crop Profile Picture' : 'Crop Banner'}
title={
cropState.field === "picture"
? "Crop Profile Picture"
: "Crop Banner"
}
onCancel={handleCropCancel}
onCrop={handleCropConfirm}
/>
)}
<div className={cn(isPublishing && 'opacity-50 pointer-events-none')}>
<div className={cn(isPublishing && "opacity-50 pointer-events-none")}>
<ProfileCard
metadata={profileData}
onChange={(patch) => 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
)}
<div className="flex gap-3">
<Button variant="ghost" onClick={onNext} className="flex-1 rounded-full h-11" disabled={isPublishing || isSaving}>
<Button
variant="ghost"
onClick={onNext}
className="flex-1 rounded-full h-11"
disabled={isPublishing || isSaving}
>
Skip
</Button>
<Button onClick={handlePublishProfile} className="flex-1 rounded-full h-11 gap-1.5" disabled={isPublishing || isUploading || isSaving}>
{(isPublishing || isSaving) ? <><Loader2 className="w-4 h-4 animate-spin" /> Saving…</> : <>Continue <ChevronRight className="w-4 h-4" /></>}
<Button
onClick={handlePublishProfile}
className="flex-1 rounded-full h-11 gap-1.5"
disabled={isPublishing || isUploading || isSaving}
>
{isPublishing || isSaving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" /> Saving…
</>
) : (
<>
Continue <ChevronRight className="w-4 h-4" />
</>
)}
</Button>
</div>
</div>
@@ -665,36 +768,65 @@ function ThemeStep({
)}
{/* Center content — semi-transparent on desktop when bg is active */}
<div className={cn(
'relative z-10 flex flex-col gap-6 animate-in fade-in slide-in-from-right-4 duration-400',
'sm:rounded-2xl sm:transition-[background-color,backdrop-filter] sm:duration-700',
bgUrl ? 'sm:bg-background/60 sm:backdrop-blur-md sm:-mx-4 sm:px-4 sm:py-4' : '',
)}>
<div
className={cn(
"relative z-10 flex flex-col gap-6 animate-in fade-in slide-in-from-right-4 duration-400",
"sm:rounded-2xl sm:transition-[background-color,backdrop-filter] sm:duration-700",
bgUrl
? "sm:bg-background/60 sm:backdrop-blur-md sm:-mx-4 sm:px-4 sm:py-4"
: "",
)}
>
<div className="space-y-2">
<h2 className="text-xl font-semibold tracking-tight">Choose your look</h2>
<p className="text-sm text-muted-foreground">Pick a theme that feels right.</p>
<h2 className="text-xl font-semibold tracking-tight">
Choose your look
</h2>
<p className="text-sm text-muted-foreground">
Pick a theme that feels right.
</p>
</div>
<ThemeGrid columns="scroll" limit={9} />
{isFirst ? (
<Button onClick={onNext} className="w-full rounded-full h-11 gap-1.5" disabled={isSaving}>
<Button
onClick={onNext}
className="w-full rounded-full h-11 gap-1.5"
disabled={isSaving}
>
{isSaving ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Saving...</>
<>
<Loader2 className="w-4 h-4 animate-spin" /> Saving...
</>
) : (
<>Continue <ChevronRight className="w-4 h-4" /></>
<>
Continue <ChevronRight className="w-4 h-4" />
</>
)}
</Button>
) : (
<div className="flex gap-3">
<Button variant="ghost" onClick={onBack} className="flex-1 rounded-full h-11" disabled={isSaving}>
<Button
variant="ghost"
onClick={onBack}
className="flex-1 rounded-full h-11"
disabled={isSaving}
>
Back
</Button>
<Button onClick={onNext} className="flex-1 rounded-full h-11 gap-1.5" disabled={isSaving}>
<Button
onClick={onNext}
className="flex-1 rounded-full h-11 gap-1.5"
disabled={isSaving}
>
{isSaving ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Saving...</>
<>
<Loader2 className="w-4 h-4 animate-spin" /> Saving...
</>
) : (
<>Continue <ChevronRight className="w-4 h-4" /></>
<>
Continue <ChevronRight className="w-4 h-4" />
</>
)}
</Button>
</div>
@@ -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 (
<div className="flex flex-col gap-6 animate-in fade-in slide-in-from-right-4 duration-400">
<div className="space-y-2">
<h2 className="text-xl font-semibold tracking-tight">Find your people</h2>
<h2 className="text-xl font-semibold tracking-tight">
Find your people
</h2>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
@@ -812,7 +968,8 @@ function FollowsStep({ onNext, onBack }: { onNext: (didFollow: boolean) => void;
))
) : packs.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
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.
</p>
) : (
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"}
<ChevronRight className="w-4 h-4" />
</Button>
</div>
@@ -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({
<div className="min-w-0">
<h3 className="font-semibold text-sm leading-snug">{title}</h3>
{description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{description}</p>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{description}
</p>
)}
</div>
<span className="text-xs text-muted-foreground flex items-center gap-1 shrink-0 mt-0.5">
@@ -908,7 +1070,7 @@ function PackCard({
<Button
className="w-full gap-2"
size="sm"
variant={isFollowed ? 'outline' : 'default'}
variant={isFollowed ? "outline" : "default"}
onClick={onFollowAll}
disabled={isFollowed || isFollowing}
>
@@ -977,7 +1139,10 @@ function PackCardSkeleton() {
</div>
<div className="flex -space-x-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="size-7 rounded-full ring-2 ring-background" />
<Skeleton
key={i}
className="size-7 rounded-full ring-2 ring-background"
/>
))}
</div>
<Skeleton className="h-8 w-full rounded-md" />
@@ -1000,12 +1165,10 @@ function OutroStep({ onComplete }: { onComplete: () => void }) {
</div>
<div className="space-y-3 max-w-xs">
<h2 className="text-2xl font-bold tracking-tight">
You're all set
</h2>
<h2 className="text-2xl font-bold tracking-tight">You're all set</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
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.
</p>
</div>
@@ -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
</Button>
<Button
onClick={onNext}
className="flex-1 rounded-full h-11 gap-1.5"
>
<Button onClick={onNext} className="flex-1 rounded-full h-11 gap-1.5">
{nextLabel}
<ChevronRight className="w-4 h-4" />
</Button>
File diff suppressed because it is too large Load Diff
+235
View File
@@ -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 (
<div className="space-y-3 mt-1">
{/* Title + status badges */}
<div className="flex items-start gap-2">
<FileText className="size-5 text-orange-500 shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-base leading-tight line-clamp-2">
{subject || "Patch"}
</span>
{isRoot && (
<Badge
variant="outline"
className="text-xs px-2 py-0 border-orange-500/30 text-orange-500"
>
root
</Badge>
)}
{isRevision && (
<Badge
variant="outline"
className="text-xs px-2 py-0 border-orange-500/30 text-orange-500"
>
revision
</Badge>
)}
</div>
{repoName && (
<p className="text-sm text-muted-foreground mt-1">{repoName}</p>
)}
</div>
</div>
{/* Commit metadata — detail view only */}
{!preview && (commitId || parentCommit || committerTag) && (
<div className="space-y-2">
{commitId && (
<div className="flex items-center gap-2 text-sm">
<GitCommit className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Commit</span>
<code className="bg-muted px-2 py-0.5 rounded text-xs font-mono">
{commitId.slice(0, 12)}
</code>
</div>
)}
{parentCommit && (
<div className="flex items-center gap-2 text-sm">
<GitCommit className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Parent</span>
<code className="bg-muted px-2 py-0.5 rounded text-xs font-mono">
{parentCommit.slice(0, 12)}
</code>
</div>
)}
{committerTag && (
<div className="flex items-center gap-2 text-sm">
<User className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Committer</span>
<span>{committerTag[1]}</span>
{committerTag[2] && (
<span className="text-muted-foreground">
&lt;{committerTag[2]}&gt;
</span>
)}
</div>
)}
</div>
)}
{/* Commit message — detail view only */}
{!preview && commitMessage && (
<div className="space-y-1.5">
<h4 className="text-sm font-medium text-muted-foreground">Message</h4>
<div className="bg-muted/50 rounded-lg px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap break-words">
{commitMessage}
</div>
</div>
)}
{/* Diff — detail view only */}
{!preview && diff && (
<div className="space-y-1.5">
<h4 className="text-sm font-medium text-muted-foreground">Diff</h4>
<div className="bg-muted/50 rounded-lg overflow-x-auto">
<pre className="px-4 py-3 text-xs font-mono leading-relaxed overflow-x-auto">
{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
<div key={i} className={lineClass}>
{line}
</div>
);
})}
</pre>
</div>
</div>
)}
{/* Tags section */}
{hashtags.length > 0 && (
<div className="space-y-1.5">
<h4 className="text-sm font-medium text-muted-foreground">Tags</h4>
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, preview ? 6 : hashtags.length).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
{preview && hashtags.length > 6 && (
<Badge
variant="outline"
className="text-xs text-muted-foreground"
>
+{hashtags.length - 6} more
</Badge>
)}
</div>
</div>
)}
{/* Action buttons */}
{hasShakespeare && (
<div className="flex flex-wrap gap-2 pt-1">
<button
type="button"
className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
onClick={(e) => {
e.stopPropagation();
window.open(
"https://shakespeare.diy",
"_blank",
"noopener,noreferrer",
);
}}
>
<Wand2 className="size-4" />
Edit with Shakespeare
</button>
</div>
)}
</div>
);
}
+201
View File
@@ -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 (
<div className="space-y-3 mt-1">
{/* Title + branch info */}
<div className="flex items-start gap-2">
<GitPullRequest className="size-5 text-green-500 shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<span className="font-semibold text-base leading-tight line-clamp-2">
{title}
</span>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
{repoName && <span>{repoName}</span>}
{repoName && branchName && (
<span className="text-muted-foreground/50">/</span>
)}
{branchName && (
<Badge
variant="secondary"
className="text-xs font-mono px-2 py-0"
>
{branchName}
</Badge>
)}
</div>
</div>
</div>
{/* Branch & commit metadata — detail view only */}
{!preview && (commitTip || mergeBase || branchName) && (
<div className="space-y-2">
{branchName && (
<div className="flex items-center gap-2 text-sm">
<GitBranch className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Branch</span>
<code className="bg-muted px-2 py-0.5 rounded text-xs font-mono">
{branchName}
</code>
</div>
)}
{commitTip && (
<div className="flex items-center gap-2 text-sm">
<GitCommit className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Tip</span>
<code className="bg-muted px-2 py-0.5 rounded text-xs font-mono">
{commitTip.slice(0, 12)}
</code>
</div>
)}
{mergeBase && (
<div className="flex items-center gap-2 text-sm">
<GitCommit className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Base</span>
<code className="bg-muted px-2 py-0.5 rounded text-xs font-mono">
{mergeBase.slice(0, 12)}
</code>
</div>
)}
</div>
)}
{/* PR description — detail view only, rendered as markdown */}
{!preview && hasDescription && (
<div className="space-y-1.5">
<h4 className="text-sm font-medium text-muted-foreground">
Description
</h4>
<div className="prose prose-sm dark:prose-invert max-w-none prose-headings:font-bold prose-a:text-primary prose-img:rounded-lg">
<Markdown rehypePlugins={[rehypeSanitize]}>
{event.content}
</Markdown>
</div>
</div>
)}
{/* Labels section */}
{labels.length > 0 && (
<div className="space-y-1.5">
<h4 className="text-sm font-medium text-muted-foreground">Labels</h4>
<div className="flex flex-wrap gap-1.5">
{labels.slice(0, preview ? 6 : labels.length).map((label) => (
<Badge key={label} variant="secondary" className="text-xs">
{label}
</Badge>
))}
{preview && labels.length > 6 && (
<Badge
variant="outline"
className="text-xs text-muted-foreground"
>
+{labels.length - 6} more
</Badge>
)}
</div>
</div>
)}
{/* Clone URL section */}
{cloneUrls[0] && (
<div className="space-y-1.5">
<h4 className="text-sm font-medium text-muted-foreground">Clone</h4>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-2.5 py-1.5 rounded-md text-xs font-mono truncate">
{cloneUrls[0]}
</code>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={(e) => {
e.stopPropagation();
handleCopy(cloneUrls[0]);
}}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex flex-wrap gap-2 pt-1">
{hasShakespeare && (
<button
type="button"
className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
onClick={(e) => {
e.stopPropagation();
window.open(shakespeareUrl, "_blank", "noopener,noreferrer");
}}
>
<Wand2 className="size-4" />
Edit with Shakespeare
</button>
)}
{!hasShakespeare && cloneUrls[0] && (
<button
type="button"
className="flex items-center gap-2 px-4 py-2.5 rounded-full border text-sm font-medium hover:bg-secondary/60 transition-colors"
onClick={(e) => {
e.stopPropagation();
handleCopy(cloneUrls[0]);
}}
>
<Copy className="size-4" />
Copy Clone URL
</button>
)}
</div>
</div>
);
}
+7 -1
View File
@@ -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<AppConfig>) => Partial<AppConfig>) => void;
updateConfig: (
updater: (currentConfig: Partial<AppConfig>) => Partial<AppConfig>,
) => void;
}
export const AppContext = createContext<AppContextType | undefined>(undefined);
+18 -2
View File
@@ -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<ExtraKindSection, string> = {
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. */
+2
View File
@@ -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). */
+122 -48
View File
@@ -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<string, IconComponent> = {
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 <Icon className={size} />;
}
@@ -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;
}
File diff suppressed because it is too large Load Diff
+33 -30
View File
@@ -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 (
<UnheadProvider head={head}>
<AppProvider storageKey='test-app-config' defaultConfig={defaultConfig}>
<AppProvider storageKey="test-app-config" defaultConfig={defaultConfig}>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='test-login'>
<NostrLoginProvider storageKey="test-login">
<NostrProvider>
<NWCProvider>
<BrowserRouter>
{children}
</BrowserRouter>
<BrowserRouter>{children}</BrowserRouter>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
@@ -122,4 +125,4 @@ export function TestApp({ children }: TestAppProps) {
);
}
export default TestApp;
export default TestApp;