Add Documents app: Tiptap+Yjs editor, autosave, NIP-23 publish

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-06 21:47:53 +00:00
committed by GitHub
parent a3a245a7a5
commit af15e6a9a7
23 changed files with 3243 additions and 2 deletions

View File

@@ -89,7 +89,7 @@ export default function ExampleApp({ setTitle }: AppProps) {
}
```
## The eleven apps
## The twelve apps
| App | `id` | Params | Notes |
|---|---|---|---|
@@ -97,6 +97,7 @@ export default function ExampleApp({ setTitle }: AppProps) {
| Profile | `profile` | `pubkey`, `relays?` | kind 0 metadata, the author's notes, follow/unfollow |
| Note | `notes` | `id?`, `relays?` | One note and its replies, or a blank local draft when `id` is absent. **Not** a singleton |
| Reader | `articles` | `pubkey?`, `identifier?`, `kind?`, `relays?` | NIP-23 long-form, `react-markdown`, NIP-84 highlights |
| Documents | `documents` | `doc?` | Tiptap + Yjs rich-text editor, autosaves to IndexedDB, explicit NIP-23 publish. Shows a login prompt when signed out |
| Bookmarks | `bookmarks` | — | NIP-51 kind 10003 list — bookmarked notes and articles |
| Web Bookmarks | `web-bookmarks` | — | NIP-B0 kind 39701 — one addressable event per saved URL |
| Live | `live` | `pubkey?`, `identifier?` | NIP-53 kind 30311 live events + kind 1311 chat |
@@ -105,6 +106,25 @@ export default function ExampleApp({ setTitle }: AppProps) {
| Settings | `settings` | — | Theme, relay list, Blossom servers, account, session |
| About | `about` | — | What this is, the app list, the shortcuts |
### Documents keeps the live draft local, publishes a snapshot
The Documents app (`src/apps/documents`, `src/lib/documents`) is the solo-editing phase of a
larger collaborative-editor plan. The working draft is a **Yjs document** persisted to
IndexedDB (`openDocumentSession` in `src/lib/documents/ydoc.ts`) and edited through Tiptap's
`Collaboration` extension (`useDocumentEditor`). That extension is the exact seam where a
Hocuspocus-style WebSocket provider attaches later — the live draft never touches Nostr
relays, and no custom event kinds are introduced.
Publishing is a deliberate owner action that serializes the document to portable Markdown
(`src/lib/documents/markdown.ts`) and emits a NIP-23 kind 30023 snapshot
(`buildPublishTemplate` in `src/lib/documents/publish.ts`). The snapshot is a one-way release
that opens in the Reader; it never replaces or interrupts the live draft. Blossom attachments
ride along as validated NIP-94 `imeta` metadata (`src/lib/documents/attachments.ts`).
Pasted and imported HTML passes an allowlist sanitizer (`src/lib/documents/sanitizeHtml.ts`)
before the constrained Tiptap schema parses it, and link hrefs are restricted to the shared
`sanitizeUrl` protocol allowlist — the editor never uses `dangerouslySetInnerHTML`.
### Spells are a third-party kind, adopted for interop
Kind `777` ("Spell") isn't in the official nostr-protocol/nips registry — it comes from

74
package-lock.json generated
View File

@@ -13,6 +13,8 @@
"@nostrify/react": "^0.6.8",
"@radix-ui/react-toast": "^1.2.1",
"@tanstack/react-query": "^5.100.5",
"@tiptap/extension-collaboration": "^3.31.3",
"@tiptap/extension-table": "^3.31.3",
"@tiptap/extensions": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
@@ -3719,6 +3721,22 @@
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-collaboration": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.31.3.tgz",
"integrity": "sha512-JAwOXjeeDYghrPcK57dtvxwn06zcz50mxSSk4PaSdLxM8tkw8+udx+51ewqKKd3WlFof4zyMwUEbO/DGaUdEvw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3",
"@tiptap/y-tiptap": "^3.0.7",
"yjs": "^13"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz",
@@ -3923,6 +3941,20 @@
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-table": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.31.3.tgz",
"integrity": "sha512-7cnVPHhdiGGeauYqca6JVyPLTqZbqFEEk9nn2e2E8+fBo6zVtV07AktJBqth/XEzjZxcUmGxjoeuWYAisWjUHg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
@@ -4051,6 +4083,27 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/y-tiptap": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.9.tgz",
"integrity": "sha512-7/El8NQ8R5V5MkdrOUdfj9IgZacpt0H071xNimX7B0AnYiWiKefQnMKd41neQYzo2MOXbWdN3iZ+7Z7BruzOSA==",
"license": "MIT",
"peer": true,
"dependencies": {
"lib0": "^0.2.100"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.7.1",
"prosemirror-state": "^1.2.3",
"prosemirror-view": "^1.9.10",
"y-protocols": "^1.0.1",
"yjs": "^13.5.38"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -10647,6 +10700,27 @@
"yjs": "^13.0.0"
}
},
"node_modules/y-protocols": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
"license": "MIT",
"peer": true,
"dependencies": {
"lib0": "^0.2.85"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"peerDependencies": {
"yjs": "^13.0.0"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",

View File

@@ -14,6 +14,8 @@
"@nostrify/react": "^0.6.8",
"@radix-ui/react-toast": "^1.2.1",
"@tanstack/react-query": "^5.100.5",
"@tiptap/extension-collaboration": "^3.31.3",
"@tiptap/extension-table": "^3.31.3",
"@tiptap/extensions": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",

View File

@@ -0,0 +1,90 @@
import { StrictMode } from 'react';
import { describe, expect, it } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { DocumentEditor } from './DocumentEditor';
import { TestApp } from '@/test/TestApp';
import type { DocumentMeta } from '@/lib/documents/types';
function meta(overrides: Partial<DocumentMeta> = {}): DocumentMeta {
return {
id: 'doc-test-1',
title: 'Test document',
createdAt: 0,
updatedAt: 0,
savedAt: 0,
role: 'owner',
archived: false,
attachments: [],
...overrides,
};
}
describe('DocumentEditor', () => {
it('mounts the editor under Strict Mode without crashing', async () => {
render(
<StrictMode>
<TestApp>
<DocumentEditor
meta={meta()}
onRename={() => {}}
onDelete={() => {}}
onUpdateMeta={() => {}}
/>
</TestApp>
</StrictMode>,
);
await waitFor(() => {
expect(document.querySelector('.doc-editor')).toBeInTheDocument();
});
});
it('shows the title, role and formatting toolbar', async () => {
render(
<TestApp>
<DocumentEditor
meta={meta()}
onRename={() => {}}
onDelete={() => {}}
onUpdateMeta={() => {}}
/>
</TestApp>,
);
await waitFor(() => {
expect(screen.getByTitle('Rename document')).toHaveTextContent('Test document');
});
expect(screen.getByRole('toolbar', { name: 'Formatting', hidden: true })).toBeInTheDocument();
expect(screen.getByText('owner')).toBeInTheDocument();
// The editor surface is the labelled, editable document body.
await waitFor(() => {
const region = document.querySelector('.doc-editor');
expect(region).toHaveAttribute('contenteditable', 'true');
});
});
it('disables editing affordances for a viewer', async () => {
render(
<TestApp>
<DocumentEditor
meta={meta({ role: 'viewer' })}
onRename={() => {}}
onDelete={() => {}}
onUpdateMeta={() => {}}
/>
</TestApp>,
);
// The editor mounts read-only for a viewer.
await waitFor(() => {
const region = document.querySelector('.doc-editor');
expect(region).toHaveAttribute('contenteditable', 'false');
});
// Viewers cannot publish or rename.
expect(screen.queryByRole('button', { name: /publish/i, hidden: true })).not.toBeInTheDocument();
expect(screen.getByTitle(/renaming is unavailable/i)).toBeDisabled();
expect(screen.getByTitle(/bold/i)).toBeDisabled();
});
});

View File

@@ -0,0 +1,448 @@
import { useCallback, useState } from 'react';
import { EditorContent } from '@tiptap/react';
import {
Check,
CloudOff,
Loader2,
MoreHorizontal,
Pencil,
Send,
Trash2,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDocumentEditor } from '@/hooks/useDocumentEditor';
import { useDocumentPublish } from '@/hooks/useDocumentPublish';
import { useRelayHints } from '@/hooks/useRelayHints';
import { useToast } from '@/hooks/useToast';
import { deriveSummary } from '@/lib/documents/publish';
import { ROLE_CAPABILITIES } from '@/lib/documents/types';
import type { DocumentMeta, PublicationRecord, SaveState } from '@/lib/documents/types';
import { cn } from '@/lib/utils';
import { FormatToolbar } from './FormatToolbar';
interface DocumentEditorProps {
meta: DocumentMeta;
onRename: (id: string, title: string) => void;
onDelete: (id: string) => void;
onUpdateMeta: (id: string, patch: Partial<Omit<DocumentMeta, 'id'>>) => void;
}
export function DocumentEditor({ meta, onRename, onDelete, onUpdateMeta }: DocumentEditorProps) {
const { toast } = useToast();
const handleAutosaved = useCallback(() => {
onUpdateMeta(meta.id, { savedAt: Date.now(), updatedAt: Date.now() });
}, [meta.id, onUpdateMeta]);
const { editor, saveState, words, canEdit, getMarkdown } = useDocumentEditor({
documentId: meta.id,
role: meta.role,
onAutosaved: handleAutosaved,
});
const capabilities = ROLE_CAPABILITIES[meta.role];
const [renameOpen, setRenameOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [publishOpen, setPublishOpen] = useState(false);
return (
<div className="flex min-h-0 flex-1 flex-col">
{/* Document toolbar: title, save state, actions. */}
<div className="flex h-11 shrink-0 items-center gap-2 border-b border-border px-3">
<button
type="button"
onClick={() => canEdit && setRenameOpen(true)}
disabled={!canEdit}
aria-label={canEdit ? 'Rename document' : `Document title (renaming requires editor access; you are a ${meta.role})`}
title={canEdit ? 'Rename document' : `You have ${meta.role} access — renaming is unavailable`}
className={cn(
'flex min-w-0 items-center gap-1.5 rounded px-1 py-0.5 text-left',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
canEdit ? 'hover:bg-muted' : 'cursor-default',
)}
>
<span className="truncate text-[13px] font-medium">{meta.title}</span>
{canEdit && <Pencil className="size-3 shrink-0 text-muted-foreground" aria-hidden />}
</button>
<SaveIndicator state={saveState} />
<div className="ml-auto flex shrink-0 items-center gap-1">
{capabilities.publish && (
<Button
size="sm"
variant="outline"
className="h-7 gap-1.5 px-2 text-xs"
onClick={() => setPublishOpen(true)}
>
<Send className="size-3.5" aria-hidden />
<span className="hidden sm:inline">Publish</span>
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
aria-label="Document actions"
>
<MoreHorizontal className="size-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canEdit && (
<DropdownMenuItem onClick={() => setRenameOpen(true)}>
<Pencil className="size-4" aria-hidden />
Rename
</DropdownMenuItem>
)}
{meta.publication && <CopyPublicationLink meta={meta} />}
{meta.role === 'owner' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-4" aria-hidden />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<FormatToolbar editor={editor} canEdit={canEdit} />
{/* Editing surface. */}
<div className="os-scroll min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl">
{editor ? (
<EditorContent editor={editor} aria-label={meta.title} />
) : (
<div className="space-y-4 p-8" aria-busy="true" aria-label="Loading document">
<div className="h-9 w-2/3 animate-pulse rounded-md bg-muted" />
<div className="h-4 w-full animate-pulse rounded-md bg-muted" />
<div className="h-4 w-4/5 animate-pulse rounded-md bg-muted" />
</div>
)}
</div>
</div>
{/* Status bar. */}
<div className="flex h-8 shrink-0 items-center gap-3 border-t border-border px-3 text-[11px] text-muted-foreground">
<span>{words} {words === 1 ? 'word' : 'words'}</span>
<span className="capitalize">{meta.role}</span>
{meta.publication && (
<span className="truncate">
Published {new Date(meta.publication.publishedAt * 1000).toLocaleDateString()}
</span>
)}
</div>
{renameOpen && (
<RenameDialog
open={renameOpen}
onOpenChange={setRenameOpen}
title={meta.title}
onRename={(title) => {
onRename(meta.id, title);
setRenameOpen(false);
toast({ title: 'Document renamed' });
}}
/>
)}
<DeleteDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={meta.title}
onDelete={() => {
setDeleteOpen(false);
onDelete(meta.id);
}}
/>
{capabilities.publish && publishOpen && (
<PublishDialog
open={publishOpen}
onOpenChange={setPublishOpen}
meta={meta}
getMarkdown={getMarkdown}
onPublished={(publication) => {
onUpdateMeta(meta.id, { publication });
setPublishOpen(false);
}}
/>
)}
</div>
);
}
function SaveIndicator({ state }: { state: SaveState }) {
const content: Record<SaveState, { icon: React.ReactNode; label: string }> = {
loading: { icon: <Loader2 className="size-3 animate-spin motion-reduce:animate-none" aria-hidden />, label: 'Loading' },
saving: { icon: <Loader2 className="size-3 animate-spin motion-reduce:animate-none" aria-hidden />, label: 'Saving…' },
saved: { icon: <Check className="size-3" aria-hidden />, label: 'Saved' },
offline: { icon: <CloudOff className="size-3" aria-hidden />, label: 'Offline — saved on this device' },
error: { icon: <CloudOff className="size-3" aria-hidden />, label: 'Save failed — will retry' },
};
return (
<span
role="status"
aria-live="polite"
className={cn(
'flex shrink-0 items-center gap-1 text-[11px]',
state === 'offline' || state === 'error' ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground',
)}
>
{content[state].icon}
<span className="hidden sm:inline">{content[state].label}</span>
<span className="sr-only sm:hidden">{content[state].label}</span>
</span>
);
}
function RenameDialog({
open,
onOpenChange,
title,
onRename,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
onRename: (title: string) => void;
}) {
// The field initializes from the title on mount; the dialog body only
// renders while open (see usage), so every open starts from the current
// title without an effect.
const [value, setValue] = useState(title);
const trimmed = value.trim();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename document</DialogTitle>
<DialogDescription>The new name shows up in your document library.</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
event.preventDefault();
if (trimmed) onRename(trimmed);
}}
>
<div className="space-y-2 py-2">
<Label htmlFor="doc-rename">Title</Label>
<Input
id="doc-rename"
value={value}
onChange={(event) => setValue(event.target.value)}
maxLength={120}
autoFocus
/>
</div>
<DialogFooter>
<Button type="submit" disabled={!trimmed}>
Rename
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function DeleteDialog({
open,
onOpenChange,
title,
onDelete,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
onDelete: () => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete {title}?</DialogTitle>
<DialogDescription>
This removes the document from this device. A published snapshot, if you made one,
stays on your relays delete it separately if needed.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Keep document
</Button>
<Button variant="destructive" onClick={onDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function CopyPublicationLink({ meta }: { meta: DocumentMeta }) {
const { toast } = useToast();
const hints = useRelayHints();
const { user } = useCurrentUser();
const publication = meta.publication;
if (!publication || !user) return null;
return (
<DropdownMenuItem
onClick={async () => {
try {
const naddr = nip19.naddrEncode({
pubkey: user.pubkey,
kind: 30023,
identifier: publication.identifier,
relays: hints,
});
await navigator.clipboard.writeText(`${window.location.origin}/${naddr}`);
toast({ title: 'Published article link copied' });
} catch {
toast({ title: 'Could not copy the link', variant: 'destructive' });
}
}}
>
Copy published link
</DropdownMenuItem>
);
}
function PublishDialog({
open,
onOpenChange,
meta,
getMarkdown,
onPublished,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
meta: DocumentMeta;
getMarkdown: () => string;
onPublished: (publication: PublicationRecord) => void;
}) {
const { toast } = useToast();
const publish = useDocumentPublish();
// The snapshot is captured the moment the dialog body mounts (the dialog
// only renders it while open): what the owner reviews and confirms is
// exactly what gets published, even if typing continues in the background.
// The live document is never touched.
const [snapshot] = useState(() => {
const markdown = getMarkdown();
return { markdown, summary: deriveSummary(markdown) };
});
const [summary, setSummary] = useState(snapshot.summary);
const markdown = snapshot.markdown;
const empty = markdown.trim().length === 0;
if (!open) return null;
const handlePublish = () => {
publish.mutate(
{
meta,
markdown,
summary: summary.trim(),
attachments: meta.attachments,
},
{
onSuccess: ({ publication }) => {
onPublished(publication);
toast({ title: 'Document published', description: 'The snapshot is live on your relays.' });
},
onError: () => {
toast({
title: 'Publishing failed',
description: 'None of your write relays accepted the article. Try again.',
variant: 'destructive',
});
},
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Publish {meta.title}</DialogTitle>
<DialogDescription>
Publishes a portable Markdown snapshot as a Nostr long-form article (kind 30023).
This is a one-way release: the live document stays here and keeps autosaving
publishing never replaces or interrupts it.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-2">
<Label htmlFor="doc-summary">Summary</Label>
<Textarea
id="doc-summary"
value={summary}
onChange={(event) => setSummary(event.target.value)}
rows={2}
maxLength={280}
placeholder="What is this document about?"
/>
</div>
{meta.publication && (
<p className="text-xs text-muted-foreground">
This document was published before. Publishing again updates the article at the same
address.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={publish.isPending}>
Cancel
</Button>
<Button onClick={handlePublish} disabled={publish.isPending || empty}>
{publish.isPending && (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" aria-hidden />
)}
{publish.isPending ? 'Publishing…' : 'Publish snapshot'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,326 @@
import { useState } from 'react';
import type { Editor } from '@tiptap/core';
import {
Bold,
Code,
Code2,
Heading1,
Heading2,
Heading3,
Italic,
Link2,
List,
ListChecks,
ListOrdered,
Minus,
Quote,
Redo2,
Strikethrough,
Table,
Underline,
Undo2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { sanitizeUrl } from '@/lib/nostrUtils';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
interface FormatToolbarProps {
editor: Editor | null;
canEdit: boolean;
}
/**
* The compact formatting toolbar. It scrolls horizontally on narrow windows
* rather than wrapping, so every control stays one row-tap away at ~360px.
*/
export function FormatToolbar({ editor, canEdit }: FormatToolbarProps) {
const disabled = !editor || !canEdit;
return (
<div
role="toolbar"
aria-label="Formatting"
className="os-scroll flex h-10 shrink-0 items-center gap-0.5 overflow-x-auto overflow-y-hidden border-b border-border px-2"
>
<ToolbarButton
label="Undo"
shortcut="Ctrl+Z"
disabled={disabled || !editor.can().undo()}
onClick={() => editor?.chain().focus().undo().run()}
icon={<Undo2 className="size-4" aria-hidden />}
/>
<ToolbarButton
label="Redo"
shortcut="Ctrl+Shift+Z"
disabled={disabled || !editor.can().redo()}
onClick={() => editor?.chain().focus().redo().run()}
icon={<Redo2 className="size-4" aria-hidden />}
/>
<ToolbarDivider />
<ToolbarButton
label="Bold"
shortcut="Ctrl+B"
disabled={disabled}
active={editor?.isActive('bold')}
onClick={() => editor?.chain().focus().toggleBold().run()}
icon={<Bold className="size-4" aria-hidden />}
/>
<ToolbarButton
label="Italic"
shortcut="Ctrl+I"
disabled={disabled}
active={editor?.isActive('italic')}
onClick={() => editor?.chain().focus().toggleItalic().run()}
icon={<Italic className="size-4" aria-hidden />}
/>
<ToolbarButton
label="Underline"
shortcut="Ctrl+U"
disabled={disabled}
active={editor?.isActive('underline')}
onClick={() => editor?.chain().focus().toggleUnderline().run()}
icon={<Underline className="size-4" aria-hidden />}
/>
<ToolbarButton
label="Strikethrough"
disabled={disabled}
active={editor?.isActive('strike')}
onClick={() => editor?.chain().focus().toggleStrike().run()}
icon={<Strikethrough className="size-4" aria-hidden />}
/>
<ToolbarButton
label="Inline code"
shortcut="Ctrl+E"
disabled={disabled}
active={editor?.isActive('code')}
onClick={() => editor?.chain().focus().toggleCode().run()}
icon={<Code className="size-4" aria-hidden />}
/>
<LinkControl editor={editor} disabled={disabled} />
<ToolbarDivider />
<ToolbarButton
label="Heading 1"
disabled={disabled}
active={editor?.isActive('heading', { level: 1 })}
onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}
icon={<Heading1 className="size-4" aria-hidden />}
/>
<ToolbarButton
label="Heading 2"
disabled={disabled}
active={editor?.isActive('heading', { level: 2 })}
onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
icon={<Heading2 className="size-4" aria-hidden />}
/>
<ToolbarButton
label="Heading 3"
disabled={disabled}
active={editor?.isActive('heading', { level: 3 })}
onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
icon={<Heading3 className="size-4" aria-hidden />}
/>
<ToolbarDivider />
<ToolbarButton
label="Bullet list"
disabled={disabled}
active={editor?.isActive('bulletList')}
onClick={() => editor?.chain().focus().toggleBulletList().run()}
icon={<List className="size-4" aria-hidden />}
/>
<ToolbarButton
label="Numbered list"
disabled={disabled}
active={editor?.isActive('orderedList')}
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
icon={<ListOrdered className="size-4" aria-hidden />}
/>
<ToolbarButton
label="Checklist"
disabled={disabled}
active={editor?.isActive('taskList')}
onClick={() => editor?.chain().focus().toggleTaskList().run()}
icon={<ListChecks className="size-4" aria-hidden />}
/>
<ToolbarDivider />
<ToolbarButton
label="Quote"
disabled={disabled}
active={editor?.isActive('blockquote')}
onClick={() => editor?.chain().focus().toggleBlockquote().run()}
icon={<Quote className="size-4" aria-hidden />}
/>
<ToolbarButton
label="Code block"
disabled={disabled}
active={editor?.isActive('codeBlock')}
onClick={() => editor?.chain().focus().toggleCodeBlock().run()}
icon={<Code2 className="size-4" aria-hidden />}
/>
<ToolbarButton
label="Table"
disabled={disabled}
active={editor?.isActive('table')}
onClick={() => {
if (!editor) return;
if (editor.isActive('table')) editor.chain().focus().deleteTable().run();
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
}}
icon={<Table className="size-4" aria-hidden />}
/>
<ToolbarButton
label="Horizontal rule"
disabled={disabled}
onClick={() => editor?.chain().focus().setHorizontalRule().run()}
icon={<Minus className="size-4" aria-hidden />}
/>
</div>
);
}
function ToolbarButton({
label,
shortcut,
disabled,
active,
onClick,
icon,
}: {
label: string;
shortcut?: string;
disabled?: boolean;
active?: boolean;
onClick: () => void;
icon: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={shortcut ? `${label} (${shortcut})` : label}
aria-pressed={active}
title={shortcut ? `${label} (${shortcut})` : label}
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-md transition-colors',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
'disabled:cursor-not-allowed disabled:opacity-40',
active ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
{icon}
</button>
);
}
function ToolbarDivider() {
return <div className="mx-1 h-5 w-px shrink-0 bg-border" aria-hidden />;
}
function LinkControl({ editor, disabled }: { editor: Editor | null; disabled: boolean }) {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [value, setValue] = useState('');
const active = editor?.isActive('link') ?? false;
// Prefill with the current link's href when the popover opens on one. This
// is event-driven (not an effect), so it runs once per open gesture.
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (next) setValue((editor?.getAttributes('link').href as string | undefined) ?? '');
};
const apply = () => {
if (!editor) return;
const trimmed = value.trim();
if (!trimmed) {
editor.chain().focus().unsetLink().run();
setOpen(false);
return;
}
// The href is untrusted input: only allowlisted protocols survive.
const safe = sanitizeUrl(trimmed);
if (!safe) {
toast({
title: 'That link is not allowed',
description: 'Only https, http, mailto and nostr links work here.',
variant: 'destructive',
});
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: safe }).run();
setOpen(false);
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
aria-label="Link"
aria-pressed={active}
title="Link"
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-md transition-colors',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
'disabled:cursor-not-allowed disabled:opacity-40',
active ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
<Link2 className="size-4" aria-hidden />
</button>
</PopoverTrigger>
<PopoverContent className="w-72 space-y-2" align="start">
<label htmlFor="doc-link-href" className="text-xs font-medium">
Link URL
</label>
<Input
id="doc-link-href"
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="https://…"
inputMode="url"
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
apply();
}
}}
/>
<div className="flex justify-end gap-2">
{active && (
<Button
size="sm"
variant="ghost"
onClick={() => {
editor?.chain().focus().unsetLink().run();
setOpen(false);
}}
>
Remove
</Button>
)}
<Button size="sm" onClick={apply}>
{active ? 'Update' : 'Add link'}
</Button>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,226 @@
import { useCallback, useEffect } from 'react';
import { ChevronLeft, FilePlus2, FileText } from 'lucide-react';
import {
AppBody,
AppLayout,
AppSectionTitle,
AppSplit,
AppSidebar,
AppToolbar,
EmptyState,
} from '@/components/os/AppChrome';
import { LoginRequired } from '@/components/nostr/LoginRequired';
import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useToast } from '@/hooks/useToast';
import { useDocumentIndex } from '@/lib/documents/store';
import { deletePersistedDocument } from '@/lib/documents/ydoc';
import { relativeTime } from '@/lib/nostrUtils';
import type { DocumentMeta } from '@/lib/documents/types';
import { cn } from '@/lib/utils';
import type { AppProps } from '@/os/types';
import { DocumentEditor } from './DocumentEditor';
export default function DocumentsApp({ params, setTitle, setParams }: AppProps) {
const isMobile = useIsMobile();
const { user } = useCurrentUser();
const { toast } = useToast();
const { documents, createDocument, updateDocument, removeDocument } = useDocumentIndex();
// The open document lives in the window's params so a reload, a deep link
// and the desktop/mobile shell switch all agree on what is open.
const selectedId = params.doc ?? null;
const selected = documents.find((doc) => doc.id === selectedId) ?? null;
useEffect(() => {
setTitle(selected ? `Documents — ${selected.title}` : 'Documents');
}, [selected, setTitle]);
const openDocument = useCallback((id: string) => setParams({ doc: id }), [setParams]);
const closeDocument = useCallback(() => setParams({}), [setParams]);
const handleCreate = useCallback(() => {
const meta = createDocument('Untitled document');
openDocument(meta.id);
}, [createDocument, openDocument]);
const handleRename = useCallback(
(id: string, title: string) => {
updateDocument(id, { title, updatedAt: Date.now() });
},
[updateDocument],
);
const handleDelete = useCallback(
(id: string) => {
removeDocument(id);
// The body lives in IndexedDB; removing the index entry alone would
// leak it. Failures are non-fatal: the entry is already gone and an
// orphaned IndexedDB database is inert.
void deletePersistedDocument(id).catch(() => {});
toast({ title: 'Document deleted' });
if (selectedId === id) closeDocument();
},
[removeDocument, toast, selectedId, closeDocument],
);
if (!user) {
return (
<AppLayout>
<AppToolbar>
<span className="text-[13px] font-medium">Documents</span>
</AppToolbar>
<AppBody>
<LoginRequired action="write documents" />
</AppBody>
</AppLayout>
);
}
const listPane = (
<div className="flex h-full min-h-0 flex-col">
<div className="shrink-0 border-b border-border p-2">
<Button size="sm" className="w-full gap-1.5" onClick={handleCreate}>
<FilePlus2 className="size-3.5" aria-hidden />
New document
</Button>
</div>
<div className="os-scroll min-h-0 flex-1 overflow-y-auto">
<DocumentList
documents={documents}
selectedId={selectedId}
onSelect={openDocument}
onCreate={handleCreate}
/>
</div>
</div>
);
const editorPane = !selected ? (
<EmptyState
title={documents.length === 0 ? 'No documents yet' : 'Pick a document'}
hint={
documents.length === 0
? 'Create your first document — it stays on this device and autosaves as you type.'
: 'Choose one from the list, or start a new one.'
}
action={
documents.length === 0 ? (
<Button size="sm" className="gap-1.5" onClick={handleCreate}>
<FilePlus2 className="size-3.5" aria-hidden />
New document
</Button>
) : undefined
}
/>
) : (
<DocumentEditor
key={selected.id}
meta={selected}
onRename={handleRename}
onDelete={handleDelete}
onUpdateMeta={updateDocument}
/>
);
// Narrow windows have no room for a sidebar, so list and editor take turns.
if (isMobile) {
return (
<AppLayout>
{!selected && (
<AppToolbar>
<span className="text-[13px] font-medium">Documents</span>
</AppToolbar>
)}
{selected ? (
<div className="flex min-h-0 flex-1 flex-col">
<AppToolbar className="h-9">
<button
type="button"
onClick={closeDocument}
className="-ml-1 flex items-center gap-1 rounded px-1 py-0.5 text-[13px] font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<ChevronLeft className="size-4" aria-hidden />
All documents
</button>
</AppToolbar>
{editorPane}
</div>
) : (
<AppBody className="flex flex-col">{listPane}</AppBody>
)}
</AppLayout>
);
}
return (
<AppLayout>
<AppSplit>
<AppSidebar className="w-60 p-0">{listPane}</AppSidebar>
<AppBody className="flex flex-col">{editorPane}</AppBody>
</AppSplit>
</AppLayout>
);
}
function DocumentList({
documents,
selectedId,
onSelect,
onCreate,
}: {
documents: DocumentMeta[];
selectedId: string | null;
onSelect: (id: string) => void;
onCreate: () => void;
}) {
if (documents.length === 0) {
return (
<div className="px-3 py-6 text-center">
<p className="text-xs text-muted-foreground">
Nothing here yet.{' '}
<button
type="button"
onClick={onCreate}
className="font-medium text-primary hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
Create a document
</button>
</p>
</div>
);
}
return (
<>
<AppSectionTitle>Your documents</AppSectionTitle>
<ul className="pb-2">
{documents.map((doc) => (
<li key={doc.id}>
<button
type="button"
onClick={() => onSelect(doc.id)}
aria-current={doc.id === selectedId ? 'true' : undefined}
className={cn(
'w-full px-3 py-2 text-left transition-colors',
'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring',
doc.id === selectedId ? 'bg-accent text-accent-foreground' : 'hover:bg-muted',
)}
>
<span className="flex items-center gap-1.5">
<FileText className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="truncate text-[13px] font-medium">{doc.title}</span>
</span>
<span className="mt-0.5 block truncate pl-5 text-[11px] text-muted-foreground">
{doc.publication ? 'Published · ' : ''}
{relativeTime(Math.floor(doc.updatedAt / 1000))}
</span>
</button>
</li>
))}
</ul>
</>
);
}

View File

@@ -0,0 +1,198 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import { TableKit } from '@tiptap/extension-table';
import { Placeholder } from '@tiptap/extensions';
import type { Editor } from '@tiptap/core';
import type * as Y from 'yjs';
import { sanitizeUrl } from '@/lib/nostrUtils';
import { sanitizeHtml } from '@/lib/documents/sanitizeHtml';
import { docToMarkdown } from '@/lib/documents/markdown';
import { openDocumentSession } from '@/lib/documents/ydoc';
import { ROLE_CAPABILITIES } from '@/lib/documents/types';
import type { DocumentRole, SaveState } from '@/lib/documents/types';
const AUTOSAVE_DELAY = 1200;
export interface DocumentEditorOptions {
documentId: string;
role: DocumentRole;
/** Called after every debounced autosave (stamps the metadata index). */
onAutosaved?: () => void;
}
export interface DocumentEditor {
editor: Editor | null;
saveState: SaveState;
/** Word count of the current document. */
words: number;
canEdit: boolean;
/** Serialize the current document to portable Markdown. */
getMarkdown: () => string;
/** Replace the document with imported Markdown. */
replaceWithMarkdown: (markdown: string) => void;
}
/**
* Wires a Tiptap editor to the Yjs document behind `documentId`.
*
* - The `Y.Doc` + IndexedDB session lives in a ref, created and destroyed
* inside a `useEffect`: React Strict Mode's double-mount opens, closes and
* reopens it predictably — no duplicate persistence handles, no stale
* listener, and the closed first session cannot write over the second.
* - The editor binds to the Yjs document through the Collaboration
* extension, which is the exact seam where the Phase 2 Hocuspocus provider
* attaches (`Collaboration.configure({ document, provider })`). Nothing
* else about the app changes when that lands.
* - Local undo/redo stays enabled: Collaboration swaps in the Yjs undo
* manager only when a provider connects, so solo Phase 1 editing keeps the
* expected Word-like per-user history.
* - Pasted/dropped HTML passes through the allowlist sanitizer before the
* editor's schema parse; the schema itself drops anything else.
*/
export function useDocumentEditor({
documentId,
role,
onAutosaved,
}: DocumentEditorOptions): DocumentEditor {
const canEdit = ROLE_CAPABILITIES[role].edit;
// The session doc is set asynchronously once the session confirms it is
// alive, which recreates the editor bound to that document. Strict Mode's
// double-mount opens, closes and reopens the session predictably — the
// closed first session's `then` is guarded off before it can set state.
const [sessionDoc, setSessionDoc] = useState<Y.Doc | null>(null);
const [saveState, setSaveState] = useState<SaveState>('loading');
const [words, setWords] = useState(0);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const dirty = useRef(false);
const onAutosavedRef = useRef(onAutosaved);
useEffect(() => {
onAutosavedRef.current = onAutosaved;
}, [onAutosaved]);
const flushSave = useCallback(() => {
if (!dirty.current) return;
dirty.current = false;
// The Yjs update was already written to IndexedDB by the persistence
// layer; here we surface the save and stamp the metadata index.
setSaveState(navigator.onLine ? 'saved' : 'offline');
onAutosavedRef.current?.();
}, []);
const scheduleSave = useCallback(() => {
dirty.current = true;
setSaveState('saving');
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(flushSave, AUTOSAVE_DELAY);
}, [flushSave]);
// Open the Yjs session per document. The state write happens inside the
// session's promise, never synchronously in the effect body.
useEffect(() => {
let active = true;
const session = openDocumentSession(documentId, {
onSynced: () => {
if (active) setSaveState(navigator.onLine ? 'saved' : 'offline');
},
// IndexedDB persists every update immediately; the debounced React
// affordance is driven by the editor's own onUpdate.
onUpdate: () => {},
});
session.whenSynced.then(() => {
if (active) setSessionDoc(session.doc);
});
return () => {
active = false;
setSessionDoc(null);
if (saveTimer.current) clearTimeout(saveTimer.current);
dirty.current = false;
session.destroy();
};
}, [documentId]);
const editor = useEditor(
{
immediatelyRender: false,
editable: canEdit,
shouldRerenderOnTransaction: true,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: {
openOnClick: false,
autolink: true,
// Only allowlisted protocols may become links. Everything else
// (javascript:, data:, vbscript:) is dropped by the extension.
isAllowedUri: (url, ctx) => {
if (ctx.defaultValidate(url)) return true;
return sanitizeUrl(url) !== undefined;
},
},
}),
...(sessionDoc ? [Collaboration.configure({ document: sessionDoc })] : []),
TableKit.configure({ table: { resizable: false } }),
Placeholder.configure({ placeholder: 'Start writing…' }),
],
editorProps: {
attributes: {
class: 'doc-editor',
// The editable region is the labelled document body.
role: 'textbox',
'aria-multiline': 'true',
},
transformPastedHTML: (html) => sanitizeHtml(html),
},
onUpdate: ({ editor: instance }) => {
setWords(countWords(instance.getText()));
scheduleSave();
},
},
[documentId, sessionDoc, canEdit],
);
// Coming back online updates the autosave affordance.
useEffect(() => {
const onOnline = () => setSaveState((s) => (s === 'offline' ? 'saved' : s));
const onOffline = () => setSaveState((s) => (s === 'saved' ? 'offline' : s));
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
return () => {
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
};
}, []);
// Keep editability in sync if the role changes while the editor is open.
useEffect(() => {
editor?.setEditable(canEdit);
}, [editor, canEdit]);
const getMarkdown = useCallback(
() => (editor ? docToMarkdown(editor.getJSON()) : ''),
[editor],
);
const replaceWithMarkdown = useCallback(
(markdown: string) => {
if (!editor || !canEdit) return;
// Lazy import keeps the parser out of the first-paint path.
import('@/lib/documents/markdown').then(({ markdownToDoc }) => {
const doc = markdownToDoc(markdown);
editor.chain().focus().setContent(doc.content ?? []).run();
});
},
[editor, canEdit],
);
return { editor, saveState, words, canEdit, getMarkdown, replaceWithMarkdown };
}
function countWords(text: string): number {
const trimmed = text.trim();
if (!trimmed) return 0;
return trimmed.split(/\s+/).length;
}

View File

@@ -0,0 +1,34 @@
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { buildPublishTemplate, publicationFromEvent } from '@/lib/documents/publish';
import type { DocumentAttachment, DocumentMeta, PublicationRecord } from '@/lib/documents/types';
export interface PublishInput {
meta: DocumentMeta;
markdown: string;
summary: string;
attachments: DocumentAttachment[];
}
export interface PublishResult {
event: NostrEvent;
publication: PublicationRecord;
}
/**
* Publishes the owner-approved Markdown snapshot as a NIP-23 kind 30023
* event. The mutation stays idle until the owner explicitly confirms, so a
* published snapshot can never silently replace live collaborative work.
*/
export function useDocumentPublish() {
const { mutateAsync } = useNostrPublish();
return useMutation<PublishResult, Error, PublishInput>({
mutationFn: async ({ meta, markdown, summary, attachments }) => {
const template = buildPublishTemplate(meta, markdown, summary, attachments);
const event = await mutateAsync(template);
return { event, publication: publicationFromEvent(event, meta.title) };
},
});
}

View File

@@ -348,3 +348,171 @@ body.os-dragging .os-window-content {
animation: os-fade-in 120ms ease-out;
}
}
/* --------------------------------------------------------- Documents editor */
/*
* The Tiptap surface. Styles target the schema's rendered elements only;
* colors come from theme variables so light/dark just work. All selectors
* are element/class based — no untrusted value is ever interpolated here.
*/
.doc-editor {
min-height: 100%;
outline: none;
padding: 1.25rem 1rem 40vh;
font-size: 1rem;
line-height: 1.7;
}
@media (min-width: 640px) {
.doc-editor {
padding: 2rem 2.5rem 40vh;
}
}
.doc-editor > * + * {
margin-top: 0.75rem;
}
.doc-editor h1 {
font-size: 1.875rem;
font-weight: 650;
line-height: 1.2;
letter-spacing: -0.01em;
margin-top: 1.5rem;
}
.doc-editor h2 {
font-size: 1.375rem;
font-weight: 650;
line-height: 1.3;
margin-top: 1.5rem;
}
.doc-editor h3 {
font-size: 1.125rem;
font-weight: 600;
margin-top: 1.25rem;
}
.doc-editor ul,
.doc-editor ol {
padding-left: 1.5rem;
}
.doc-editor ul:not([data-type='taskList']) {
list-style: disc;
}
.doc-editor ol {
list-style: decimal;
}
.doc-editor li > ul,
.doc-editor li > ol {
margin-top: 0.25rem;
}
.doc-editor blockquote {
border-left: 2px solid var(--primary);
padding-left: 1rem;
color: var(--muted-foreground);
font-style: italic;
}
.doc-editor code {
background: var(--muted);
border-radius: 0.25rem;
padding: 0.1rem 0.35rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
}
.doc-editor pre {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
overflow-x: auto;
}
.doc-editor pre code {
background: transparent;
padding: 0;
font-size: 0.8125rem;
}
.doc-editor a {
color: var(--primary);
text-decoration: underline;
text-decoration-color: color-mix(in oklab, var(--primary) 40%, transparent);
text-underline-offset: 2px;
}
.doc-editor a:hover {
text-decoration-color: var(--primary);
}
.doc-editor hr {
border-color: var(--border);
margin: 1.5rem 0;
}
.doc-editor table {
border-collapse: collapse;
width: 100%;
font-size: 0.875rem;
}
.doc-editor th,
.doc-editor td {
border: 1px solid var(--border);
padding: 0.375rem 0.625rem;
text-align: left;
vertical-align: top;
}
.doc-editor th {
background: var(--muted);
font-weight: 600;
}
.doc-editor .selectedCell {
background: color-mix(in oklab, var(--accent) 60%, transparent);
}
/* Checklists render without a bullet and align the box with the first line. */
.doc-editor ul[data-type='taskList'] {
list-style: none;
padding-left: 0.25rem;
}
.doc-editor ul[data-type='taskList'] li {
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.doc-editor ul[data-type='taskList'] li > label {
margin-top: 0.3rem;
user-select: none;
}
.doc-editor ul[data-type='taskList'] li > div {
flex: 1;
min-width: 0;
}
.doc-editor ul[data-type='taskList'] li[data-checked='true'] > div {
color: var(--muted-foreground);
text-decoration: line-through;
}
/* Placeholder: shown only on the first empty node of an empty document. */
.doc-editor p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: var(--muted-foreground);
float: left;
height: 0;
pointer-events: none;
}

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { attachmentFromImetaTags, attachmentToImetaTags } from './attachments';
const SHA = 'a'.repeat(64);
describe('attachmentFromImetaTags', () => {
it('parses Blossom uploader tags', () => {
const result = attachmentFromImetaTags([
['url', 'https://blossom.example/file.png'],
['m', 'image/png'],
['x', SHA],
['size', '2048'],
]);
expect(result).toEqual({
url: 'https://blossom.example/file.png',
mimeType: 'image/png',
sha256: SHA,
size: 2048,
});
});
it('parses a combined imeta tag', () => {
const result = attachmentFromImetaTags([
['imeta', `url https://blossom.example/file.pdf`, 'm application/pdf', `x ${SHA}`],
]);
expect(result).toEqual({
url: 'https://blossom.example/file.pdf',
mimeType: 'application/pdf',
sha256: SHA,
});
});
it('rejects non-https and unsafe URLs', () => {
expect(attachmentFromImetaTags([['url', 'javascript:alert(1)']])).toBeNull();
expect(attachmentFromImetaTags([['url', 'not a url']])).toBeNull();
expect(attachmentFromImetaTags([['m', 'image/png']])).toBeNull();
});
it('drops malformed hashes and sizes', () => {
const result = attachmentFromImetaTags([
['url', 'https://blossom.example/file.png'],
['x', 'not-a-hash'],
['size', '-5'],
]);
expect(result).toEqual({ url: 'https://blossom.example/file.png' });
});
});
describe('attachmentToImetaTags', () => {
it('round-trips through attachmentFromImetaTags', () => {
const original = {
url: 'https://blossom.example/file.png',
mimeType: 'image/png',
sha256: SHA,
size: 2048,
};
const tags = attachmentToImetaTags(original);
expect(attachmentFromImetaTags(tags)).toEqual(original);
});
it('includes the bare x tag for NIP-94 addressability', () => {
const tags = attachmentToImetaTags({ url: 'https://blossom.example/f', sha256: SHA });
expect(tags).toContainEqual(['x', SHA]);
});
});

