Merge branch 'main' into update-hatch-action

This commit is contained in:
filemon
2026-04-02 06:17:41 -03:00
37 changed files with 4626 additions and 88 deletions
+1
View File
@@ -108,6 +108,7 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
- Use present tense ("Add dark mode toggle", not "Added dark mode toggle")
- Focus on what the user sees/experiences, not internal implementation details
- Use the current date in YYYY-MM-DD format
- **Never use Nostr protocol jargon.** NIP numbers (e.g., "NIP-89", "NIP-17"), kind numbers (e.g., "kind 30078"), and other protocol-level references must not appear in the changelog. Describe the feature in plain language from the user's perspective. For example, write "App cards for Nostr apps" instead of "App cards for Nostr apps (NIP-89)". The changelog audience is end users, not protocol developers.
- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
- **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers.
- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes.
+37
View File
@@ -716,6 +716,43 @@ await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newT
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
### D-Tag Collision Prevention for Addressable Events
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
#### When to Check for Collisions
**Must check before publishing** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.). **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
#### Implementation Pattern
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
```typescript
// Before publishing a new addressable event:
const slug = slugify(title, { lower: true, strict: true });
const existing = await nostr.query([
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
]);
if (existing.length > 0) {
toast({
title: 'Slug already in use',
description: 'Change the slug or edit the existing item.',
variant: 'destructive',
});
return;
}
// Safe to publish
publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
```
**Skip the check in edit mode** -- when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
### Nostr Login
To enable login with Nostr, simply use the `LoginArea` component already included in this project.
+33 -6
View File
@@ -1,5 +1,32 @@
# Changelog
## [2.3.0] - 2026-04-02
### Added
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
### Fixed
- Custom emoji no longer stretch to fill their container
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
## [2.2.11] - 2026-04-02
### Fixed
- Fix crash caused by the "What's new" toast firing outside the router
## [2.2.10] - 2026-04-02
### Added
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
### Changed
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
### Fixed
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
## [2.2.9] - 2026-04-01
### Added
@@ -36,7 +63,7 @@
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
### Changed
- Post action buttons extracted into a reusable PostActionBar component
@@ -91,11 +118,11 @@
## [2.2.2] - 2026-03-29
### Added
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
- Dedicated photo upload flow for sharing photos
- Pull-to-refresh on all feed pages
- 3D tilt effect on badge images -- hover over badges to see them pop
- Multi-select badge awarding with indicators for already-sent badges
- Badge list recovery dialog for restoring kind 10008 profile badge lists
- Badge list recovery dialog for restoring profile badge lists
- Compact badge row preview in embedded profile badges events
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
- Release notes now included in Zapstore publishing
@@ -114,7 +141,7 @@
- Double-tap reactions now properly show the emoji on the post
- Emoji shortcode autocomplete text and highlight colors
- Profile skeleton no longer flickers for brand-new users with no metadata
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
- Event links now route correctly for all event types
- Badge notifications are now clickable
- Custom profile tab form no longer retains fields from a previously edited tab
- Double line under profile tabs in edit mode
@@ -141,10 +168,10 @@
- Blobbi shop and inventory system with items that affect your pet's stats
- Daily missions with reroll, care streaks, and stage-based rewards
- Immersive full-screen divines experience on both mobile and desktop with floating controls
- NIP-11 relay information panel on the network settings page
- Relay information panel on the network settings page
- Link preview cards now display inside quoted posts instead of raw URLs
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
- Remote signer UX improvements for Amber and NIP-46 users on Android
- Remote signer UX improvements for Amber users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.2.9"
versionName "2.3.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2 -2
View File
@@ -303,7 +303,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.2.9;
MARKETING_VERSION = 2.3.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -325,7 +325,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.2.9;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+1853 -20
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.2.9",
"version": "2.3.0",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -53,6 +53,17 @@
"@fontsource/special-elite": "^5.2.8",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@milkdown/core": "^7.20.0",
"@milkdown/ctx": "^7.20.0",
"@milkdown/plugin-clipboard": "^7.20.0",
"@milkdown/plugin-history": "^7.20.0",
"@milkdown/plugin-listener": "^7.20.0",
"@milkdown/plugin-upload": "^7.20.0",
"@milkdown/preset-commonmark": "^7.20.0",
"@milkdown/preset-gfm": "^7.20.0",
"@milkdown/prose": "^7.20.0",
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.0",
"@nostrify/react": "^0.4.0",
"@nostrify/types": "^0.36.9",
@@ -117,6 +128,7 @@
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"rehype-sanitize": "^6.0.0",
"slugify": "^1.6.8",
"smol-toml": "^1.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
+33 -6
View File
@@ -1,5 +1,32 @@
# Changelog
## [2.3.0] - 2026-04-02
### Added
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
### Fixed
- Custom emoji no longer stretch to fill their container
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
## [2.2.11] - 2026-04-02
### Fixed
- Fix crash caused by the "What's new" toast firing outside the router
## [2.2.10] - 2026-04-02
### Added
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
### Changed
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
### Fixed
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
## [2.2.9] - 2026-04-01
### Added
@@ -36,7 +63,7 @@
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
### Changed
- Post action buttons extracted into a reusable PostActionBar component
@@ -91,11 +118,11 @@
## [2.2.2] - 2026-03-29
### Added
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
- Dedicated photo upload flow for sharing photos
- Pull-to-refresh on all feed pages
- 3D tilt effect on badge images -- hover over badges to see them pop
- Multi-select badge awarding with indicators for already-sent badges
- Badge list recovery dialog for restoring kind 10008 profile badge lists
- Badge list recovery dialog for restoring profile badge lists
- Compact badge row preview in embedded profile badges events
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
- Release notes now included in Zapstore publishing
@@ -114,7 +141,7 @@
- Double-tap reactions now properly show the emoji on the post
- Emoji shortcode autocomplete text and highlight colors
- Profile skeleton no longer flickers for brand-new users with no metadata
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
- Event links now route correctly for all event types
- Badge notifications are now clickable
- Custom profile tab form no longer retains fields from a previously edited tab
- Double line under profile tabs in edit mode
@@ -141,10 +168,10 @@
- Blobbi shop and inventory system with items that affect your pet's stats
- Daily missions with reroll, care streaks, and stage-based rewards
- Immersive full-screen divines experience on both mobile and desktop with floating controls
- NIP-11 relay information panel on the network settings page
- Relay information panel on the network settings page
- Link preview cards now display inside quoted posts instead of raw URLs
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
- Remote signer UX improvements for Amber and NIP-46 users on Android
- Remote signer UX improvements for Amber users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
+3 -4
View File
@@ -16,8 +16,8 @@ import NostrProvider from "@/components/NostrProvider";
import { NostrSync } from "@/components/NostrSync";
import { PlausibleProvider } from "@/components/PlausibleProvider";
import { SentryProvider } from "@/components/SentryProvider";
import { VersionCheck } from "@/components/VersionCheck";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import type { AppConfig } from "@/contexts/AppContext";
@@ -201,12 +201,11 @@ export function App() {
<NostrProvider>
<NostrSync />
<NativeNotifications />
<VersionCheck />
<NWCProvider>
<DMProvider config={dmConfig}>
<EmotionDevProvider>
<TooltipProvider>
<Toaster />
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
+8
View File
@@ -6,8 +6,10 @@ import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { BlobbiActionsProvider } from "@/blobbi/companion/interaction/BlobbiActionsProvider";
import { sidebarItemIcon } from "@/lib/sidebarItems";
import { Toaster } from "./components/ui/toaster";
import { MainLayout } from "./components/MainLayout";
import { ScrollToTop } from "./components/ScrollToTop";
import { VersionCheck } from "./components/VersionCheck";
import { useCurrentUser } from "./hooks/useCurrentUser";
import { useProfileUrl } from "./hooks/useProfileUrl";
import { getExtraKindDef } from "./lib/extraKinds";
@@ -32,6 +34,7 @@ const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.H
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
const BlobbiPage = lazy(() => import("./pages/BlobbiPage").then(m => ({ default: m.BlobbiPage })));
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
@@ -136,6 +139,8 @@ export function AppRouter() {
return (
<AudioPlayerProvider>
<BrowserRouter>
<Toaster />
<VersionCheck />
<MinimizedAudioBar />
<AudioNavigationGuard />
<DeepLinkHandler />
@@ -207,6 +212,8 @@ export function AppRouter() {
}
/>
<Route path="/webxdc" element={<WebxdcFeedPage />} />
<Route path="/articles/new" element={<ArticleEditorPage />} />
<Route path="/articles/edit/:naddr" element={<ArticleEditorPage />} />
<Route
path="/articles"
element={
@@ -214,6 +221,7 @@ export function AppRouter() {
kind={articlesDef.kind}
title={articlesDef.label}
icon={sidebarItemIcon("articles", "size-5")}
fabHref="/articles/new"
/>
}
/>
+316
View File
@@ -0,0 +1,316 @@
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { ExternalLink, GitFork, Package } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { Skeleton } from '@/components/ui/skeleton';
import { useLinkPreview } from '@/hooks/useLinkPreview';
import { NostrURI } from '@/lib/NostrURI';
import { cn } from '@/lib/utils';
/** Get a tag value by name. */
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
/** Get all values for a tag name. */
function getAllTags(tags: string[][], name: string): string[] {
return tags.filter(([n]) => n === name).map(([, v]) => v);
}
/** Parse kind-0-style metadata from the content field. */
function parseHandlerMetadata(content: string): NostrMetadata {
if (!content) return {};
try {
return JSON.parse(content) as NostrMetadata;
} catch {
return {};
}
}
/** Get the website URL from web handler tags or metadata. */
function getWebsiteUrl(tags: string[][], metadata: NostrMetadata): string | undefined {
const webTags = tags.filter(([n]) => n === 'web');
for (const tag of webTags) {
const url = tag[1];
if (url) {
const base = url.replace(/<bech32>/g, '').replace(/\/+$/, '');
return base;
}
}
return metadata.website;
}
/** Extract the display domain from a URL. */
function displayDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
/** Build a Shakespeare "Edit with Shakespeare" URL from a kind 30617 `a` tag, if present. */
function getShakespeareUrl(tags: string[][]): string | undefined {
for (const tag of tags) {
if (tag[0] !== 'a') continue;
const parts = tag[1]?.split(':');
if (!parts || parts[0] !== '30617' || parts.length < 3) continue;
const pubkey = parts[1];
const identifier = parts.slice(2).join(':');
const nostrUri = new NostrURI({ pubkey, identifier }).toString();
return `https://shakespeare.diy/clone?url=${encodeURIComponent(nostrUri)}`;
}
return undefined;
}
interface AppHandlerContentProps {
event: NostrEvent;
/** If true, show compact preview (used in NoteCard feed). */
compact?: boolean;
}
/** Renders a kind 31990 NIP-89 application handler event as a showcase-style card. */
export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
const metadata = useMemo(() => parseHandlerMetadata(event.content), [event.content]);
const name = metadata.name || getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unknown App';
const about = metadata.about;
const picture = metadata.picture;
const websiteUrl = getWebsiteUrl(event.tags, metadata);
const hashtags = getAllTags(event.tags, 't');
const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]);
const { data: preview, isLoading: previewLoading } = useLinkPreview(websiteUrl ?? null);
const thumbnailUrl = preview?.thumbnail_url;
const [imgError, setImgError] = useState(false);
const showThumbnail = thumbnailUrl && !imgError;
if (compact) {
return (
<div className="mt-2">
<div className="rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
{/* Screenshot hero — only shown while loading or when a thumbnail exists */}
{(previewLoading || showThumbnail) && (
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
{previewLoading ? (
<Skeleton className="absolute inset-0" />
) : (
<img
src={thumbnailUrl}
alt={name}
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
onError={() => setImgError(true)}
/>
)}
</div>
)}
{/* Content */}
<div className="relative z-10 px-3.5 pb-3.5 space-y-2">
{/* App icon — overlaps the screenshot hero like a profile avatar */}
<div className={showThumbnail || previewLoading ? '-mt-7' : 'pt-3.5'}>
{picture ? (
<img
src={picture}
alt={name}
className="size-14 rounded-xl object-cover shrink-0 border-3 border-background bg-background shadow-sm"
loading="lazy"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none';
}}
/>
) : (
<div className="size-14 rounded-xl bg-primary/10 flex items-center justify-center shrink-0 border-3 border-background shadow-sm">
<Package className="size-6 text-primary/50" />
</div>
)}
</div>
{/* Name + domain */}
<div className="min-w-0">
<h3 className="font-semibold text-[15px] leading-snug truncate">{name}</h3>
{websiteUrl && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<ExternalFavicon url={websiteUrl} size={12} />
<span className="truncate">{displayDomain(websiteUrl)}</span>
</div>
)}
</div>
{/* Description */}
{about && (
<p className="text-sm text-muted-foreground line-clamp-2">{about}</p>
)}
{/* Tags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, 4).map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
className="text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
#{tag}
</Link>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2">
{websiteUrl && (
<Button asChild size="sm" className="h-7 text-xs">
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Open App
<ExternalLink className="size-3 ml-1.5" />
</a>
</Button>
)}
{shakespeareUrl && (
<Button asChild variant="secondary" size="sm" className="h-7 text-xs">
<a href={shakespeareUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Fork
<GitFork className="size-3 ml-1" />
</a>
</Button>
)}
</div>
</div>
</div>
</div>
);
}
// Full detail view
return (
<div className="mt-3">
<div className="rounded-xl border border-border overflow-hidden">
{/* Screenshot hero — only shown while loading or when a thumbnail exists */}
{(previewLoading || showThumbnail) && (
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
{previewLoading ? (
<Skeleton className="absolute inset-0" />
) : (
<img
src={thumbnailUrl}
alt={name}
className="size-full object-cover"
loading="lazy"
onError={() => setImgError(true)}
/>
)}
</div>
)}
{/* Content */}
<div className="relative z-10 px-4 pb-4 space-y-3">
{/* App icon — overlaps the screenshot hero like a profile avatar */}
<div className={cn(
'flex items-end justify-between',
showThumbnail || previewLoading ? '-mt-10' : 'pt-4',
)}>
{picture ? (
<img
src={picture}
alt={name}
className="size-20 rounded-2xl object-cover shrink-0 border-4 border-background bg-background shadow-sm"
loading="lazy"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none';
}}
/>
) : (
<div className="size-20 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0 border-4 border-background shadow-sm">
<Package className="size-8 text-primary/50" />
</div>
)}
</div>
{/* Name + domain */}
<div className="min-w-0">
<h2 className="text-xl font-semibold leading-snug truncate">{name}</h2>
{websiteUrl && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<ExternalFavicon url={websiteUrl} size={14} />
<span className="truncate">{displayDomain(websiteUrl)}</span>
</div>
)}
</div>
{/* Description */}
{about && (
<p className="text-sm text-muted-foreground leading-relaxed">{about}</p>
)}
{/* Tags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
className="text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
#{tag}
</Link>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
{websiteUrl && (
<Button asChild size="sm">
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Open App
<ExternalLink className="size-3 ml-1.5" />
</a>
</Button>
)}
{shakespeareUrl && (
<Button asChild variant="secondary" size="sm">
<a href={shakespeareUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Fork
<GitFork className="size-3.5 ml-1.5" />
</a>
</Button>
)}
</div>
</div>
</div>
</div>
);
}
/** Skeleton loading state for AppHandlerContent. */
export function AppHandlerSkeleton() {
return (
<div className="mt-3">
<div className="rounded-xl border border-border overflow-hidden">
<Skeleton className="aspect-[2/1] w-full" />
<div className="px-4 pb-4 space-y-3">
<div className="-mt-10">
<Skeleton className="size-20 rounded-2xl border-4 border-background" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3 w-24" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</div>
</div>
</div>
);
}
+12
View File
@@ -115,6 +115,7 @@ const KIND_LABELS: Record<number, string> = {
30817: 'a custom NIP',
31922: 'a calendar event',
31923: 'a calendar event',
31990: 'an app',
32267: 'an app',
34139: 'a playlist',
34236: 'a divine',
@@ -157,6 +158,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
30063: Package,
30311: Radio,
30617: GitBranch,
31990: Package,
32267: Package,
34236: Clapperboard,
36767: Sparkles,
@@ -228,6 +230,16 @@ function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.Co
return { text: siteName ? `${siteName} nsite` : 'an nsite', icon };
}
// NIP-89 apps: name lives in JSON content, not in tags
if (event.kind === 31990) {
try {
const meta = JSON.parse(event.content);
const appName = meta?.name || event.tags.find(([n]) => n === 'name')?.[1];
if (appName) return { text: `${appName} app`, icon };
} catch { /* fall through */ }
return { text: 'an app', icon };
}
// Extract a title-like string from tags
const title = event.tags.find(([name]) => name === 'title')?.[1];
const name = event.tags.find(([name]) => name === 'name')?.[1];
+23 -16
View File
@@ -224,6 +224,26 @@ export function ComposeBox({
const voiceRecorder = useVoiceRecorder();
const [isPublishingVoice, setIsPublishingVoice] = useState(false);
const resetComposeState = useCallback(() => {
setContent('');
setCwEnabled(false);
setCwText('');
setExpanded(false);
setPickerOpen(false);
setTrayOpen(false);
setInternalPreviewMode(false);
setMode(initialMode);
setPollQuestion('');
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
setPollType('singlechoice');
setPollDuration(7);
setRemovedEmbeds(new Set());
setUploadedFileTags([]);
setUploadedFileGroups(new Map());
setWebxdcUuids(new Map());
setWebxdcMetas(new Map());
}, [initialMode]);
// Use controlled preview mode if provided, otherwise use internal state
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
@@ -928,15 +948,7 @@ export function ComposeBox({
});
}
setContent('');
setCwEnabled(false);
setCwText('');
setExpanded(false);
setRemovedEmbeds(new Set());
setUploadedFileTags([]);
setUploadedFileGroups(new Map());
setWebxdcUuids(new Map());
setWebxdcMetas(new Map());
resetComposeState();
// Optimistically bump the reply count on the parent event
if (replyTo && !(replyTo instanceof URL)) {
queryClient.setQueryData<EventStats>(['event-stats', replyTo.id], (prev) =>
@@ -984,12 +996,7 @@ export function ComposeBox({
try {
await createEvent({ kind: 1068, content: pollQuestion.trim(), tags });
// Reset poll state
setMode('post');
setPollQuestion('');
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
setPollType('singlechoice');
setPollDuration(7);
resetComposeState();
queryClient.invalidateQueries({ queryKey: ['feed'] });
toast({ title: 'Poll published!' });
onSuccess?.();
@@ -1007,7 +1014,7 @@ export function ComposeBox({
if (!user && compact) return null;
return (
<div className={cn("px-4 py-3 bg-background/50")}>
<div className={cn("px-4 py-3 bg-background/85")}>
{/* Preview toggle at top when not controlled and has previewable content */}
{hasPreviewableContent && controlledPreviewMode === undefined && (
<div className="flex items-center justify-end mb-3">
+3 -3
View File
@@ -15,7 +15,7 @@ interface CustomEmojiImgProps {
/**
* Renders a single custom emoji as an inline image.
*/
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] align-text-bottom' }: CustomEmojiImgProps) {
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom' }: CustomEmojiImgProps) {
return (
<img
src={url}
@@ -52,7 +52,7 @@ export function ReactionEmoji({ content, tags, className }: ReactionEmojiProps)
const url = getCustomEmojiUrl(emoji, tags);
if (url) {
const name = emoji.slice(1, -1);
return <CustomEmojiImg name={name} url={url} className={className ?? 'inline h-[1.2em] w-[1.2em] align-text-bottom'} />;
return <CustomEmojiImg name={name} url={url} className={className ?? 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom'} />;
}
}
@@ -70,7 +70,7 @@ export function ReactionEmoji({ content, tags, className }: ReactionEmojiProps)
*/
export function RenderResolvedEmoji({ emoji, className }: { emoji: ResolvedEmoji; className?: string }) {
if (emoji.url && emoji.name) {
return <CustomEmojiImg name={emoji.name} url={emoji.url} className={className ?? 'inline h-[1.2em] w-[1.2em] align-middle'} />;
return <CustomEmojiImg name={emoji.name} url={emoji.url} className={className ?? 'inline h-[1.2em] w-[1.2em] object-contain align-middle'} />;
}
return <span className={cn('inline-block leading-none', className)}>{emoji.content}</span>;
}
+2 -1
View File
@@ -1082,6 +1082,7 @@ function hasVideo(tags: string[][]): boolean {
/** Fallback labels for well-known kinds not in EXTRA_KINDS. */
const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
31990: 'App',
32267: 'App',
30063: 'Release',
15128: 'Nsite',
@@ -1109,7 +1110,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey
const KindIcon = useMemo(() => {
if (kindDef?.id) return CONTENT_KIND_ICONS[kindDef.id] ?? FileText;
// Fallback icons for well-known kinds not in EXTRA_KINDS
if (addr.kind === 32267 || addr.kind === 30063) return Package;
if (addr.kind === 31990 || addr.kind === 32267 || addr.kind === 30063) return Package;
if (addr.kind === 15128 || addr.kind === 35128) return Globe;
return FileText;
}, [kindDef, addr.kind]);
+8 -4
View File
@@ -108,10 +108,14 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const { startSignup } = useOnboarding();
// Kind-specific pages only support Follows + Global. Clamp any other
// persisted tab (e.g. 'ditto', 'communities') back to 'follows'.
const activeTab: FeedTab = kinds && rawActiveTab !== 'follows' && rawActiveTab !== 'global'
? 'follows'
: rawActiveTab;
// persisted tab (e.g. 'ditto', 'communities') back to the appropriate default.
// Logged-out users must land on 'global' since 'follows' requires a user.
const activeTab: FeedTab = (() => {
if (!kinds) return rawActiveTab; // Home feed: no clamping
if (rawActiveTab === 'global') return 'global';
if (rawActiveTab === 'follows' && user) return 'follows';
return user ? 'follows' : 'global';
})();
// Is the active tab a saved feed?
const activeSavedFeed = useMemo(
+1 -1
View File
@@ -184,7 +184,7 @@ function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) {
{/* Emoji group header */}
<div className="flex items-center gap-2 px-4 py-2 bg-secondary/30 sticky top-0 z-[1]">
{customUrl && customName ? (
<CustomEmojiImg name={customName} url={customUrl} className="inline-block h-6 w-6" />
<CustomEmojiImg name={customName} url={customUrl} className="inline-block h-6 w-6 object-contain" />
) : (
<span className="text-lg">{emoji}</span>
)}
+8 -3
View File
@@ -1,7 +1,12 @@
import { Link } from 'react-router-dom';
interface LinkFooterProps {
/** Optional callback fired when an internal (React Router) link is clicked. */
onNavigate?: () => void;
}
/** Shared footer links used in both sidebars. */
export function LinkFooter() {
export function LinkFooter({ onNavigate }: LinkFooterProps) {
return (
<footer className="mt-auto pt-4 pb-4 text-left bg-background/85 rounded-xl p-3 -mx-1">
<p className="text-xs text-muted-foreground">
@@ -23,7 +28,7 @@ export function LinkFooter() {
Docs
</a>
{' · '}
<Link to="/privacy" className="text-primary hover:underline">
<Link to="/privacy" className="text-primary hover:underline" onClick={onNavigate}>
Privacy
</Link>
{' · '}
@@ -36,7 +41,7 @@ export function LinkFooter() {
Source
</a>
{' · '}
<Link to="/changelog" className="text-primary hover:underline">
<Link to="/changelog" className="text-primary hover:underline" onClick={onNavigate}>
Changelog
</Link>
{' · '}
+2 -2
View File
@@ -319,7 +319,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
</nav>
<div className="px-2">
<LinkFooter />
<LinkFooter onNavigate={handleClose} />
</div>
</div>
) : (
@@ -363,7 +363,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
</nav>
<div className="px-2">
<LinkFooter />
<LinkFooter onNavigate={handleClose} />
</div>
</div>
)}
+13 -4
View File
@@ -74,6 +74,7 @@ import { EncryptedMessageContent } from "@/components/EncryptedMessageContent";
import { EncryptedLetterContent } from "@/components/EncryptedLetterContent";
import { VanishCardCompact } from "@/components/VanishEventContent";
import { ZapstoreAppContent } from "@/components/ZapstoreAppContent";
import { AppHandlerContent } from "@/components/AppHandlerContent";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getAvatarShape } from "@/lib/avatarShape";
import { Badge } from "@/components/ui/badge";
@@ -306,6 +307,7 @@ export const NoteCard = memo(function NoteCard({
const isCustomNip = event.kind === 30817;
const isNsite = event.kind === 15128 || event.kind === 35128;
const isZapstoreApp = event.kind === 32267;
const isAppHandler = event.kind === 31990;
const isEncryptedDM = event.kind === 4;
const isLetter = event.kind === 8211;
const isVanish = event.kind === 62;
@@ -336,6 +338,7 @@ export const NoteCard = memo(function NoteCard({
!isAudioKind &&
!isDevKind &&
!isZapstoreApp &&
!isAppHandler &&
!isEncryptedDM &&
!isLetter &&
!isVanish &&
@@ -532,6 +535,8 @@ export const NoteCard = memo(function NoteCard({
<NsiteCard event={event} />
) : isZapstoreApp ? (
<ZapstoreAppContent event={event} compact />
) : isAppHandler ? (
<AppHandlerContent event={event} compact />
) : isEncryptedDM ? (
<EncryptedMessageContent event={event} compact />
) : isLetter ? (
@@ -789,11 +794,11 @@ export const NoteCard = memo(function NoteCard({
<div className="flex gap-3">
<div className="flex flex-col items-center">
{/* Reaction emoji bubble instead of avatar */}
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0">
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0 text-lg leading-none">
<ReactionEmoji
content={event.content}
tags={event.tags}
className="text-lg leading-none"
className="h-5 w-5 object-contain"
/>
</div>
{threaded && (
@@ -870,11 +875,11 @@ export const NoteCard = memo(function NoteCard({
>
<div className="flex items-center gap-3">
{/* Large reaction emoji */}
<div className="flex items-center justify-center size-11 rounded-full bg-pink-500/10 shrink-0">
<div className="flex items-center justify-center size-11 rounded-full bg-pink-500/10 shrink-0 text-xl leading-none">
<ReactionEmoji
content={event.content}
tags={event.tags}
className="text-xl leading-none"
className="h-6 w-6 object-contain"
/>
</div>
@@ -2002,6 +2007,10 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
icon: Package,
action: "published an app",
},
31990: {
icon: Package,
action: "published an app",
},
30617: {
icon: GitBranch,
action: "shared a",
+1 -1
View File
@@ -545,7 +545,7 @@ export function NoteContent({
{groupedTokens.map((token, i) => {
switch (token.type) {
case 'text':
return <span key={i}>{linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 align-text-bottom' : undefined))}</span>;
return <span key={i}>{linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 object-contain align-text-bottom' : undefined))}</span>;
case 'image-embed': {
if (disableEmbeds) {
// In preview contexts (e.g. triple-dot menu), replace image URLs
+1 -1
View File
@@ -186,7 +186,7 @@ export function ReactionButton({
{filledHeart ? (
<Heart className="size-6" fill={hasReacted ? 'currentColor' : 'none'} />
) : hasReacted && userReaction ? (
<RenderResolvedEmoji emoji={userReaction} className="size-5 leading-none translate-y-px" />
<RenderResolvedEmoji emoji={userReaction} className="h-5 w-5 object-contain leading-none translate-y-px" />
) : (
<Heart className="size-5" />
)}
+5 -2
View File
@@ -19,8 +19,11 @@ async function fetchChangelogExcerpt(version: string): Promise<string | undefine
const entry = entries.find((e) => e.version === version) ?? entries[0];
if (!entry) return undefined;
// Return the first item from the first section.
return entry.sections[0]?.items[0];
// Return a truncated first item from the first section.
const item = entry.sections[0]?.items[0];
if (!item) return undefined;
if (item.length <= 60) return item;
return item.slice(0, 60).trimEnd() + '…';
} catch {
return undefined;
}
+904
View File
@@ -0,0 +1,904 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import {
ArrowLeft,
Image,
Save,
Send,
Loader2,
Hash,
FileText,
X,
Clock,
Cloud,
HardDrive,
Trash2,
ChevronRight,
} from 'lucide-react';
import slugify from 'slugify';
import { useNostr } from '@nostrify/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { FabButton } from '@/components/FabButton';
import { toast } from '@/hooks/useToast';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDrafts, type Draft } from '@/hooks/useDrafts';
import { usePublishedArticles } from '@/hooks/usePublishedArticles';
import { saveDraft as saveLocalDraft, deleteDraftBySlug, deleteLocalDraftById, getLocalDrafts } from '@/lib/localDrafts';
import type { ArticleFields } from '@/lib/articleHelpers';
import { MilkdownEditor } from './MilkdownEditor';
export type ArticleData = ArticleFields;
interface ArticleEditorProps {
/** Pre-filled data for editing an existing article or loading a draft. */
initialData?: Partial<ArticleData> & { publishedAt?: number };
/** Whether the editor is in edit mode (updating an existing article). */
editMode?: boolean;
}
type EditorTab = 'write' | 'drafts';
export function ArticleEditor({ initialData, editMode = false }: ArticleEditorProps) {
const navigate = useNavigate();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutate: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { drafts: relayDrafts, isLoading: isDraftsLoading, saveDraft: saveRelayDraft, deleteDraft: deleteRelayDraft, isDeleting } = useDrafts();
const { articles: publishedArticles } = usePublishedArticles();
const fileInputRef = useRef<HTMLInputElement>(null);
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [activeTab, setActiveTab] = useState<EditorTab>('write');
const [localDrafts, setLocalDrafts] = useState<Draft[]>([]);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; slug: string; isLocal: boolean } | null>(null);
const [tagInput, setTagInput] = useState('');
const slugManuallyEdited = useRef(!!initialData?.slug);
const [isPublished, setIsPublished] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [showLeaveDialog, setShowLeaveDialog] = useState(false);
const [isEditMode, setIsEditMode] = useState(editMode);
const [originalPublishedAt, setOriginalPublishedAt] = useState<number | null>(
initialData?.publishedAt ?? null,
);
const [article, setArticle] = useState<ArticleData>({
title: initialData?.title || '',
summary: initialData?.summary || '',
content: initialData?.content || '',
image: initialData?.image || '',
tags: initialData?.tags || [],
slug: initialData?.slug || '',
});
// Keep a ref to the latest article data so the auto-save timer doesn't
// need `article` in its dependency array (which would reset it on every keystroke).
const articleRef = useRef(article);
articleRef.current = article;
const mountedRef = useRef(true);
useEffect(() => () => { mountedRef.current = false; }, []);
/** Save draft to relay (with localStorage fallback). Shared by manual save + auto-save. */
const persistDraft = useCallback(async (data: ArticleData, { silent }: { silent?: boolean } = {}) => {
if (user) {
try {
await saveRelayDraft(data);
if (!mountedRef.current) return;
setLastSaved(new Date());
setHasUnsavedChanges(false);
if (!silent) {
toast({ title: 'Draft saved', description: 'Your article has been saved to Nostr relays.' });
}
} catch (error) {
console.error('Failed to save draft to relay:', error);
saveLocalDraft(data);
if (!mountedRef.current) return;
setLastSaved(new Date());
setHasUnsavedChanges(false);
if (!silent) {
toast({ title: 'Draft saved locally', description: 'Could not sync to relays. Saved to your browser.', variant: 'destructive' });
}
}
} else {
saveLocalDraft(data);
if (!mountedRef.current) return;
setLastSaved(new Date());
setHasUnsavedChanges(false);
if (!silent) {
toast({ title: 'Draft saved', description: 'Your article has been saved locally.' });
}
}
}, [user, saveRelayDraft]);
// Auto-save 30s after the first unsaved change. The timer starts once and
// is only reset when `hasUnsavedChanges` transitions, not on every keystroke.
useEffect(() => {
if (!hasUnsavedChanges) return;
autoSaveTimeoutRef.current = setTimeout(() => {
const current = articleRef.current;
if (current.content.length === 0) return;
persistDraft(current, { silent: true });
}, 30000);
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
};
}, [hasUnsavedChanges, persistDraft]);
// Reference to handlers for keyboard shortcuts
const handlePublishRef = useRef<(() => void) | null>(null);
const handleSaveDraftRef = useRef<(() => void) | null>(null);
// Warn before leaving page with unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges && (article.title || article.content)) {
e.preventDefault();
e.returnValue = '';
return '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges, article.title, article.content]);
// Auto-generate slug from title (skip if user manually edited the slug)
useEffect(() => {
if (article.title && !slugManuallyEdited.current) {
const newSlug = slugify(article.title, {
lower: true,
strict: true,
trim: true,
});
setArticle((prev) => ({ ...prev, slug: newSlug }));
}
}, [article.title]);
// Derived stats
const wordCount = useMemo(() => article.content.trim().split(/\s+/).filter(Boolean).length, [article.content]);
const readingTime = Math.ceil(wordCount / 200);
// Load local drafts when drafts tab is shown
useEffect(() => {
if (activeTab === 'drafts') {
setLocalDrafts(getLocalDrafts());
}
}, [activeTab]);
// Combine relay and local drafts, avoiding duplicates by slug
const combinedDrafts = useMemo(() => {
const drafts: (Draft & { isLocal: boolean })[] = [];
const seenSlugs = new Set<string>();
for (const draft of relayDrafts) {
if (draft.slug) seenSlugs.add(draft.slug);
drafts.push({ ...draft, isLocal: false });
}
for (const draft of localDrafts) {
if (!draft.slug || !seenSlugs.has(draft.slug)) {
drafts.push({ ...draft, isLocal: true });
}
}
return drafts.sort((a, b) => b.updatedAt - a.updatedAt);
}, [relayDrafts, localDrafts]);
/** Load a draft or published article into the editor. */
const handleLoadItem = useCallback((item: ArticleData & { publishedAt?: number }, isPublishedArticle: boolean) => {
setArticle({
title: item.title,
summary: item.summary,
content: item.content,
image: item.image,
tags: item.tags,
slug: item.slug,
});
slugManuallyEdited.current = !!item.slug;
setIsEditMode(isPublishedArticle);
setOriginalPublishedAt(item.publishedAt ?? null);
setHasUnsavedChanges(false);
setActiveTab('write');
toast({
title: isPublishedArticle ? 'Article loaded for editing' : 'Draft loaded',
description: isPublishedArticle
? 'Make changes and publish to update your article.'
: 'Your draft has been loaded into the editor.',
});
}, []);
const handleDeleteDraft = useCallback(async () => {
if (!deleteTarget) return;
if (deleteTarget.isLocal) {
setLocalDrafts(deleteLocalDraftById(deleteTarget.id));
toast({ title: 'Draft deleted', description: 'Removed from your browser.' });
} else {
try {
await deleteRelayDraft(deleteTarget.slug);
toast({ title: 'Draft deleted', description: 'Deletion published to relays.' });
} catch (error) {
const message = error instanceof Error ? error.message : '';
toast({ title: 'Delete failed', description: message || 'Could not delete draft.', variant: 'destructive' });
}
}
setDeleteTarget(null);
}, [deleteTarget, deleteRelayDraft]);
const updateArticle = useCallback(
(field: keyof ArticleData, value: string | string[]) => {
setArticle((prev) => ({ ...prev, [field]: value }));
setHasUnsavedChanges(true);
},
[],
);
const handleAddTag = useCallback(() => {
const newTag = tagInput.trim().toLowerCase().replace(/^#/, '');
if (newTag && !article.tags.includes(newTag)) {
setArticle((prev) => ({ ...prev, tags: [...prev.tags, newTag] }));
setTagInput('');
}
}, [tagInput, article.tags]);
const handleRemoveTag = useCallback((tagToRemove: string) => {
setArticle((prev) => ({
...prev,
tags: prev.tags.filter((tag) => tag !== tagToRemove),
}));
}, []);
const handleImageUpload = useCallback(
async (file: File) => {
try {
const [[, url]] = await uploadFile(file);
return url;
} catch (error) {
console.error('Upload failed:', error);
toast({
title: 'Upload failed',
description: 'Could not upload the image. Please try again.',
variant: 'destructive',
});
return null;
}
},
[uploadFile],
);
const handleHeaderImageUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const url = await handleImageUpload(file);
if (url) {
updateArticle('image', url);
toast({
title: 'Image uploaded',
description: 'Header image has been set successfully.',
});
}
},
[handleImageUpload, updateArticle],
);
const handleSaveDraft = useCallback(async () => {
await persistDraft(article);
}, [article, persistDraft]);
/** Perform the actual publish (called directly or after overwrite confirmation). */
const doPublish = useCallback(() => {
if (!user) return;
// Use original published_at when editing, current time for new articles
const publishedAtTimestamp =
isEditMode && originalPublishedAt
? Math.floor(originalPublishedAt / 1000)
: Math.floor(Date.now() / 1000);
const tags: string[][] = [
['d', article.slug || slugify(article.title, { lower: true, strict: true })],
['title', article.title],
['published_at', publishedAtTimestamp.toString()],
];
if (article.summary) {
tags.push(['summary', article.summary]);
}
if (article.image) {
tags.push(['image', article.image]);
}
article.tags.forEach((tag) => {
tags.push(['t', tag]);
});
publishEvent(
{
kind: 30023,
content: article.content,
tags,
},
{
onSuccess: async () => {
setIsPublished(true);
setHasUnsavedChanges(false);
// Remove draft after publishing
if (article.slug) {
deleteDraftBySlug(article.slug);
try {
await deleteRelayDraft(article.slug);
} catch (error) {
console.error('Failed to delete draft from relay:', error);
}
}
toast({
title: isEditMode ? 'Article updated' : 'Article published',
description: isEditMode
? 'Your article has been updated on Nostr.'
: 'Your article is now live on Nostr.',
});
// Navigate back to articles feed
navigate('/articles');
},
onError: (error) => {
toast({
title: 'Publishing failed',
description:
error.message || 'Could not publish your article. Please try again.',
variant: 'destructive',
});
},
},
);
}, [
user,
article,
publishEvent,
deleteRelayDraft,
isEditMode,
originalPublishedAt,
navigate,
]);
const handlePublish = useCallback(async () => {
if (!user) {
toast({
title: 'Login required',
description: 'Please login to publish your article.',
variant: 'destructive',
});
return;
}
if (!article.title.trim()) {
toast({
title: 'Title required',
description: 'Please add a title to your article.',
variant: 'destructive',
});
return;
}
if (!article.content.trim()) {
toast({
title: 'Content required',
description: 'Please write some content for your article.',
variant: 'destructive',
});
return;
}
// In edit mode we're intentionally overwriting, so skip the collision check
if (!isEditMode) {
const slug = article.slug || slugify(article.title, { lower: true, strict: true });
try {
const existing = await nostr.query([
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
]);
if (existing.length > 0) {
toast({
title: 'Slug already in use',
description: 'You already have a published article with this slug. Change the slug or edit the existing article from My Articles.',
variant: 'destructive',
});
return;
}
} catch {
// If the check fails (e.g. relay timeout), proceed anyway
}
}
doPublish();
}, [user, article, isEditMode, nostr, doPublish]);
// Set refs for keyboard shortcuts
handlePublishRef.current = handlePublish;
handleSaveDraftRef.current = handleSaveDraft;
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
handleSaveDraftRef.current?.();
}
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && user) {
e.preventDefault();
handlePublishRef.current?.();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [user]);
// Handle back navigation with unsaved changes check
const handleBack = useCallback(() => {
if (hasUnsavedChanges && (article.title || article.content)) {
setShowLeaveDialog(true);
} else {
navigate('/articles');
}
}, [hasUnsavedChanges, article.title, article.content, navigate]);
const handleLeaveWithoutSaving = useCallback(() => {
setShowLeaveDialog(false);
navigate('/articles');
}, [navigate]);
const handleSaveAndLeave = useCallback(async () => {
await handleSaveDraft();
setShowLeaveDialog(false);
navigate('/articles');
}, [handleSaveDraft, navigate]);
const statusLabel = isPublished ? (
<span className="text-green-600 dark:text-green-400 text-sm">
{isEditMode ? 'Updated' : 'Published'}
</span>
) : isEditMode ? (
hasUnsavedChanges ? (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
Editing
</span>
) : (
<span className="text-blue-600 dark:text-blue-400 text-sm">Editing</span>
)
) : hasUnsavedChanges ? (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
Unsaved
</span>
) : lastSaved ? (
<span className="text-sm text-muted-foreground">Saved</span>
) : null;
const totalDrafts = combinedDrafts.length;
return (
<div className="flex flex-col">
{/* Sticky header */}
<div className="sticky top-0 z-20">
<SubHeaderBar pinned className="relative !top-0">
<button
onClick={handleBack}
className="pl-3 pr-1 py-1.5 text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<ArrowLeft className="size-5" />
</button>
<TabButton
label="New"
active={activeTab === 'write'}
onClick={() => setActiveTab('write')}
/>
<TabButton
label="My Articles"
active={activeTab === 'drafts'}
onClick={() => setActiveTab('drafts')}
/>
</SubHeaderBar>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleHeaderImageUpload}
className="hidden"
/>
{/* ── New article tab ──────────────────────────────────────── */}
{activeTab === 'write' && (
<div className="px-4 py-6 pb-24 space-y-6">
{/* Header Image */}
{article.image ? (
<div className="relative rounded-xl overflow-hidden group">
<img
src={article.image}
alt="Header"
className="w-full h-48 sm:h-64 object-cover"
/>
{/* Desktop: centered overlay on hover */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors hidden sm:flex items-center justify-center opacity-0 group-hover:opacity-100">
<Button
variant="secondary"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Image className="w-4 h-4 mr-2" />
)}
Change Image
</Button>
</div>
{/* Mobile: persistent corner button */}
<Button
variant="secondary"
size="icon"
className="absolute top-2 right-2 h-8 w-8 rounded-full shadow-md sm:hidden"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Image className="w-4 h-4" />
)}
</Button>
</div>
) : (
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-full h-32 border-2 border-dashed border-border rounded-xl flex flex-col items-center justify-center text-muted-foreground hover:border-primary/50 hover:text-primary/70 transition-colors"
>
{isUploading ? (
<Loader2 className="w-8 h-8 animate-spin mb-2" />
) : (
<Image className="w-8 h-8 mb-2" />
)}
<span className="text-sm">Add a header image</span>
</button>
)}
{/* Title */}
<input
type="text"
value={article.title}
onChange={(e) => updateArticle('title', e.target.value)}
placeholder="Your article title..."
className="w-full text-3xl sm:text-4xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/40"
/>
{/* Metadata — inline between title and body */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="summary" className="text-muted-foreground text-sm">Summary</Label>
<Textarea
id="summary"
value={article.summary}
onChange={(e) => updateArticle('summary', e.target.value)}
placeholder="A brief description of your article..."
rows={2}
className="resize-none"
/>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="space-y-1.5 flex-1">
<Label htmlFor="slug" className="text-muted-foreground text-xs leading-none">URL Slug</Label>
<Input
id="slug"
value={article.slug}
onChange={(e) => {
slugManuallyEdited.current = true;
updateArticle('slug', e.target.value);
}}
placeholder="article-url-slug"
className="h-8 font-mono text-xs"
/>
</div>
<div className="space-y-1.5 flex-1">
<Label className="text-muted-foreground text-xs inline-flex items-center gap-1 leading-none">
<Hash className="w-3 h-3 shrink-0" />
Tags
</Label>
<div className="flex gap-1.5">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) =>
e.key === 'Enter' && (e.preventDefault(), handleAddTag())
}
placeholder="Add a tag..."
className="h-8 text-xs flex-1"
/>
<Button type="button" variant="secondary" size="icon" className="h-8 w-8 shrink-0" onClick={handleAddTag}>
<span className="text-base leading-none">+</span>
</Button>
</div>
</div>
</div>
{article.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{article.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1 px-2 py-1">
#{tag}
<button onClick={() => handleRemoveTag(tag)} className="ml-1 hover:text-destructive">
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{/* Editor */}
<MilkdownEditor
value={article.content}
onChange={(value) => updateArticle('content', value || '')}
onUploadImage={handleImageUpload}
placeholder="Start writing your article..."
className="rounded-xl border border-border bg-card min-h-[250px] sm:min-h-[400px]"
/>
{/* Stats + Save */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
<span className="shrink-0">{wordCount} words</span>
<span>·</span>
<span className="shrink-0">{readingTime} min read</span>
{statusLabel && (
<>
<span>·</span>
{statusLabel}
</>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={handleSaveDraft}
className="rounded-full gap-1.5 shrink-0"
>
<Save className="size-3.5" />
Save Draft
</Button>
</div>
</div>
)}
{/* ── Drafts tab ───────────────────────────────────────────── */}
{activeTab === 'drafts' && (
<div className="px-4 py-4">
{isDraftsLoading && user ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground mb-4" />
<p className="text-muted-foreground">Loading drafts...</p>
</div>
) : totalDrafts === 0 && publishedArticles.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<FileText className="w-8 h-8 text-muted-foreground" />
</div>
<p className="text-muted-foreground">No drafts or articles yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Save a draft or publish to see content here
</p>
</div>
) : (
<div className="space-y-6">
{/* Drafts section */}
{totalDrafts > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground px-1">Drafts ({totalDrafts})</h3>
{combinedDrafts.map((draft) => (
<div
key={draft.id}
className="group p-4 rounded-xl border border-border hover:border-primary/30 hover:bg-card transition-all cursor-pointer"
onClick={() => handleLoadItem(draft, false)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">
{draft.title || 'Untitled Draft'}
</h3>
{draft.summary && (
<p className="text-sm text-muted-foreground truncate mt-1">
{draft.summary}
</p>
)}
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-1" />
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
{draft.isLocal ? (
<HardDrive className="w-3 h-3 shrink-0" />
) : (
<Cloud className="w-3 h-3 text-primary shrink-0" />
)}
<Clock className="w-3 h-3 shrink-0" />
<span>{formatDistanceToNow(draft.updatedAt, { addSuffix: true })}</span>
{draft.tags.length > 0 && (
<>
<span>·</span>
<span>{draft.tags.length} tags</span>
</>
)}
<span className="flex-1" />
<button
className="p-1 rounded-full text-muted-foreground hover:text-destructive transition-colors"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget({ id: draft.id, slug: draft.slug, isLocal: draft.isLocal });
}}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
{/* Published articles section */}
{publishedArticles.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground px-1">Published ({publishedArticles.length})</h3>
{publishedArticles.map((pub) => (
<div
key={pub.id}
className="group p-4 rounded-xl border border-border hover:border-green-500/30 hover:bg-card transition-all cursor-pointer"
onClick={() => handleLoadItem(pub, true)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">
{pub.title || 'Untitled Article'}
</h3>
{pub.summary && (
<p className="text-sm text-muted-foreground truncate mt-1">
{pub.summary}
</p>
)}
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-1" />
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<Clock className="w-3 h-3 shrink-0" />
<span>Published {formatDistanceToNow(pub.publishedAt, { addSuffix: true })}</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{/* Publish FAB — mobile: fixed bottom right */}
<div className="fixed bottom-fab right-6 z-30 sidebar:hidden">
<FabButton
onClick={handlePublish}
disabled={isPublishing || !user}
title={isEditMode ? 'Update article' : 'Publish article'}
icon={isPublishing
? <Loader2 size={18} className="animate-spin" />
: <Send strokeWidth={3} size={18} />
}
/>
</div>
{/* Publish FAB — desktop: sticky within column */}
<div className="hidden sidebar:block sticky bottom-6 z-30 pointer-events-none">
<div className="flex justify-end pr-4">
<div className="pointer-events-auto">
<FabButton
onClick={handlePublish}
disabled={isPublishing || !user}
title={isEditMode ? 'Update article' : 'Publish article'}
icon={isPublishing
? <Loader2 size={18} className="animate-spin" />
: <Send strokeWidth={3} size={18} />
}
/>
</div>
</div>
</div>
{/* Leave Confirmation Dialog */}
<AlertDialog open={showLeaveDialog} onOpenChange={setShowLeaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Would you like to save your draft before
leaving?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
<AlertDialogCancel onClick={() => setShowLeaveDialog(false)}>
Cancel
</AlertDialogCancel>
<Button variant="outline" onClick={handleLeaveWithoutSaving}>
Discard
</Button>
<AlertDialogAction onClick={handleSaveAndLeave}>
Save Draft
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete Draft Confirmation Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete draft?</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget?.isLocal
? 'This draft will be permanently deleted from your browser.'
: 'This draft will be deleted from Nostr relays.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteDraft}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Deleting...
</>
) : (
'Delete'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface LinkDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedText?: string;
onSubmit: (text: string, url: string) => void;
}
export function LinkDialog({ open, onOpenChange, selectedText, onSubmit }: LinkDialogProps) {
const [text, setText] = useState('');
const [url, setUrl] = useState('');
// Reset form when dialog opens
useEffect(() => {
if (open) {
setText(selectedText || '');
setUrl('');
}
}, [open, selectedText]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (url.trim()) {
const finalText = text.trim() || url.trim();
let finalUrl = url.trim();
// Add https:// if no protocol specified
if (!/^https?:\/\//i.test(finalUrl)) {
finalUrl = 'https://' + finalUrl;
}
onSubmit(finalText, finalUrl);
onOpenChange(false);
}
};
const hasSelectedText = !!selectedText;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Insert Link</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
{!hasSelectedText && (
<div className="space-y-2">
<Label htmlFor="link-text">Link Text</Label>
<Input
id="link-text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter link text..."
autoFocus={!hasSelectedText}
/>
</div>
)}
{hasSelectedText && (
<div className="space-y-2">
<Label className="text-muted-foreground">Link Text</Label>
<p className="text-sm bg-muted px-3 py-2 rounded-md">{selectedText}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="link-url">URL</Label>
<Input
id="link-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
autoFocus={hasSelectedText}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!url.trim()}>
Insert Link
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+374
View File
@@ -0,0 +1,374 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { Editor, rootCtx, defaultValueCtx, editorViewCtx } from '@milkdown/core';
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
import { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBlockquoteCommand, insertHrCommand, turnIntoTextCommand, wrapInHeadingCommand, toggleInlineCodeCommand, wrapInBulletListCommand, wrapInOrderedListCommand } from '@milkdown/preset-commonmark';
import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm';
import { history } from '@milkdown/plugin-history';
import { clipboard } from '@milkdown/plugin-clipboard';
import { listener, listenerCtx } from '@milkdown/plugin-listener';
import { upload, uploadConfig } from '@milkdown/plugin-upload';
import { Decoration } from '@milkdown/prose/view';
import { replaceAll, callCommand } from '@milkdown/utils';
import { MilkdownToolbar } from './MilkdownToolbar';
import { LinkDialog } from './LinkDialog';
interface MilkdownEditorInnerProps {
value: string;
onChange: (markdown: string) => void;
onUploadImage?: (file: File) => Promise<string | null>;
placeholder?: string;
showToolbar?: boolean;
sourceMode?: boolean;
onToggleSource?: () => void;
}
function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, showToolbar = true, sourceMode, onToggleSource }: MilkdownEditorInnerProps) {
const initialValueRef = useRef(value);
const editorRef = useRef<Editor | null>(null);
const lastExternalValue = useRef(value);
const onUploadImageRef = useRef(onUploadImage);
const fileInputRef = useRef<HTMLInputElement>(null);
// Link dialog state
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [selectedTextForLink, setSelectedTextForLink] = useState<string>('');
const selectionRef = useRef<{ from: number; to: number } | null>(null);
// Keep ref updated
useEffect(() => {
onUploadImageRef.current = onUploadImage;
}, [onUploadImage]);
const { get } = useEditor((root) => {
const editor = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root);
ctx.set(defaultValueCtx, initialValueRef.current);
ctx.get(listenerCtx).markdownUpdated((_, markdown) => {
lastExternalValue.current = markdown;
onChange(markdown);
});
// Configure upload plugin
ctx.set(uploadConfig.key, {
uploader: async (files, schema) => {
const images: File[] = [];
for (let i = 0; i < files.length; i++) {
const file = files.item(i);
if (!file) continue;
// Only handle images
if (!file.type.includes('image')) continue;
images.push(file);
}
const nodes: ReturnType<typeof schema.nodes.image.createAndFill>[] = [];
for (const image of images) {
try {
// Use the upload handler if provided
if (onUploadImageRef.current) {
const url = await onUploadImageRef.current(image);
if (url) {
const node = schema.nodes.image.createAndFill({
src: url,
alt: image.name,
});
if (node) nodes.push(node);
}
} else {
// Fallback to base64 if no upload handler
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve) => {
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(image);
});
const node = schema.nodes.image.createAndFill({
src: dataUrl,
alt: image.name,
});
if (node) nodes.push(node);
}
} catch (error) {
console.error('Failed to upload image:', error);
}
}
return nodes.filter((node): node is NonNullable<typeof node> => node !== null);
},
enableHtmlFileUploader: true,
uploadWidgetFactory: (pos, spec) => {
// Create a placeholder widget while uploading
const widgetEl = document.createElement('span');
widgetEl.className = 'milkdown-upload-placeholder';
widgetEl.textContent = 'Uploading...';
return Decoration.widget(pos, widgetEl, spec);
},
});
})
.use(commonmark)
.use(gfm)
.use(history)
.use(clipboard)
.use(listener)
.use(upload);
return editor;
});
// Store editor reference
useEffect(() => {
editorRef.current = get() ?? null;
}, [get]);
// Toggle `has-content` class on blur so CSS can hide the placeholder
// when the editor has real content (including trailing whitespace that
// ProseMirror collapses out of the DOM).
useEffect(() => {
const editor = get();
if (!editor) return;
let dom: HTMLElement;
try {
dom = editor.ctx.get(editorViewCtx).dom;
} catch {
return;
}
const check = () => {
const hasContent = !!lastExternalValue.current.replace(/\n/g, '');
dom.classList.toggle('has-content', hasContent);
};
// Set initial state
check();
dom.addEventListener('blur', check);
return () => dom.removeEventListener('blur', check);
}, [get]);
// Handle external value changes (e.g., loading a draft)
useEffect(() => {
const editor = get();
if (editor && value !== lastExternalValue.current) {
// Only update if the value changed externally (not from user typing)
editor.action(replaceAll(value));
lastExternalValue.current = value;
}
}, [value, get]);
// Handle link dialog open
const handleLinkButtonClick = useCallback(() => {
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
const { state } = view;
const { from, to } = state.selection;
const selectedText = state.doc.textBetween(from, to);
// Store selection for later use
selectionRef.current = { from, to };
setSelectedTextForLink(selectedText);
setLinkDialogOpen(true);
} catch (error) {
console.error('Failed to get selection:', error);
}
}, [get]);
// Handle link insertion from dialog
const handleLinkSubmit = useCallback((text: string, url: string) => {
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
const { state, dispatch } = view;
const { schema } = state;
// Create a link mark
const linkMark = schema.marks.link.create({ href: url });
// Create text node with link mark
const linkNode = schema.text(text, [linkMark]);
const tr = state.tr;
if (selectionRef.current) {
const { from, to } = selectionRef.current;
// Replace selection with linked text
tr.replaceWith(from, to, linkNode);
} else {
// Insert at current position
const { from } = state.selection;
tr.insert(from, linkNode);
}
dispatch(tr);
view.focus();
} catch (error) {
console.error('Failed to insert link:', error);
}
}, [get]);
// Handle image upload via file picker + ProseMirror insertion
const handleImageButtonClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleImageFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !onUploadImageRef.current) return;
const url = await onUploadImageRef.current(file);
if (!url) return;
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
const { state, dispatch } = view;
const { schema } = state;
const node = schema.nodes.image.createAndFill({ src: url, alt: file.name });
if (node) {
const { from } = state.selection;
dispatch(state.tr.insert(from, node));
view.focus();
}
} catch (error) {
console.error('Failed to insert image:', error);
}
// Reset so the same file can be re-selected
e.target.value = '';
}, [get]);
// Handle toolbar commands
const handleCommand = useCallback((command: string) => {
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
switch (command) {
case 'toggleBold':
editor.action(callCommand(toggleStrongCommand.key));
break;
case 'toggleItalic':
editor.action(callCommand(toggleEmphasisCommand.key));
break;
case 'toggleStrikethrough':
editor.action(callCommand(toggleStrikethroughCommand.key));
break;
case 'toggleInlineCode':
editor.action(callCommand(toggleInlineCodeCommand.key));
break;
case 'heading1':
editor.action(callCommand(wrapInHeadingCommand.key, 1));
break;
case 'heading2':
editor.action(callCommand(wrapInHeadingCommand.key, 2));
break;
case 'heading3':
editor.action(callCommand(wrapInHeadingCommand.key, 3));
break;
case 'bulletList':
editor.action(callCommand(wrapInBulletListCommand.key));
break;
case 'orderedList':
editor.action(callCommand(wrapInOrderedListCommand.key));
break;
case 'blockquote':
editor.action(callCommand(wrapInBlockquoteCommand.key));
break;
case 'link':
handleLinkButtonClick();
return; // Don't refocus, dialog will handle it
case 'hr':
editor.action(callCommand(insertHrCommand.key));
break;
case 'paragraph':
editor.action(callCommand(turnIntoTextCommand.key));
break;
}
// Refocus the editor
view.focus();
} catch (error) {
console.error('Command failed:', error);
}
}, [get, handleLinkButtonClick]);
return (
<>
{showToolbar && (
<MilkdownToolbar
onCommand={handleCommand}
onImageUpload={onUploadImage ? handleImageButtonClick : undefined}
sourceMode={sourceMode}
onToggleSource={onToggleSource}
/>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageFileChange}
className="hidden"
/>
{sourceMode ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full min-h-[250px] sm:min-h-[350px] p-3 bg-transparent font-mono text-sm resize-y outline-none"
placeholder={placeholder}
spellCheck={false}
/>
) : (
<div
className="milkdown-content"
style={placeholder ? { '--ph': `"${placeholder.replace(/"/g, '\\"')}"` } as React.CSSProperties : undefined}
>
<Milkdown />
</div>
)}
<LinkDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
selectedText={selectedTextForLink}
onSubmit={handleLinkSubmit}
/>
</>
);
}
interface MilkdownEditorProps {
value: string;
onChange: (markdown: string) => void;
onUploadImage?: (file: File) => Promise<string | null>;
placeholder?: string;
className?: string;
showToolbar?: boolean;
}
export function MilkdownEditor({ value, onChange, onUploadImage, placeholder, className, showToolbar = true }: MilkdownEditorProps) {
const [sourceMode, setSourceMode] = useState(false);
return (
<div className={`milkdown-editor ${className || ''}`}>
<MilkdownProvider>
<MilkdownEditorInner
value={value}
onChange={onChange}
onUploadImage={onUploadImage}
placeholder={placeholder}
showToolbar={showToolbar}
sourceMode={sourceMode}
onToggleSource={() => setSourceMode((s) => !s)}
/>
</MilkdownProvider>
</div>
);
}
+233
View File
@@ -0,0 +1,233 @@
import {
Bold,
Italic,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Link,
Image,
Minus,
HelpCircle,
Eye,
EyeOff,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
function MarkdownHelpPopover() {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<HelpCircle className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72" align="end">
<div className="space-y-2">
<h4 className="font-medium text-sm">Markdown Quick Reference</h4>
<div className="text-xs space-y-1.5 font-mono text-muted-foreground">
<div className="flex justify-between"><span>**bold**</span><span className="font-sans font-bold">bold</span></div>
<div className="flex justify-between"><span>*italic*</span><span className="font-sans italic">italic</span></div>
<div className="flex justify-between"><span># Heading 1</span><span className="font-sans">H1</span></div>
<div className="flex justify-between"><span>## Heading 2</span><span className="font-sans">H2</span></div>
<div className="flex justify-between"><span>- list item</span><span className="font-sans">* item</span></div>
<div className="flex justify-between"><span>1. numbered</span><span className="font-sans">1. item</span></div>
<div className="flex justify-between"><span>[text](url)</span><span className="font-sans text-primary">link</span></div>
<div className="flex justify-between"><span>![alt](url)</span><span className="font-sans">image</span></div>
<div className="flex justify-between"><span>&gt; quote</span><span className="font-sans border-l-2 pl-1">quote</span></div>
<div className="flex justify-between"><span>`code`</span><span className="font-sans bg-muted px-1 rounded">code</span></div>
</div>
<p className="text-xs text-muted-foreground pt-2 border-t">
Drag & drop or paste images to upload
</p>
</div>
</PopoverContent>
</Popover>
);
}
const hasPointerFine = typeof window !== 'undefined'
&& window.matchMedia('(pointer: fine)').matches;
interface ToolbarButtonProps {
icon: React.ReactNode;
label: string;
shortcut?: string;
onClick: () => void;
active?: boolean;
}
function ToolbarButton({ icon, label, shortcut, onClick, active }: ToolbarButtonProps) {
const button = (
<Button
variant="ghost"
size="icon"
onClick={onClick}
aria-label={label}
className={cn(
"h-8 w-8 text-muted-foreground hover:text-foreground",
active && "bg-muted text-foreground"
)}
>
{icon}
</Button>
);
if (!hasPointerFine) return button;
return (
<Tooltip>
<TooltipTrigger asChild>
{button}
</TooltipTrigger>
<TooltipContent>
<span>{label}</span>
{shortcut && <span className="ml-2 text-muted-foreground text-xs">{shortcut}</span>}
</TooltipContent>
</Tooltip>
);
}
interface MilkdownToolbarProps {
onCommand: (command: string) => void;
onImageUpload?: () => void;
sourceMode?: boolean;
onToggleSource?: () => void;
className?: string;
}
export function MilkdownToolbar({ onCommand, onImageUpload, sourceMode, onToggleSource, className }: MilkdownToolbarProps) {
return (
<div className={cn(
"flex items-center gap-0.5 p-1.5 border-b border-border bg-card/95 backdrop-blur-sm flex-wrap sticky top-0 z-10 rounded-t-xl",
className
)}>
{!sourceMode && (
<>
{/* Text formatting */}
<ToolbarButton
icon={<Bold className="h-4 w-4" />}
label="Bold"
shortcut="Ctrl+B"
onClick={() => onCommand('toggleBold')}
/>
<ToolbarButton
icon={<Italic className="h-4 w-4" />}
label="Italic"
shortcut="Ctrl+I"
onClick={() => onCommand('toggleItalic')}
/>
<ToolbarButton
icon={<Strikethrough className="h-4 w-4" />}
label="Strikethrough"
onClick={() => onCommand('toggleStrikethrough')}
/>
<ToolbarButton
icon={<Code className="h-4 w-4" />}
label="Inline Code"
onClick={() => onCommand('toggleInlineCode')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
{/* Headings */}
<ToolbarButton
icon={<Heading1 className="h-4 w-4" />}
label="Heading 1"
onClick={() => onCommand('heading1')}
/>
<ToolbarButton
icon={<Heading2 className="h-4 w-4" />}
label="Heading 2"
onClick={() => onCommand('heading2')}
/>
<ToolbarButton
icon={<Heading3 className="h-4 w-4" />}
label="Heading 3"
onClick={() => onCommand('heading3')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
{/* Lists */}
<ToolbarButton
icon={<List className="h-4 w-4" />}
label="Bullet List"
onClick={() => onCommand('bulletList')}
/>
<ToolbarButton
icon={<ListOrdered className="h-4 w-4" />}
label="Numbered List"
onClick={() => onCommand('orderedList')}
/>
<ToolbarButton
icon={<Quote className="h-4 w-4" />}
label="Blockquote"
onClick={() => onCommand('blockquote')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
{/* Links and media */}
<ToolbarButton
icon={<Link className="h-4 w-4" />}
label="Insert Link"
onClick={() => onCommand('link')}
/>
{onImageUpload && (
<ToolbarButton
icon={<Image className="h-4 w-4" />}
label="Insert Image"
onClick={onImageUpload}
/>
)}
<ToolbarButton
icon={<Minus className="h-4 w-4" />}
label="Horizontal Rule"
onClick={() => onCommand('hr')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
<MarkdownHelpPopover />
</>
)}
{sourceMode && (
<>
<span className="text-xs text-muted-foreground px-1.5">Markdown Source</span>
<span className="flex-1" />
</>
)}
{onToggleSource && (
<ToolbarButton
icon={sourceMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
label={sourceMode ? 'Rich text editor' : 'Markdown source'}
active={sourceMode}
onClick={onToggleSource}
/>
)}
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { type ArticleFields } from '@/lib/articleHelpers';
/** Kind 31234 — NIP-37 Draft Wrap. */
const DRAFT_WRAP_KIND = 31234;
/** The inner draft kind we're wrapping. */
const ARTICLE_KIND = 30023;
export interface Draft extends ArticleFields {
id: string;
updatedAt: number;
eventId?: string;
}
type DraftData = ArticleFields;
/** Build an unsigned kind-30023 event object from draft data. */
function buildInnerDraftEvent(draft: DraftData): Record<string, unknown> {
const tags: string[][] = [
['d', draft.slug],
['title', draft.title],
];
if (draft.summary) tags.push(['summary', draft.summary]);
if (draft.image) tags.push(['image', draft.image]);
draft.tags.forEach(tag => tags.push(['t', tag]));
return {
kind: ARTICLE_KIND,
content: draft.content,
tags,
};
}
/** Parse a decrypted inner draft event back into a Draft. */
function parseDraftPayload(inner: Record<string, unknown>, wrapEvent: NostrEvent): Draft | null {
const tags = (inner.tags ?? []) as string[][];
const getTag = (name: string) => tags.find(t => t[0] === name)?.[1] || '';
const getTags = (name: string) => tags.filter(t => t[0] === name).map(t => t[1]);
return {
id: wrapEvent.id,
eventId: wrapEvent.id,
title: getTag('title'),
summary: getTag('summary'),
content: (inner.content as string) || '',
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
updatedAt: wrapEvent.created_at * 1000,
};
}
export function useDrafts() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
// Query and decrypt drafts from relay
const query = useQuery<Draft[]>({
queryKey: ['drafts', user?.pubkey ?? ''],
queryFn: async ({ signal }) => {
if (!user?.pubkey || !user.signer.nip44) return [];
const events = await nostr.query(
[{ kinds: [DRAFT_WRAP_KIND], authors: [user.pubkey], '#k': [String(ARTICLE_KIND)], limit: 100 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
const drafts: Draft[] = [];
for (const event of events) {
// Blank content means deleted
if (!event.content.trim()) continue;
try {
const decrypted = await user.signer.nip44.decrypt(user.pubkey, event.content);
const inner = JSON.parse(decrypted) as Record<string, unknown>;
const draft = parseDraftPayload(inner, event);
if (draft && draft.content.trim()) drafts.push(draft);
} catch {
// Skip events that fail to decrypt or parse
continue;
}
}
return drafts.sort((a, b) => b.updatedAt - a.updatedAt);
},
enabled: !!user?.pubkey && !!user?.signer.nip44,
staleTime: 30 * 1000,
});
// Save draft: encrypt inner event and publish as kind 31234
const saveMutation = useMutation({
mutationFn: async (draft: DraftData) => {
if (!user?.signer.nip44) throw new Error('NIP-44 encryption not supported by signer');
const inner = buildInnerDraftEvent(draft);
const plaintext = JSON.stringify(inner);
const encrypted = await user.signer.nip44.encrypt(user.pubkey, plaintext);
return publishEvent({
kind: DRAFT_WRAP_KIND,
content: encrypted,
tags: [
['d', draft.slug],
['k', String(ARTICLE_KIND)],
],
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['drafts', user?.pubkey] });
},
});
// Delete draft (publish kind 5 deletion event)
const deleteMutation = useMutation({
mutationFn: async (slug: string) => {
if (!user) throw new Error('User is not logged in');
const event = await publishEvent({
kind: 5,
content: '',
tags: [['a', `${DRAFT_WRAP_KIND}:${user.pubkey}:${slug}`]],
});
return { event, slug };
},
onSuccess: (data) => {
queryClient.setQueryData(['drafts', user?.pubkey], (oldData: Draft[] | undefined) => {
if (!oldData) return [];
return oldData.filter(d => d.slug !== data?.slug);
});
},
});
return {
drafts: query.data || [],
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
saveDraft: saveMutation.mutateAsync,
isSaving: saveMutation.isPending,
deleteDraft: deleteMutation.mutateAsync,
isDeleting: deleteMutation.isPending,
};
}
+56
View File
@@ -0,0 +1,56 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { parseArticleEvent, type ArticleFields } from '@/lib/articleHelpers';
export interface PublishedArticle extends ArticleFields {
id: string;
eventId: string;
publishedAt: number;
updatedAt: number;
}
function eventToArticle(event: NostrEvent): PublishedArticle {
const parsed = parseArticleEvent(event);
return {
...parsed,
id: event.id,
eventId: event.id,
updatedAt: event.created_at * 1000,
};
}
export function usePublishedArticles() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const query = useQuery<PublishedArticle[]>({
queryKey: ['published-articles', user?.pubkey ?? ''],
queryFn: async ({ signal }) => {
if (!user?.pubkey) {
return [];
}
const events = await nostr.query(
[{ kinds: [30023], authors: [user.pubkey], limit: 100 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
return events
.filter(e => e.content.trim().length > 0)
.map(eventToArticle)
.sort((a, b) => b.publishedAt - a.publishedAt);
},
enabled: !!user?.pubkey,
staleTime: 30 * 1000,
});
return {
articles: query.data || [],
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
}
+158
View File
@@ -494,3 +494,161 @@
}
}
/* ── Milkdown Editor Styles ─────────────────────────────────────────────────── */
.milkdown-editor .milkdown {
@apply outline-none;
}
.milkdown-editor .editor {
@apply outline-none min-h-[400px] p-3;
font-size: 1.125rem;
line-height: 1.75;
}
.milkdown-editor .ProseMirror {
@apply outline-none min-h-[400px];
}
.milkdown-editor .ProseMirror:focus {
@apply outline-none;
}
/* Headings */
.milkdown-editor h1 {
@apply text-3xl font-bold mt-6 mb-4;
}
.milkdown-editor h2 {
@apply text-2xl font-semibold mt-5 mb-3;
}
.milkdown-editor h3 {
@apply text-xl font-semibold mt-4 mb-2;
}
.milkdown-editor h4 {
@apply text-lg font-medium mt-3 mb-2;
}
/* Inline styles */
.milkdown-editor strong {
@apply font-bold;
}
.milkdown-editor em {
@apply italic;
}
.milkdown-editor del {
@apply line-through text-muted-foreground;
}
.milkdown-editor code {
@apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono;
}
/* Block elements */
.milkdown-editor p {
@apply my-1.5;
}
.milkdown-editor blockquote {
@apply border-l-4 border-primary/40 pl-4 my-4 italic text-muted-foreground;
}
.milkdown-editor pre {
@apply bg-muted rounded-lg p-4 my-4 overflow-x-auto;
}
.milkdown-editor pre code {
@apply bg-transparent p-0 font-mono;
font-size: 0.875rem;
}
/* Lists */
.milkdown-editor ul {
@apply list-disc list-inside my-3 space-y-1;
}
.milkdown-editor ol {
@apply list-decimal list-inside my-3 space-y-1;
}
.milkdown-editor li {
@apply pl-1;
}
.milkdown-editor li p {
@apply inline my-0;
}
/* Task lists (GFM) */
.milkdown-editor ul.task-list {
@apply list-none pl-0;
}
.milkdown-editor li.task-list-item {
@apply flex items-start gap-2 pl-0;
}
.milkdown-editor li.task-list-item input[type="checkbox"] {
@apply mt-1.5 h-4 w-4 rounded border-border;
}
/* Links */
.milkdown-editor a {
@apply text-primary underline underline-offset-2 hover:text-primary/80 transition-colors;
}
/* Horizontal rule */
.milkdown-editor hr {
@apply my-6 border-border;
}
/* Images */
.milkdown-editor img {
@apply max-w-full h-auto rounded-lg my-4;
}
/* Tables (GFM) */
.milkdown-editor table {
@apply w-full border-collapse my-4;
}
.milkdown-editor th,
.milkdown-editor td {
@apply border border-border px-3 py-2 text-left;
}
.milkdown-editor th {
@apply bg-muted font-semibold;
}
.milkdown-editor tr:nth-child(even) {
@apply bg-muted/30;
}
/* Upload placeholder */
.milkdown-upload-placeholder {
@apply inline-flex items-center gap-2 px-3 py-1.5 bg-muted/50 rounded-md text-sm text-muted-foreground;
}
.milkdown-upload-placeholder::before {
content: '';
@apply w-4 h-4 border-2 border-muted-foreground/30 border-t-primary rounded-full animate-spin;
}
/* Placeholder — only when unfocused AND content is empty. */
.milkdown-editor .ProseMirror:not(:focus):not(.has-content) > p:first-child::before {
@apply text-muted-foreground pointer-events-none float-left h-0;
content: var(--ph, '');
}
/* Milkdown content area */
.milkdown-editor .milkdown-content .ProseMirror {
@apply min-h-[350px];
}
+33
View File
@@ -0,0 +1,33 @@
import type { NostrEvent } from '@nostrify/nostrify';
/** Fields shared by drafts and published articles. */
export interface ArticleFields {
title: string;
summary: string;
content: string;
image: string;
tags: string[];
slug: string;
}
/**
* Extract common article fields from a Nostr event's tags + content.
* Works for kind 30023 (published) events and the inner event of NIP-37 draft wraps.
*/
export function parseArticleEvent(event: NostrEvent): ArticleFields & { publishedAt: number } {
const getTag = (name: string) => event.tags.find(t => t[0] === name)?.[1] || '';
const getTags = (name: string) => event.tags.filter(t => t[0] === name).map(t => t[1]);
const publishedAtTag = getTag('published_at');
const publishedAt = publishedAtTag ? parseInt(publishedAtTag) * 1000 : event.created_at * 1000;
return {
title: getTag('title'),
summary: getTag('summary'),
content: event.content,
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
publishedAt,
};
}
+3 -3
View File
@@ -128,8 +128,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
route: 'articles',
addressable: true,
section: 'feed',
blurb: 'Blog posts, essays, and guides. Write and publish from a dedicated editor.',
sites: [{ url: 'https://inkwell.shakespeare.wtf' }],
blurb: 'Blog posts, essays, and guides. Write and publish long-form articles.',
},
// Media
{
@@ -479,7 +478,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
id: 'development',
showKey: 'showDevelopment',
feedKey: 'feedIncludeDevelopment',
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267],
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267, 31990],
label: 'Development',
description: 'Git repos, patches, PRs, nsites, apps, and custom NIPs',
route: 'development',
@@ -548,6 +547,7 @@ const KIND_SPECIFIC_LABELS: Record<number, string> = {
30008: 'profile badges',
30817: 'repository issue',
32267: 'app',
31990: 'app',
30063: 'release',
};
+76
View File
@@ -0,0 +1,76 @@
import type { Draft } from '@/hooks/useDrafts';
const DRAFTS_KEY = 'article-drafts';
/** Save a draft to localStorage. Returns the draft ID or null on failure. */
export function saveDraft(draft: Omit<Draft, 'id' | 'updatedAt'> & { id?: string }): string | null {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
const drafts: Draft[] = stored ? JSON.parse(stored) : [];
const existingIndex = draft.id
? drafts.findIndex(d => d.id === draft.id)
: drafts.findIndex(d => d.slug === draft.slug);
const newDraft: Draft = {
...draft,
id: draft.id || (existingIndex >= 0 ? drafts[existingIndex].id : crypto.randomUUID()),
updatedAt: Date.now(),
};
if (existingIndex >= 0) {
drafts[existingIndex] = newDraft;
} else {
drafts.unshift(newDraft);
}
// Keep only the 20 most recent drafts
const trimmedDrafts = drafts.slice(0, 20);
localStorage.setItem(DRAFTS_KEY, JSON.stringify(trimmedDrafts));
return newDraft.id;
} catch (error) {
console.error('Failed to save draft:', error);
return null;
}
}
/** Delete a draft by slug from localStorage. */
export function deleteDraftBySlug(slug: string): void {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
if (!stored) return;
const drafts: Draft[] = JSON.parse(stored);
const filtered = drafts.filter(d => d.slug !== slug);
localStorage.setItem(DRAFTS_KEY, JSON.stringify(filtered));
} catch (error) {
console.error('Failed to delete draft:', error);
}
}
/** Delete a draft by id from localStorage. Returns the remaining drafts. */
export function deleteLocalDraftById(id: string): Draft[] {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
if (!stored) return [];
const drafts: Draft[] = JSON.parse(stored);
const filtered = drafts.filter(d => d.id !== id);
localStorage.setItem(DRAFTS_KEY, JSON.stringify(filtered));
return filtered;
} catch (error) {
console.error('Failed to delete draft:', error);
return [];
}
}
/** Get all local drafts. */
export function getLocalDrafts(): Draft[] {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useState } from 'react';
import { useSearchParams, useParams } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { nip19 } from 'nostr-tools';
import type { AddressPointer } from 'nostr-tools/nip19';
import { Loader2 } from 'lucide-react';
import { ArticleEditor, type ArticleData } from '@/components/articles/ArticleEditor';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { getLocalDrafts } from '@/lib/localDrafts';
import { parseArticleEvent } from '@/lib/articleHelpers';
/** Thin page wrapper for /articles/new and /articles/edit/:naddr */
export function ArticleEditorPage() {
useLayoutOptions({ showFAB: false, hasSubHeader: true });
const [searchParams] = useSearchParams();
const { naddr: naddrParam } = useParams<{ naddr: string }>();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const draftSlug = searchParams.get('draft');
const [initialData, setInitialData] = useState<(Partial<ArticleData> & { publishedAt?: number }) | undefined>(undefined);
const [editMode, setEditMode] = useState(false);
const [loading, setLoading] = useState(!!naddrParam || !!draftSlug);
// Load draft from relay (NIP-37 kind 31234, encrypted) or localStorage if ?draft=<slug>
useEffect(() => {
if (!draftSlug) return;
const loadDraft = async () => {
if (user?.signer.nip44) {
try {
const events = await nostr.query([
{ kinds: [31234], authors: [user.pubkey], '#d': [draftSlug], limit: 1 },
]);
if (events.length > 0 && events[0].content.trim()) {
const decrypted = await user.signer.nip44.decrypt(user.pubkey, events[0].content);
const inner = JSON.parse(decrypted) as Record<string, unknown>;
const tags = (inner.tags ?? []) as string[][];
const getTag = (name: string) => tags.find(t => t[0] === name)?.[1] || '';
const getTags = (name: string) => tags.filter(t => t[0] === name).map(t => t[1]);
setInitialData({
title: getTag('title'),
summary: getTag('summary'),
content: (inner.content as string) || '',
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
});
setLoading(false);
return;
}
} catch {
// Fall through to localStorage
}
}
// Fallback to localStorage
const drafts = getLocalDrafts();
const draft = drafts.find((d) => d.slug === draftSlug);
if (draft) {
setInitialData({
title: draft.title,
summary: draft.summary,
content: draft.content,
image: draft.image,
tags: draft.tags,
slug: draft.slug,
});
}
setLoading(false);
};
loadDraft();
}, [draftSlug, user, nostr]);
// Load existing article for editing if /articles/edit/:naddr
useEffect(() => {
if (!naddrParam) return;
let decoded: { type: string; data: AddressPointer };
try {
decoded = nip19.decode(naddrParam) as { type: 'naddr'; data: AddressPointer };
if (decoded.type !== 'naddr') {
setLoading(false);
return;
}
} catch {
setLoading(false);
return;
}
const addr = decoded.data;
// Only allow editing your own articles
if (user && addr.pubkey !== user.pubkey) {
setLoading(false);
return;
}
nostr
.query([
{
kinds: [addr.kind],
authors: [addr.pubkey],
'#d': [addr.identifier],
limit: 1,
},
])
.then((events) => {
if (events.length > 0) {
setInitialData(parseArticleEvent(events[0]));
setEditMode(true);
}
})
.catch((err) => {
console.error('Failed to load article for editing:', err);
})
.finally(() => {
setLoading(false);
});
}, [naddrParam, nostr, user]);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return <ArticleEditor initialData={initialData} editMode={editMode} />;
}
+1 -1
View File
@@ -50,7 +50,7 @@ export function KindFeedPage({ kind, title, icon, emptyMessage, kindDef, backTo
description: `${title} on Nostr`,
});
const fabClick = onFabClick ?? (resolvedDef ? () => setInfoOpen(true) : undefined);
const fabClick = onFabClick ?? (!fabHref && resolvedDef ? () => setInfoOpen(true) : undefined);
useLayoutOptions({ showFAB, fabKind: primaryKind, fabHref, onFabClick: fabClick, hasSubHeader: !!user });
const kinds = Array.isArray(kind) ? kind : [kind];
+2 -2
View File
@@ -469,7 +469,7 @@ function LikeNotification({ item, isNew }: { item: NotificationItem; isNew: bool
actorPubkey={item.event.pubkey}
icon={
<span className="text-base leading-none size-4 flex items-center justify-center">
<ReactionEmoji content={item.event.content.trim()} tags={item.event.tags} className="inline-block h-4 w-4" />
<ReactionEmoji content={item.event.content.trim()} tags={item.event.tags} className="inline-block h-4 w-4 object-contain" />
</span>
}
action={`reacted to your ${noun}`}
@@ -569,7 +569,7 @@ function LikeNotificationGroup({ group }: { group: GroupedNotificationItem }) {
actors={group.actors}
icon={
<span className="text-base leading-none size-4 flex items-center justify-center">
<ReactionEmoji content={firstEvent.content.trim()} tags={firstEvent.tags} className="inline-block h-4 w-4" />
<ReactionEmoji content={firstEvent.content.trim()} tags={firstEvent.tags} className="inline-block h-4 w-4 object-contain" />
</span>
}
action={`reacted to your ${noun}`}
+20 -4
View File
@@ -12,6 +12,8 @@ import {
MessageCircle,
MoreHorizontal,
Radio,
Package,
Rocket,
Share2,
Star,
Zap,
@@ -51,7 +53,7 @@ import { RepostIcon } from "@/components/icons/RepostIcon";
import { LiveStreamPage } from "@/components/LiveStreamPage";
import { MagicDeckContent } from "@/components/MagicDeckContent";
import { MusicDetailContent } from "@/components/MusicDetailContent";
import { NoteCard } from "@/components/NoteCard";
import { EventActionHeader, NoteCard } from "@/components/NoteCard";
import { NoteContent } from "@/components/NoteContent";
import { NsiteCard } from "@/components/NsiteCard";
import { NoteMoreMenu } from "@/components/NoteMoreMenu";
@@ -83,6 +85,7 @@ import { VoiceMessagePlayer } from "@/components/VoiceMessagePlayer";
import { WebxdcEmbed } from "@/components/WebxdcEmbed";
import { ProfileCard } from "@/components/ProfileCard";
import { ZapstoreAppContent } from "@/components/ZapstoreAppContent";
import { AppHandlerContent } from "@/components/AppHandlerContent";
import { useAppContext } from "@/hooks/useAppContext";
import { type AddrCoords, useAddrEvent, useEvent } from "@/hooks/useEvent";
import { type ImetaEntry, parseImetaMap } from "@/lib/imeta";
@@ -133,6 +136,7 @@ function shellTitleForKind(kind?: number): string {
if (kind === BADGE_PROFILE_KIND_NEW || kind === BADGE_PROFILE_KIND_LEGACY) return "Badge Collection";
if (kind === BOOK_REVIEW_KIND) return "Book Review";
if (kind === 32267) return "App Details";
if (kind === 31990) return "App";
if (kind === 15128 || kind === 35128) return "Nsite";
if (kind === VANISH_KIND) return "Request to Vanish";
if (kind === 20) return "Photo";
@@ -998,6 +1002,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
const isCustomNip = event.kind === 30817;
const isNsite = event.kind === 15128 || event.kind === 35128;
const isZapstoreApp = event.kind === 32267;
const isAppHandler = event.kind === 31990;
const isEncryptedDM = event.kind === 4;
const isLetter = event.kind === 8211;
const isVanish = event.kind === VANISH_KIND;
@@ -1025,6 +1030,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
!isCommunity &&
!isDevKind &&
!isZapstoreApp &&
!isAppHandler &&
!isEncryptedDM &&
!isLetter &&
!isVanish &&
@@ -1438,11 +1444,11 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<article ref={focusedPostRef} className="px-4 pt-3 pb-0">
<div className="flex items-center gap-3">
{/* Reaction emoji bubble — size-10 matches the threaded ancestor avatar column */}
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0">
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0 text-xl leading-none">
<ReactionEmoji
content={event.content}
tags={event.tags}
className="text-xl leading-none"
className="h-6 w-6 object-contain"
/>
</div>
@@ -1933,6 +1939,14 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
{/* Main post — expanded Ditto-style view */}
{!isReaction && !isRepost && !isVanish && !isZap && !isProfile && (
<article ref={focusedPostRef} className="px-4 pt-3 pb-0">
{/* Kind action header for app handlers */}
{isAppHandler && (
<EventActionHeader pubkey={event.pubkey} icon={Package} action="published an app" />
)}
{isNsite && (
<EventActionHeader pubkey={event.pubkey} icon={Rocket} action="deployed an" noun="nsite" nounRoute="/development" />
)}
{/* Author row */}
<div className="flex items-center gap-3">
{author.isLoading ? (
@@ -2044,6 +2058,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
</div>
) : isZapstoreApp ? (
<ZapstoreAppContent event={event} />
) : isAppHandler ? (
<AppHandlerContent event={event} />
) : isEncryptedDM ? (
<EncryptedMessageContent event={event} />
) : isLetter ? (
@@ -2148,7 +2164,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<RenderResolvedEmoji
key={i}
emoji={emoji}
className="h-4 w-4 leading-none"
className="h-4 w-4 object-contain leading-none"
/>
))}
</span>