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:
Generated
+25
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+650
-254
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
<{committerTag[2]}>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
+853
-552
File diff suppressed because it is too large
Load Diff
+33
-30
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user