View File

@@ -0,0 +1,60 @@
import { sanitizeUrl } from '@/lib/nostrUtils';
import type { DocumentAttachment } from './types';
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
/**
* Turn raw Blossom uploader tags (NIP-94 `imeta` triplets) into validated
* attachment metadata. Anything untrusted — non-https URLs, malformed hashes,
* negative sizes — is dropped rather than passed through.
*/
export function attachmentFromImetaTags(tags: string[][]): DocumentAttachment | null {
const flat = new Map<string, string>();
for (const tag of tags) {
const [name, ...values] = tag;
if (!name || values.length === 0) continue;
if (name === 'imeta') {
// Each remaining entry is one `key value` pair.
for (const entry of values) {
const space = entry.indexOf(' ');
if (space <= 0) continue;
const key = entry.slice(0, space);
const val = entry.slice(space + 1);
if (!flat.has(key)) flat.set(key, val);
}
continue;
}
// Bare NIP-94 tags: `url`, `m`, `x`, `size`.
if (!flat.has(name)) flat.set(name, values[0]);
}
const url = sanitizeUrl(flat.get('url'));
if (!url || !url.startsWith('https:')) return null;
const attachment: DocumentAttachment = { url };
const mimeType = flat.get('m');
if (mimeType && /^[\w.+-]+\/[\w.+-]+$/.test(mimeType)) attachment.mimeType = mimeType;
const sha256 = flat.get('x');
if (sha256 && SHA256_PATTERN.test(sha256)) attachment.sha256 = sha256;
const size = Number(flat.get('size'));
if (Number.isFinite(size) && size >= 0) attachment.size = size;
return attachment;
}
/** Serialize an attachment back into NIP-94 tags for the publish event. */
export function attachmentToImetaTags(attachment: DocumentAttachment): string[][] {
const imeta = [
`url ${attachment.url}`,
attachment.mimeType ? `m ${attachment.mimeType}` : undefined,
attachment.sha256 ? `x ${attachment.sha256}` : undefined,
attachment.size !== undefined ? `size ${attachment.size}` : undefined,
].filter((part): part is string => typeof part === 'string');
const tags: string[][] = [['imeta', ...imeta]];
if (attachment.sha256) tags.push(['x', attachment.sha256]);
return tags;
}

