diff --git a/docs/apps.md b/docs/apps.md index 5bf3981..247757b 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 0cf5070..4754533 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 728fb52..2eb7387 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/apps/documents/DocumentEditor.test.tsx b/src/apps/documents/DocumentEditor.test.tsx new file mode 100644 index 0000000..c761587 --- /dev/null +++ b/src/apps/documents/DocumentEditor.test.tsx @@ -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 { + 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( + + + {}} + onDelete={() => {}} + onUpdateMeta={() => {}} + /> + + , + ); + + await waitFor(() => { + expect(document.querySelector('.doc-editor')).toBeInTheDocument(); + }); + }); + + it('shows the title, role and formatting toolbar', async () => { + render( + + {}} + onDelete={() => {}} + onUpdateMeta={() => {}} + /> + , + ); + + 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( + + {}} + onDelete={() => {}} + onUpdateMeta={() => {}} + /> + , + ); + + // 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(); + }); +}); diff --git a/src/apps/documents/DocumentEditor.tsx b/src/apps/documents/DocumentEditor.tsx new file mode 100644 index 0000000..31ccf04 --- /dev/null +++ b/src/apps/documents/DocumentEditor.tsx @@ -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>) => 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 ( +
+ {/* Document toolbar: title, save state, actions. */} +
+ + + + +
+ {capabilities.publish && ( + + )} + + + + + + + {canEdit && ( + setRenameOpen(true)}> + + Rename + + )} + {meta.publication && } + {meta.role === 'owner' && ( + <> + + setDeleteOpen(true)} + > + + Delete + + + )} + + +
+
+ + + + {/* Editing surface. */} +
+
+ {editor ? ( + + ) : ( +
+
+
+
+
+ )} +
+
+ + {/* Status bar. */} +
+ {words} {words === 1 ? 'word' : 'words'} + {meta.role} + {meta.publication && ( + + Published {new Date(meta.publication.publishedAt * 1000).toLocaleDateString()} + + )} +
+ + {renameOpen && ( + { + onRename(meta.id, title); + setRenameOpen(false); + toast({ title: 'Document renamed' }); + }} + /> + )} + + { + setDeleteOpen(false); + onDelete(meta.id); + }} + /> + + {capabilities.publish && publishOpen && ( + { + onUpdateMeta(meta.id, { publication }); + setPublishOpen(false); + }} + /> + )} +
+ ); +} + +function SaveIndicator({ state }: { state: SaveState }) { + const content: Record = { + loading: { icon: , label: 'Loading' }, + saving: { icon: , label: 'Saving…' }, + saved: { icon: , label: 'Saved' }, + offline: { icon: , label: 'Offline — saved on this device' }, + error: { icon: , label: 'Save failed — will retry' }, + }; + + return ( + + {content[state].icon} + {content[state].label} + {content[state].label} + + ); +} + +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 ( + + + + Rename document + The new name shows up in your document library. + +
{ + event.preventDefault(); + if (trimmed) onRename(trimmed); + }} + > +
+ + setValue(event.target.value)} + maxLength={120} + autoFocus + /> +
+ + + +
+
+
+ ); +} + +function DeleteDialog({ + open, + onOpenChange, + title, + onDelete, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + onDelete: () => void; +}) { + return ( + + + + Delete “{title}”? + + This removes the document from this device. A published snapshot, if you made one, + stays on your relays — delete it separately if needed. + + + + + + + + + ); +} + +function CopyPublicationLink({ meta }: { meta: DocumentMeta }) { + const { toast } = useToast(); + const hints = useRelayHints(); + const { user } = useCurrentUser(); + const publication = meta.publication; + + if (!publication || !user) return null; + + return ( + { + 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 + + ); +} + +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 ( + + + + Publish “{meta.title}” + + 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. + + + +
+
+ +