View File

@@ -0,0 +1,305 @@
import { describe, expect, it } from 'vitest';
import type { JSONContent } from '@tiptap/core';
import { docToMarkdown, markdownToDoc } from './markdown';
function doc(...content: JSONContent[]): JSONContent {
return { type: 'doc', content };
}
function para(...content: JSONContent[]): JSONContent {
return { type: 'paragraph', content };
}
function text(text: string, marks?: JSONContent['marks']): JSONContent {
const node: JSONContent = { type: 'text', text };
if (marks) node.marks = marks;
return node;
}
describe('docToMarkdown', () => {
it('serializes headings and paragraphs', () => {
const input = doc(
{ type: 'heading', attrs: { level: 1 }, content: [text('Title')] },
para(text('Hello world')),
);
expect(docToMarkdown(input)).toBe('# Title\n\nHello world');
});
it('serializes inline marks', () => {
const input = para(
text('a '),
text('bold', [{ type: 'bold' }]),
text(' and '),
text('italic', [{ type: 'italic' }]),
text(' and '),
text('struck', [{ type: 'strike' }]),
text(' and '),
text('code', [{ type: 'code' }]),
);
expect(docToMarkdown(doc(input))).toBe('a **bold** and *italic* and ~~struck~~ and `code`');
});
it('serializes links and drops unsafe hrefs', () => {
const input = para(
text('safe', [{ type: 'link', attrs: { href: 'https://example.com' } }]),
text(' '),
text('evil', [{ type: 'link', attrs: { href: 'javascript:alert(1)' } }]),
);
expect(docToMarkdown(doc(input))).toBe('[safe](https://example.com) evil');
});
it('serializes bullet, ordered and task lists', () => {
const input = doc(
{
type: 'bulletList',
content: [
{ type: 'listItem', content: [para(text('one'))] },
{
type: 'listItem',
content: [
para(text('two')),
{
type: 'bulletList',
content: [{ type: 'listItem', content: [para(text('nested'))] }],
},
],
},
],
},
{
type: 'orderedList',
content: [
{ type: 'listItem', content: [para(text('first'))] },
{ type: 'listItem', content: [para(text('second'))] },
],
},
{
type: 'taskList',
content: [
{ type: 'taskItem', attrs: { checked: true }, content: [para(text('done'))] },
{ type: 'taskItem', attrs: { checked: false }, content: [para(text('todo'))] },
],
},
);
expect(docToMarkdown(input)).toBe(
'- one\n- two\n - nested\n\n1. first\n2. second\n\n- [x] done\n- [ ] todo',
);
});
it('serializes quotes, code blocks and rules', () => {
const input = doc(
{ type: 'blockquote', content: [para(text('wise words'))] },
{
type: 'codeBlock',
attrs: { language: 'ts' },
content: [text('const a = 1;')],
},
{ type: 'horizontalRule' },
);
expect(docToMarkdown(input)).toBe('> wise words\n\n```ts\nconst a = 1;\n```\n\n---');
});
it('serializes tables as GFM', () => {
const input = doc({
type: 'table',
content: [
{
type: 'tableRow',
content: [
{ type: 'tableHeader', content: [para(text('Name'))] },
{ type: 'tableHeader', content: [para(text('Role'))] },
],
},
{
type: 'tableRow',
content: [
{ type: 'tableCell', content: [para(text('Ada'))] },
{ type: 'tableCell', content: [para(text('owner'))] },
],
},
],
});
expect(docToMarkdown(input)).toBe(
'| Name | Role |\n| --- | --- |\n| Ada | owner |',
);
});
it('escapes characters that would change meaning', () => {
const input = para(text('1. not a list # not a heading *not italic*'));
expect(docToMarkdown(doc(input))).toBe('1\\. not a list \\# not a heading \\*not italic\\*');
});
it('does not mangle text that is already a plain sentence', () => {
const input = para(text('Hello world'));
expect(docToMarkdown(doc(input))).toBe('Hello world');
});
it('returns an empty string for an empty document', () => {
expect(docToMarkdown(doc(para()))).toBe('');
expect(docToMarkdown(undefined)).toBe('');
});
});
describe('markdownToDoc', () => {
it('parses headings and paragraphs', () => {
const result = markdownToDoc('# Title\n\nHello world');
expect(result).toEqual(
doc(
{ type: 'heading', attrs: { level: 1 }, content: [text('Title')] },
para(text('Hello world')),
),
);
});
it('parses inline marks', () => {
const result = markdownToDoc('a **bold** and *italic* and ~~struck~~ and `code`');
expect(result).toEqual(
doc(
para(
text('a '),
text('bold', [{ type: 'bold' }]),
text(' and '),
text('italic', [{ type: 'italic' }]),
text(' and '),
text('struck', [{ type: 'strike' }]),
text(' and '),
text('code', [{ type: 'code' }]),
),
),
);
});
it('parses links and keeps unsafe hrefs as literal text', () => {
const result = markdownToDoc('[safe](https://example.com) [evil](javascript:alert(1))');
expect(result).toEqual(
doc(
para(
text('safe', [{ type: 'link', attrs: { href: 'https://example.com' } }]),
text(' [evil](javascript:alert(1))'),
),
),
);
});
it('parses bullet, ordered and task lists with nesting', () => {
const result = markdownToDoc('- one\n- two\n - nested\n\n1. first\n2. second\n\n- [x] done\n- [ ] todo');
expect(result).toEqual(
doc(
{
type: 'bulletList',
content: [
{ type: 'listItem', content: [para(text('one'))] },
{
type: 'listItem',
content: [
para(text('two')),
{
type: 'bulletList',
content: [{ type: 'listItem', content: [para(text('nested'))] }],
},
],
},
],
},
{
type: 'orderedList',
content: [
{ type: 'listItem', content: [para(text('first'))] },
{ type: 'listItem', content: [para(text('second'))] },
],
},
{
type: 'taskList',
content: [
{ type: 'taskItem', attrs: { checked: true }, content: [para(text('done'))] },
{ type: 'taskItem', attrs: { checked: false }, content: [para(text('todo'))] },
],
},
),
);
});
it('parses quotes, code blocks and rules', () => {
const result = markdownToDoc('> wise words\n\n```ts\nconst a = 1;\n```\n\n---');
expect(result).toEqual(
doc(
{ type: 'blockquote', content: [para(text('wise words'))] },
{
type: 'codeBlock',
attrs: { language: 'ts' },
content: [text('const a = 1;')],
},
{ type: 'horizontalRule' },
),
);
});
it('parses GFM tables', () => {
const result = markdownToDoc('| Name | Role |\n| --- | --- |\n| Ada | owner |');
expect(result).toEqual(
doc({
type: 'table',
content: [
{
type: 'tableRow',
content: [
{ type: 'tableHeader', content: [para(text('Name'))] },
{ type: 'tableHeader', content: [para(text('Role'))] },
],
},
{
type: 'tableRow',
content: [
{ type: 'tableCell', content: [para(text('Ada'))] },
{ type: 'tableCell', content: [para(text('owner'))] },
],
},
],
}),
);
});
it('unescapes escaped characters', () => {
const result = markdownToDoc('1\\. not a list \\# not a heading \\*not italic\\*');
expect(result).toEqual(doc(para(text('1. not a list # not a heading *not italic*'))));
});
});
describe('round trip', () => {
it('serialize(parse(x)) preserves the constructs the schema supports', () => {
const source = [
'# Title',
'',
'Some **bold** and *italic* and ~~struck~~ and `code` and a [link](https://example.com).',
'',
'- one',
'- two',
' - nested',
'',
'1. first',
'2. second',
'',
'- [x] done',
'- [ ] todo',
'',
'> wise words',
'',
'```ts',
'const a = 1;',
'```',
'',
'| Name | Role |',
'| --- | --- |',
'| Ada | owner |',
'',
'---',
].join('\n');
// The serializer escapes trailing punctuation aggressively; that output
// must parse back to the identical document, and the second serialization
// must be stable.
const once = docToMarkdown(markdownToDoc(source));
expect(markdownToDoc(once)).toEqual(markdownToDoc(source));
expect(docToMarkdown(markdownToDoc(once))).toBe(once);
});
});

View File

@@ -0,0 +1,559 @@
import type { JSONContent } from '@tiptap/core';
/**
* ProseMirror JSON ↔ portable Markdown.
*
* The serializer produces the NIP-23 snapshot body. It is a small, fully
* understood walk over the constrained editor schema: the only HTML that
* ever reaches the editor has already passed `sanitizeHtml`, and the output
* here is plain text, so no untrusted HTML survives a publish.
*
* The parser covers the same constructs so a published snapshot can be
* imported back and tests can round-trip. It is not a general Markdown
* implementation — anything unrecognised degrades to a paragraph instead of
* dropping content.
*/
const MENTION_PATTERN = /^nostr:((npub|nprofile|note|nevent|naddr)1[02-9ac-hj-np-z]+)$/;
// ---------------------------------------------------------------------------
// Serializer
// ---------------------------------------------------------------------------
function escapeText(text: string): string {
// Escape every ASCII punctuation character. CommonMark defines a backslash
// before ASCII punctuation as the literal character, so this is uniform,
// always round-trips, and needs no positional special cases.
return text.replace(/([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g, '\\$1');
}
function isSafeHref(href: string | undefined | null): href is string {
if (!href) return false;
if (MENTION_PATTERN.test(href)) return true;
try {
const parsed = new URL(href, 'https://layer.invalid');
return ['https:', 'http:', 'mailto:'].includes(parsed.protocol);
} catch {
return false;
}
}
function serializeInline(nodes: JSONContent[] | undefined): string {
if (!nodes) return '';
let out = '';
for (const node of nodes) {
if (node.type === 'hardBreak') {
out += '\\\n';
continue;
}
if (node.type !== 'text' || typeof node.text !== 'string') continue;
const prefix: string[] = [];
const suffix: string[] = [];
let code = false;
for (const mark of node.marks ?? []) {
switch (mark.type) {
case 'code':
code = true;
break;
case 'bold': prefix.push('**'); suffix.unshift('**'); break;
case 'italic': prefix.push('*'); suffix.unshift('*'); break;
case 'strike': prefix.push('~~'); suffix.unshift('~~'); break;
case 'underline': prefix.push('<u>'); suffix.unshift('</u>'); break;
case 'link': {
const href = (mark.attrs as { href?: string } | undefined)?.href;
if (isSafeHref(href)) {
prefix.push('[');
suffix.unshift(`](${href})`);
}
break;
}
default:
break;
}
}
let text: string;
if (code) {
// Inline code is never escaped; pick a fence that cannot collide.
const longest = Math.max(0, ...[...node.text.matchAll(/`+/g)].map((m) => m[0].length));
const fence = '`'.repeat(longest + 1);
const pad = node.text.startsWith('`') || node.text.endsWith('`') ? ' ' : '';
text = `${fence}${pad}${node.text}${pad}${fence}`;
} else {
text = escapeText(node.text);
}
out += `${prefix.join('')}${text}${suffix.join('')}`;
}
return out;
}
function serializeList(
node: JSONContent,
depth: number,
ordered: boolean,
task: boolean,
): string[] {
const lines: string[] = [];
let index = 0;
for (const item of node.content ?? []) {
index += 1;
const indent = ' '.repeat(depth);
const marker = task ? '-' : ordered ? `${index}.` : '-';
const checkbox = task
? ` [${(item.attrs as { checked?: boolean } | undefined)?.checked ? 'x' : ' '}]`
: '';
let first = true;
for (const block of item.content ?? []) {
if (block.type === 'paragraph') {
const text = serializeInline(block.content);
lines.push(first ? `${indent}${marker}${checkbox} ${text}` : `${indent} ${text}`);
first = false;
} else if (
block.type === 'bulletList' ||
block.type === 'orderedList' ||
block.type === 'taskList'
) {
lines.push(
...serializeList(
block,
depth + 1,
block.type === 'orderedList',
block.type === 'taskList',
),
);
} else {
const nested = serializeBlock(block);
if (nested) {
lines.push(...nested.split('\n').map((line) => `${indent} ${line}`));
}
}
}
if (first) lines.push(`${indent}${marker}${checkbox}`);
}
return lines;
}
function serializeTable(node: JSONContent): string[] {
const rows: string[][] = [];
for (const row of node.content ?? []) {
if (row.type !== 'tableRow') continue;
const cells: string[] = [];
for (const cell of row.content ?? []) {
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
const text = (cell.content ?? [])
.map((block) => serializeInline(block.content))
.join(' ')
.replace(/\|/g, '\\|')
.replace(/\n/g, ' ')
.trim();
cells.push(text);
}
rows.push(cells);
}
if (rows.length === 0) return [];
const width = Math.max(...rows.map((row) => row.length));
const padded = rows.map((row) => [...row, ...Array<string>(width - row.length).fill('')]);
const lines = padded.map((row) => `| ${row.join(' | ')} |`);
// GFM needs a header row; the first row always plays that role.
const separator = `| ${Array<string>(width).fill('---').join(' | ')} |`;
return [lines[0], separator, ...lines.slice(1)];
}
function serializeBlock(node: JSONContent): string {
switch (node.type) {
case 'paragraph':
return serializeInline(node.content);
case 'heading': {
const level = Math.min(Math.max(Number(node.attrs?.level) || 1, 1), 6);
const text = serializeInline(node.content);
return text ? `${'#'.repeat(level)} ${text}` : '';
}
case 'blockquote': {
const inner = serializeBlocks(node.content);
return inner
.split('\n')
.map((line) => (line ? `> ${line}` : '>'))
.join('\n');
}
case 'codeBlock': {
const language = typeof node.attrs?.language === 'string' ? node.attrs.language : '';
const body = (node.content ?? [])
.filter((child) => child.type === 'text' && typeof child.text === 'string')
.map((child) => child.text)
.join('');
const longest = Math.max(2, ...[...body.matchAll(/`+/g)].map((m) => m[0].length));
const fence = '`'.repeat(longest + 1);
return `${fence}${language}\n${body.replace(/\n$/, '')}\n${fence}`;
}
case 'horizontalRule':
return '---';
case 'bulletList':
case 'orderedList':
case 'taskList':
return serializeList(
node,
0,
node.type === 'orderedList',
node.type === 'taskList',
).join('\n');
case 'table':
return serializeTable(node).join('\n');
default:
return '';
}
}
function serializeBlocks(nodes: JSONContent[] | undefined): string {
return (nodes ?? [])
.map((node) => serializeBlock(node))
.filter((block) => block.trim().length > 0)
.join('\n\n');
}
/** Serialize the editor document to portable Markdown. */
export function docToMarkdown(doc: JSONContent | null | undefined): string {
if (!doc) return '';
return serializeBlocks(doc.content).replace(/\n{3,}/g, '\n\n').trim();
}
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
type Mark = NonNullable<JSONContent['marks']>[number];
function textNode(text: string, marks?: Mark[]): JSONContent {
const node: JSONContent = { type: 'text', text };
if (marks && marks.length > 0) node.marks = marks;
return node;
}
const UNESCAPE = /\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g;
const UNESCAPE_SINGLE = /^[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]$/;
/** Inline Markdown → text nodes: code, bold, italic, strike, links, mentions. */
function parseInline(source: string): JSONContent[] {
const nodes: JSONContent[] = [];
let rest = source;
const pushText = (text: string) => {
if (text) nodes.push(textNode(text.replace(UNESCAPE, '$1')));
};
while (rest.length > 0) {
// Backslash escape: the punctuation after it is always literal. This must
// win over every construct below (e.g. `\*` must not open emphasis).
if (rest[0] === '\\') {
if (rest.length > 1 && UNESCAPE_SINGLE.test(rest[1])) {
pushText(rest[1]);
rest = rest.slice(2);
} else {
pushText(rest[0]);
rest = rest.slice(1);
}
continue;
}
// Inline code: `code` with any fence length.
const code = rest.match(/^(`+)(.+?)\1(?!`)/s);
if (code) {
nodes.push(textNode(code[2], [{ type: 'code' }]));
rest = rest.slice(code[0].length);
continue;
}
// [label](href)
const link = rest.match(/^\[([^\]]*)\]\(([^)\s]+)\)/);
if (link) {
const [, label, href] = link;
if (isSafeHref(href)) {
for (const inner of parseInline(label)) {
nodes.push({ ...inner, marks: [...(inner.marks ?? []), { type: 'link', attrs: { href } }] });
}
} else {
pushText(link[0]);
}
rest = rest.slice(link[0].length);
continue;
}
// Bare nostr: mention becomes a link.
const mention = rest.match(/^nostr:((npub|nprofile|note|nevent|naddr)1[02-9ac-hj-np-z]+)/);
if (mention) {
nodes.push(textNode(`${mention[1].slice(0, 16)}`, [{ type: 'link', attrs: { href: mention[0] } }]));
rest = rest.slice(mention[0].length);
continue;
}
// Bold, italic, strike — longest token first.
const bold = rest.match(/^\*\*([^*]+)\*\*/) ?? rest.match(/^__([^_]+)__/);
if (bold) {
for (const inner of parseInline(bold[1])) {
nodes.push({ ...inner, marks: [...(inner.marks ?? []), { type: 'bold' } satisfies Mark] });
}
rest = rest.slice(bold[0].length);
continue;
}
const italic = rest.match(/^\*([^*]+)\*/) ?? rest.match(/^_([^_]+)_/);
if (italic) {
for (const inner of parseInline(italic[1])) {
nodes.push({ ...inner, marks: [...(inner.marks ?? []), { type: 'italic' } satisfies Mark] });
}
rest = rest.slice(italic[0].length);
continue;
}
const strike = rest.match(/^~~([^~]+)~~/);
if (strike) {
for (const inner of parseInline(strike[1])) {
nodes.push({ ...inner, marks: [...(inner.marks ?? []), { type: 'strike' } satisfies Mark] });
}
rest = rest.slice(strike[0].length);
continue;
}
// Plain text up to the next possible construct.
const next = rest.search(/[`*_~[\]!]|\\(?=[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])|nostr:/);
if (next === -1) {
pushText(rest);
break;
}
if (next === 0) {
pushText(rest[0]);
rest = rest.slice(1);
continue;
}
pushText(rest.slice(0, next));
rest = rest.slice(next);
}
// Merge adjacent text nodes carrying identical marks.
const merged: JSONContent[] = [];
for (const node of nodes) {
const prev = merged[merged.length - 1];
if (
prev &&
prev.type === 'text' &&
node.type === 'text' &&
JSON.stringify(prev.marks ?? []) === JSON.stringify(node.marks ?? [])
) {
prev.text = (prev.text ?? '') + (node.text ?? '');
} else {
merged.push(node);
}
}
return merged;
}
const LIST_ITEM = /^(\s*)([-+*]|\d+\.)\s+(?:\[( |x|X)\][ \t]+)?(.*)$/;
interface ParsedListItem {
indent: number;
ordered: boolean;
task: boolean;
checked: boolean;
text: string;
}
/** Turn a flat run of list-item lines into nested list nodes. */
function buildList(items: ParsedListItem[]): JSONContent | null {
if (items.length === 0) return null;
interface Frame {
indent: number;
type: 'bulletList' | 'orderedList' | 'taskList';
node: JSONContent;
/** Last item added, so a deeper list nests underneath it. */
lastItem: JSONContent | null;
}
const first = items[0];
const root: Frame = {
indent: first.indent,
type: first.task ? 'taskList' : first.ordered ? 'orderedList' : 'bulletList',
node: { type: first.task ? 'taskList' : first.ordered ? 'orderedList' : 'bulletList', content: [] },
lastItem: null,
};
const stack: Frame[] = [root];
for (const item of items) {
const type = item.task ? 'taskList' : item.ordered ? 'orderedList' : 'bulletList';
// Pop frames this line no longer belongs to.
while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {
stack.pop();
}
let frame = stack[stack.length - 1];
if (item.indent > frame.indent) {
// Deeper: nest a new list inside the previous item.
frame = { indent: item.indent, type, node: { type, content: [] }, lastItem: null };
const parent = stack[stack.length - 1].lastItem;
if (parent) {
parent.content = [...(parent.content ?? []), frame.node];
}
stack.push(frame);
} else if (frame.type !== type && item.indent === frame.indent) {
// Marker flavor changed at the same depth: switch this list's type.
frame.type = type;
frame.node.type = type;
}
const node: JSONContent = {
type: item.task ? 'taskItem' : 'listItem',
...(item.task ? { attrs: { checked: item.checked } } : {}),
content: [{ type: 'paragraph', content: parseInline(item.text) }],
};
frame.node.content = [...(frame.node.content ?? []), node];
frame.lastItem = node;
}
return root.node;
}
/** Parse Markdown into a ProseMirror-compatible JSON document. */
export function markdownToDoc(markdown: string): JSONContent {
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
const blocks: JSONContent[] = [];
let paragraph: string[] = [];
const flushParagraph = () => {
const text = paragraph.join(' ').trim();
paragraph = [];
if (!text) return;
const content = parseInline(text);
blocks.push({ type: 'paragraph', ...(content.length > 0 ? { content } : {}) });
};
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Fenced code block.
const fence = line.match(/^(`{3,}|~{3,})([\w-]*)\s*$/);
if (fence) {
flushParagraph();
const marker = fence[1][0];
const length = fence[1].length;
const language = fence[2] ?? '';
const body: string[] = [];
i += 1;
const closing = new RegExp(`^\\${marker}{${length},}\\s*$`);
while (i < lines.length && !closing.test(lines[i])) {
body.push(lines[i]);
i += 1;
}
i += 1; // consume the closing fence (or run off the end)
blocks.push({
type: 'codeBlock',
attrs: { language: language || null },
...(body.length > 0 ? { content: [{ type: 'text', text: body.join('\n') }] } : {}),
});
continue;
}
// Table: header row, | --- | separator, then body rows.
if (/^\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
flushParagraph();
const parseRow = (row: string, header: boolean): JSONContent => ({
type: 'tableRow',
content: row
.replace(/^\|/, '')
.replace(/\|$/, '')
.split(/(?<!\\)\|/)
.map((cell) => ({
type: header ? 'tableHeader' : 'tableCell',
content: [
{
type: 'paragraph',
content: parseInline(cell.replace(/\\\|/g, '|').trim()),
},
],
})),
});
const rows: JSONContent[] = [parseRow(line, true)];
i += 2;
while (i < lines.length && /^\|.*\|\s*$/.test(lines[i])) {
rows.push(parseRow(lines[i], false));
i += 1;
}
blocks.push({ type: 'table', content: rows });
continue;
}
// Heading.
const heading = line.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
flushParagraph();
const content = parseInline(heading[2].trim());
blocks.push({
type: 'heading',
attrs: { level: heading[1].length },
...(content.length > 0 ? { content } : {}),
});
i += 1;
continue;
}
// Horizontal rule.
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
flushParagraph();
blocks.push({ type: 'horizontalRule' });
i += 1;
continue;
}
// Blockquote: collect the run and parse the inner text recursively.
if (/^\s*>/.test(line)) {
flushParagraph();
const inner: string[] = [];
while (i < lines.length && /^\s*>/.test(lines[i])) {
inner.push(lines[i].replace(/^\s*>\s?/, ''));
i += 1;
}
const doc = markdownToDoc(inner.join('\n'));
blocks.push({ type: 'blockquote', content: doc.content ?? [] });
continue;
}
// List run (bullets, ordered, tasks), nested by two-space indentation.
if (LIST_ITEM.test(line)) {
flushParagraph();
const items: ParsedListItem[] = [];
while (i < lines.length && LIST_ITEM.test(lines[i])) {
const match = lines[i].match(LIST_ITEM)!;
items.push({
indent: match[1].replace(/\t/g, ' ').length,
ordered: /^\d+\.$/.test(match[2]),
task: match[3] !== undefined,
checked: (match[3] ?? '').toLowerCase() === 'x',
text: match[4] ?? '',
});
i += 1;
}
const list = buildList(items);
if (list) blocks.push(list);
continue;
}
if (line.trim() === '') {
flushParagraph();
i += 1;
continue;
}
paragraph.push(line.trim());
i += 1;
}
flushParagraph();
return { type: 'doc', content: blocks };
}

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import type { NostrEvent } from '@nostrify/nostrify';
import { buildPublishTemplate, deriveSummary, documentIdentifier, publicationFromEvent } from './publish';
import type { DocumentMeta } from './types';
function meta(overrides: Partial<DocumentMeta> = {}): DocumentMeta {
return {
id: 'abcd1234-0000-0000-0000-000000000000',
title: 'My Notes',
createdAt: 0,
updatedAt: 0,
savedAt: 0,
role: 'owner',
archived: false,
attachments: [],
...overrides,
};
}
describe('documentIdentifier', () => {
it('slugifies the title and suffixes the document id', () => {
expect(documentIdentifier('My Notes!', 'abcd1234-rest')).toBe('my-notes-abcd1234');
});
it('falls back to a generic slug for untitled documents', () => {
expect(documentIdentifier('!!!', 'abcd1234-rest')).toBe('document-abcd1234');
});
});
describe('buildPublishTemplate', () => {
it('builds a NIP-23 kind 30023 template', () => {
const template = buildPublishTemplate(meta(), '# Hello\n\nBody', 'A summary', []);
expect(template.kind).toBe(30023);
expect(template.content).toBe('# Hello\n\nBody');
expect(template.tags).toContainEqual(['d', 'my-notes-abcd1234']);
expect(template.tags).toContainEqual(['title', 'My Notes']);
expect(template.tags).toContainEqual(['summary', 'A summary']);
expect(template.tags.some(([name]) => name === 'published_at')).toBe(true);
});
it('reuses the existing identifier on republish', () => {
const existing = meta({
publication: {
eventId: 'e',
identifier: 'kept-slug',
address: '30023:pk:kept-slug',
publishedAt: 1,
title: 'Old',
},
});
const template = buildPublishTemplate(existing, 'body', '', []);
expect(template.tags).toContainEqual(['d', 'kept-slug']);
});
it('attaches NIP-94 imeta metadata', () => {
const attachment = { url: 'https://blossom.example/f.png', sha256: 'a'.repeat(64) };
const template = buildPublishTemplate(meta(), 'body', '', [attachment]);
expect(template.tags.some(([name]) => name === 'imeta')).toBe(true);
expect(template.tags).toContainEqual(['x', 'a'.repeat(64)]);
});
});
describe('publicationFromEvent', () => {
it('extracts the address and timestamp', () => {
const event: NostrEvent = {
id: 'event-id',
pubkey: 'pk',
kind: 30023,
created_at: 100,
content: '',
sig: '',
tags: [
['d', 'slug'],
['published_at', '99'],
],
};
expect(publicationFromEvent(event, 'My Notes')).toEqual({
eventId: 'event-id',
identifier: 'slug',
address: '30023:pk:slug',
publishedAt: 99,
title: 'My Notes',
});
});
});
describe('deriveSummary', () => {
it('strips Markdown syntax into plain text', () => {
expect(deriveSummary('# Title\n\nSome **bold** [link](https://example.com) text')).toBe(
'Title Some bold link text',
);
});
it('truncates long bodies', () => {
const long = 'word '.repeat(100);
const summary = deriveSummary(long, 40);
expect(summary.length).toBeLessThanOrEqual(40);
expect(summary.endsWith('…')).toBe(true);
});
});

View File

@@ -0,0 +1,78 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { tagValue } from '@/lib/nostrUtils';
import type { DocumentAttachment, DocumentMeta, PublicationRecord } from './types';
import { attachmentToImetaTags } from './attachments';
export const ARTICLE_KIND = 30023;
const IDENTIFIER_PATTERN = /^[a-z0-9-]+$/;
/**
* The NIP-23 `d` identifier is part of the article's permanent address, so it
* must be a plain slug: lowercase, URL-safe, and — per NIP-01 — free of the
* characters that would break address parsing.
*/
export function documentIdentifier(title: string, id: string): string {
const slug = title
.toLowerCase()
.normalize('NFKD')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48);
const base = IDENTIFIER_PATTERN.test(slug) && slug.length > 0 ? slug : 'document';
return `${base}-${id.slice(0, 8)}`;
}
export interface PublishTemplate {
kind: number;
content: string;
tags: string[][];
}
/**
* Build the kind 30023 snapshot event template. The snapshot is a portable,
* owner-approved release: it carries the Markdown body, title, summary and
* NIP-94 attachment metadata, and never replaces the live document.
*/
export function buildPublishTemplate(
meta: DocumentMeta,
markdown: string,
summary: string,
attachments: DocumentAttachment[],
): PublishTemplate {
const identifier = meta.publication?.identifier ?? documentIdentifier(meta.title, meta.id);
const tags: string[][] = [
['d', identifier],
['title', meta.title],
['published_at', String(Math.floor(Date.now() / 1000))],
];
if (summary) tags.push(['summary', summary]);
for (const attachment of attachments) {
tags.push(...attachmentToImetaTags(attachment));
}
return { kind: ARTICLE_KIND, content: markdown, tags };
}
/** Extract the publication record from the signed, relay-acknowledged event. */
export function publicationFromEvent(event: NostrEvent, title: string): PublicationRecord {
const identifier = tagValue(event, 'd') ?? '';
return {
eventId: event.id,
identifier,
address: `${event.kind}:${event.pubkey}:${identifier}`,
publishedAt: Number(tagValue(event, 'published_at')) || event.created_at,
title,
};
}
/** A short plain-text summary derived from the Markdown body. */
export function deriveSummary(markdown: string, maxLength = 160): string {
const plain = markdown
.replace(/^[#>\s`-]+/gm, '')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[*_~`]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (plain.length <= maxLength) return plain;
return `${plain.slice(0, maxLength - 1).trimEnd()}`;
}

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { sanitizeHtml } from './sanitizeHtml';
describe('sanitizeHtml', () => {
it('keeps allowlisted formatting elements', () => {
const input = '<h1>Title</h1><p>Some <strong>bold</strong> and <em>italic</em> and <s>struck</s> and <u>under</u>.</p>';
expect(sanitizeHtml(input)).toBe(input);
});
it('removes scripts and event handlers', () => {
const input = '<p onclick="alert(1)">hi</p><script>alert(2)</script>';
const result = sanitizeHtml(input);
expect(result).not.toContain('onclick');
expect(result).not.toContain('<script');
expect(result).toContain('<p>hi</p>');
});
it('drops javascript: hrefs but keeps safe protocols', () => {
const input =
'<a href="javascript:alert(1)">evil</a><a href="https://example.com">safe</a><a href="nostr:npub1abc">mention</a>';
const result = sanitizeHtml(input);
expect(result).not.toContain('javascript:');
expect(result).toContain('<a>evil</a>');
expect(result).toContain('href="https://example.com/"');
expect(result).toContain('rel="noopener noreferrer nofollow"');
expect(result).toContain('nostr:npub1abc');
});
it('unwraps disallowed elements but keeps their text', () => {
const input = '<div><span>kept</span></div><iframe src="https://evil.example"></iframe>';
const result = sanitizeHtml(input);
expect(result).toContain('kept');
expect(result).not.toContain('<div');
expect(result).not.toContain('<iframe');
expect(result).not.toContain('evil.example');
});
it('strips style attributes and srcdoc', () => {
const input = '<p style="background:url(javascript:alert(1))">styled</p>';
expect(sanitizeHtml(input)).toBe('<p>styled</p>');
});
it('keeps checklist classes and data-checked', () => {
const input =
'<ul class="task-list"><li class="task-item" data-checked="true">done</li></ul>';
expect(sanitizeHtml(input)).toBe(input);
});
it('keeps tables', () => {
const input = '<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>';
expect(sanitizeHtml(input)).toBe(input);
});
it('returns an empty string for empty input', () => {
expect(sanitizeHtml('')).toBe('');
});
});

View File

@@ -0,0 +1,121 @@
/**
* Allowlist-based sanitizer for HTML that is about to enter the editor
* (pasted or imported content). It never touches `innerHTML` on a live node
* and never returns markup for `dangerouslySetInnerHTML` — the returned
* string feeds Tiptap's paste parser only.
*
* The allowlist mirrors the constrained editor schema: headings, paragraphs,
* lists, checklists, quotes, code, tables, links and inline formatting.
* Everything else — scripts, styles, iframes, forms, event handlers,
* `javascript:` URLs — is dropped, not escaped.
*/
const ALLOWED_ELEMENTS = new Set([
'a',
'blockquote',
'br',
'code',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'li',
'ol',
'p',
'pre',
's',
'strong',
'table',
'tbody',
'td',
'th',
'thead',
'tr',
'u',
'ul',
]);
/** class="task-list" / "task-item" and data-checked carry checklist state. */
const ALLOWED_CLASSES = new Set(['task-list', 'task-item']);
const SAFE_PROTOCOLS = new Set(['https:', 'http:', 'mailto:', 'nostr:']);
function sanitizeHref(value: string | null): string | null {
if (!value) return null;
try {
const parsed = new URL(value, window.location.origin);
return SAFE_PROTOCOLS.has(parsed.protocol) ? parsed.href : null;
} catch {
return null;
}
}
function sanitizeAttributes(element: Element): void {
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase();
// Event handlers, styles and unknown data attributes never survive.
if (name.startsWith('on') || name === 'style' || name === 'srcdoc') {
element.removeAttribute(attribute.name);
continue;
}
if (element.tagName === 'A' && name === 'href') {
const safe = sanitizeHref(attribute.value);
if (safe) {
element.setAttribute('href', safe);
element.setAttribute('rel', 'noopener noreferrer nofollow');
} else {
element.removeAttribute(attribute.name);
}
continue;
}
if (name === 'class') {
const kept = attribute.value
.split(/\s+/)
.filter((cls) => ALLOWED_CLASSES.has(cls))
.join(' ');
if (kept) element.setAttribute('class', kept);
else element.removeAttribute(attribute.name);
continue;
}
if (name === 'data-checked' && (attribute.value === 'true' || attribute.value === 'false')) {
continue;
}
element.removeAttribute(attribute.name);
}
}
function sanitizeElement(element: Element): void {
// Children first: a disallowed child is unwrapped before we look up again.
for (const child of [...element.children]) {
sanitizeElement(child);
}
const tag = element.tagName.toLowerCase();
if (!ALLOWED_ELEMENTS.has(tag)) {
// Unwrap: keep the (already sanitized) children, drop the wrapper. This
// keeps the text of a <div> or <span> instead of deleting it.
element.replaceWith(...element.childNodes);
return;
}
sanitizeAttributes(element);
}
/** Sanitize an HTML string into editor-safe markup. */
export function sanitizeHtml(html: string): string {
if (typeof html !== 'string' || html.length === 0) return '';
const doc = new DOMParser().parseFromString(html, 'text/html');
for (const child of [...doc.body.children]) {
sanitizeElement(child);
}
return doc.body.innerHTML;
}

View File

@@ -0,0 +1,95 @@
import { useLocalStorage } from '@/hooks/useLocalStorage';
import type { DocumentMeta } from './types';
const INDEX_KEY = 'layer:documents:index';
const CURRENT_VERSION = 1;
interface DocumentIndex {
version: number;
documents: DocumentMeta[];
}
function emptyIndex(): DocumentIndex {
return { version: CURRENT_VERSION, documents: [] };
}
function isValidMeta(value: unknown): value is DocumentMeta {
if (typeof value !== 'object' || value === null) return false;
const meta = value as Record<string, unknown>;
return (
typeof meta.id === 'string' &&
meta.id.length > 0 &&
typeof meta.title === 'string' &&
typeof meta.createdAt === 'number' &&
typeof meta.updatedAt === 'number'
);
}
function parseIndex(raw: string): DocumentIndex {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null) return emptyIndex();
const documents = (parsed as Record<string, unknown>).documents;
if (!Array.isArray(documents)) return emptyIndex();
return { version: CURRENT_VERSION, documents: documents.filter(isValidMeta) };
}
function mintId(): string {
try {
return crypto.randomUUID();
} catch {
return `doc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
}
/**
* The document library index: one localStorage entry holding the metadata of
* every document. Bodies live in IndexedDB (see `ydoc.ts`), which keeps this
* index tiny and keeps per-keystroke writes out of synchronous storage.
*
* `useLocalStorage` broadcasts writes on `window`, so a rename in one window
* shows up in an open library without a reload.
*/
export function useDocumentIndex() {
const [index, setIndex] = useLocalStorage<DocumentIndex>(INDEX_KEY, emptyIndex(), {
serialize: JSON.stringify,
deserialize: parseIndex,
});
const documents = [...index.documents]
.filter((doc) => !doc.archived)
.sort((a, b) => b.updatedAt - a.updatedAt);
const createDocument = (title: string): DocumentMeta => {
const now = Date.now();
const meta: DocumentMeta = {
id: mintId(),
title,
createdAt: now,
updatedAt: now,
savedAt: now,
role: 'owner',
archived: false,
attachments: [],
};
setIndex((prev) => ({ ...prev, documents: [...prev.documents, meta] }));
return meta;
};
const updateDocument = (id: string, patch: Partial<Omit<DocumentMeta, 'id'>>) => {
setIndex((prev) => ({
...prev,
documents: prev.documents.map((doc) =>
doc.id === id ? { ...doc, ...patch, id: doc.id } : doc,
),
}));
};
const removeDocument = (id: string) => {
setIndex((prev) => ({
...prev,
documents: prev.documents.filter((doc) => doc.id !== id),
}));
};
return { documents, createDocument, updateDocument, removeDocument };
}

View File

@@ -0,0 +1,63 @@
/**
* Shared types for the Documents app.
*
* Phase 1 stores documents locally (metadata in localStorage, the rich-text
* body as a Yjs document persisted to IndexedDB) and releases portable
* Markdown snapshots as NIP-23 kind 30023 events. Phase 2 moves the live
* document behind a Hocuspocus-style collaboration service that owns the
* ACL — the roles below already model that boundary.
*/
/** Document access roles, ordered from most to least privileged. */
export type DocumentRole = 'owner' | 'editor' | 'commenter' | 'viewer';
/** What each role may do. Server-side enforcement arrives with Phase 2. */
export const ROLE_CAPABILITIES: Record<
DocumentRole,
{ edit: boolean; comment: boolean; manageAccess: boolean; publish: boolean }
> = {
owner: { edit: true, comment: true, manageAccess: true, publish: true },
editor: { edit: true, comment: true, manageAccess: false, publish: false },
commenter: { edit: false, comment: true, manageAccess: false, publish: false },
viewer: { edit: false, comment: false, manageAccess: false, publish: false },
};
export interface PublicationRecord {
/** Kind 30023 event id of the published snapshot. */
eventId: string;
/** NIP-23 `d` identifier of the snapshot. */
identifier: string;
/** Addressable coordinate (`30023:<pubkey>:<identifier>`). */
address: string;
publishedAt: number;
/** Title the snapshot was published under. */
title: string;
}
/** Validated NIP-94-style metadata for a Blossom attachment. */
export interface DocumentAttachment {
url: string;
mimeType?: string;
/** Lowercase hex SHA-256 of the blob, when the uploader reported it. */
sha256?: string;
size?: number;
}
export interface DocumentMeta {
/** Stable id, also the IndexedDB/Yjs document name. */
id: string;
title: string;
createdAt: number;
updatedAt: number;
/** Milliseconds timestamp of the last successful autosave. */
savedAt: number;
role: DocumentRole;
/** True once the owner has archived the document (kept, but read-only). */
archived: boolean;
/** Most recent published snapshot, if any. Never the live source of truth. */
publication?: PublicationRecord;
attachments: DocumentAttachment[];
}
/** Autosave surface state, shown in the editor toolbar. */
export type SaveState = 'loading' | 'saving' | 'saved' | 'offline' | 'error';

View File

@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest';
import * as Y from 'yjs';
import { openDocumentSession } from './ydoc';
// jsdom has no IndexedDB; the session must degrade to in-memory gracefully.
describe('openDocumentSession', () => {
it('opens a document and fires onSynced', async () => {
const onSynced = vi.fn();
const session = openDocumentSession('test-doc', { onUpdate: () => {}, onSynced });
await session.whenSynced;
expect(onSynced).toHaveBeenCalledOnce();
expect(session.doc).toBeInstanceOf(Y.Doc);
session.destroy();
});
it('forwards updates', async () => {
const onUpdate = vi.fn();
const session = openDocumentSession('test-doc', { onUpdate, onSynced: () => {} });
session.doc.getXmlFragment('default');
session.doc.transact(() => {
session.doc.getMap('meta').set('key', 'value');
});
expect(onUpdate).toHaveBeenCalled();
session.destroy();
});
it('destroy is idempotent and stops callbacks', async () => {
const onSynced = vi.fn();
const session = openDocumentSession('test-doc', { onUpdate: () => {}, onSynced });
session.destroy();
session.destroy();
await session.whenSynced;
// A destroyed session must not fire onSynced afterwards.
expect(onSynced).not.toHaveBeenCalled();
});
});

103
src/lib/documents/ydoc.ts Normal file
View File

@@ -0,0 +1,103 @@
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
const DB_PREFIX = 'layer-doc-';
/** Callbacks a session reports through. */
export interface DocumentSessionEvents {
/** Fired on every Yjs update (local typing and, later, remote peers). */
onUpdate: () => void;
/** Fired once the local copy has been loaded into the document. */
onSynced: () => void;
}
export interface DocumentSession {
doc: Y.Doc;
/** Resolved when the local snapshot has been applied. */
whenSynced: Promise<void>;
/** Tears down persistence and frees the document. Safe to call twice. */
destroy: () => void;
}
/** IndexedDB is unavailable in some environments (tests, locked-down modes). */
function indexedDbAvailable(): boolean {
try {
return typeof indexedDB !== 'undefined';
} catch {
return false;
}
}
/**
* Opens the Yjs document for `documentId` with offline persistence behind it.
*
* The session is created and torn down in a `useEffect`, so React Strict
* Mode's mount → unmount → mount cycle simply opens, closes and reopens it:
* each mount gets its own `Y.Doc`, no provider or subscription outlives its
* effect, and the IndexedDB write behind an update is idempotent, so the
* double-mount cannot corrupt or duplicate state.
*
* Without IndexedDB the session degrades to an in-memory document: editing
* still works, it just does not survive a reload until the Phase 2 service
* provides persistence.
*
* Phase 2 attaches a Hocuspocus WebSocket provider here; `ydoc.on('update')`
* is the single fan-in point, so nothing else changes.
*/
export function openDocumentSession(
documentId: string,
events: DocumentSessionEvents,
): DocumentSession {
const doc = new Y.Doc();
const persistence = indexedDbAvailable()
? new IndexeddbPersistence(`${DB_PREFIX}${documentId}`, doc)
: null;
const onUpdate = () => events.onUpdate();
doc.on('update', onUpdate);
let destroyed = false;
// `whenSynced` resolves with the persistence instance; callers only care
// that the snapshot landed. Without persistence the doc starts empty and is
// "synced" immediately — asynchronously, matching the IndexedDB contract.
let whenSynced: Promise<void>;
let cleanup: () => void;
if (persistence) {
const onSynced = () => events.onSynced();
persistence.on('synced', onSynced);
whenSynced = persistence.whenSynced.then(() => undefined);
cleanup = () => persistence.off('synced', onSynced);
} else {
whenSynced = Promise.resolve().then(() => {
if (!destroyed) events.onSynced();
});
cleanup = () => {};
}
return {
doc,
whenSynced,
destroy: () => {
if (destroyed) return;
destroyed = true;
doc.off('update', onUpdate);
cleanup();
persistence?.destroy();
doc.destroy();
},
};
}
/** Removes the persisted body of a document that was deleted from the index. */
export async function deletePersistedDocument(documentId: string): Promise<void> {
if (!indexedDbAvailable()) return;
const doc = new Y.Doc();
const persistence = new IndexeddbPersistence(`${DB_PREFIX}${documentId}`, doc);
try {
await persistence.clearData();
} finally {
persistence.destroy();
doc.destroy();
}
}

View File

@@ -1,5 +1,5 @@
import { lazy } from 'react';
import { Activity, Bookmark, BookOpen, CalendarDays, FileText, Image, Info, Link2, Radio, Rss, Search, Settings, Sparkles, User } from 'lucide-react';
import { Activity, Bookmark, BookOpen, CalendarDays, FileText, FilePenLine, Image, Info, Link2, Radio, Rss, Search, Settings, Sparkles, User } from 'lucide-react';
import type { AppDefinition } from './types';
/**
@@ -109,6 +109,18 @@ export const APPS: AppDefinition[] = [
defaultSize: { width: 900, height: 700 },
minSize: { width: 420, height: 420 },
},
{
id: 'documents',
title: 'Documents',
description: 'Write rich-text documents and publish them as Nostr articles',
icon: FilePenLine,
category: 'tools',
component: lazy(() => import('@/apps/documents')),
defaultSize: { width: 820, height: 720 },
minSize: { width: 360, height: 360 },
// Auth is enforced inside the app: writing documents needs a signer, so
// the app renders a login prompt when signed out (same as Bookmarks).
},
{
id: 'spells',
title: 'Spells',