import type { Meta, StoryObj } from '@storybook/react-vite' import { useState } from 'react' import type { Editor, JSONContent } from '@tiptap/react' import { IconSquareRounded } from '@tabler/icons-react' import { expect, userEvent, waitFor, within } from 'storybook/test' import type { ReferenceViewProps } from './BlockNodes' import { Button } from './Button' import { CommentBox, commentBoxRows, type BoxUnder, type CommentBoxDraft, type CommentBoxProps, type SendAnswer, type UploadAnswer } from './CommentBox' import type { CaptionedItem, MentionPerson, ReferenceSection, SlashCommand } from './ComposerTriggers' import { InlineChip } from './InlineChip' import { Kbd } from './Kbd' import { LinkCard } from './LinkCard' import { ITEM, ready } from './linked.examples' import { PartCard } from './PartCard' /** * The one box every app writes in. Every story is live: write, open a list, * send. A send is answered after a moment; write "refuse" anywhere to see a * refusal. Pick the theme in the toolbar. */ const meta = { title: 'Primitives/CommentBox', component: CommentBox, parameters: { layout: 'padded' }, decorators: [ (Story) => (
), ], } satisfies Meta export default meta type Story = StoryObj const PEOPLE: MentionPerson[] = ['one', 'two', 'three', 'four'].map((n, i) => ({ id: n, name: `Person ${n}`, pubkey: String(i + 1).repeat(64) })) const ITEMS: CaptionedItem[] = ['one', 'two', 'three'].map((n, i) => ({ id: String(i + 5).repeat(64), label: 'Label', snippet: `Item ${n}`, uri: `nostr:nevent1item${n}`, })) const references = (query: string): ReferenceSection[] => [ { label: 'Group one', items: ITEMS.filter((item) => item.snippet.toLowerCase().includes(query.toLowerCase())) }, ] const INSERT = [commentBoxRows.file, commentBoxRows.mention, commentBoxRows.urgent, commentBoxRows.reference] const plain = (body: JSONContent): string => (body.text ?? '') + (body.content ?? []).map(plain).join(' ') /** A relay that answers after a moment, and refuses whatever says "refuse". */ const answering = (body: JSONContent): Promise => new Promise((resolve) => setTimeout(() => resolve(/refuse/i.test(plain(body)) ? { accepted: false, reason: 'rate-limited, try again in a minute' } : { accepted: true }), 900), ) /** A relay that never answers. */ const silent = (): Promise => new Promise(() => {}) const uploads = (answer: 'ready' | 'failed' | 'never') => (file: File): Promise => answer === 'never' ? new Promise(() => {}) : new Promise((resolve) => setTimeout(() => resolve(answer === 'ready' ? { accepted: true, value: file.name } : { accepted: false, reason: 'The file is larger than 20 MB.' }), 1800), ) const PLACEHOLDER = ( <> Write, or type / for commands ) const DRAFT: JSONContent = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Half written, and still here when you come back' }] }], } const WITH_CHIPS: JSONContent = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Agreed with ' }, { type: 'mention', attrs: { id: 'two', label: 'Person two', pubkey: '2'.repeat(64) } }, { type: 'text', text: ', as in ' }, { type: 'captionedReference', attrs: { id: ITEMS[0].id, label: 'Label', snippet: 'Item one', uri: ITEMS[0].uri } }, ], }, ], } /** The sent lines above the box, so a send can be seen to land. */ function Live({ start, ...props }: Partial & { start?: JSONContent }) { const [sent, setSent] = useState([]) const [kept] = useState(() => { let body = start const draft: CommentBoxDraft = { read: () => body, write: (b) => (body = b), clear: () => (body = undefined) } return draft }) const [open, setOpen] = useState(true) return (
{sent.map((line, i) => (

{line}

))} {open ? ( setSent((all) => [...all, plain(body)])} {...props} /> ) : (

The box is closed. Open it again: the words are kept.

)} {start && (
)}
) } const typeInto = (text: string) => async function play({ canvasElement }: { canvasElement: HTMLElement }) { const box = within(canvasElement).getByRole('textbox', { name: 'Write' }) await userEvent.click(box) await userEvent.keyboard(text) } const pickAFile = async ({ canvasElement }: { canvasElement: HTMLElement }) => { const input = canvasElement.querySelector('input[type=file]') as HTMLInputElement await userEvent.upload(input, new File(['placeholder'], 'plan.pdf', { type: 'application/pdf' })) } /** Nothing written. */ export const Empty: Story = { render: () => } /** Words half written. Close the box and open it again: they are still there. */ export const WithADraft: Story = { render: () => } /** `@`: the people the app supplies. */ export const MentionOpen: Story = { render: () => , play: typeInto('Asking @') } /** `!@`: the same people, urgently. */ export const UrgentOpen: Story = { render: () => , play: typeInto('Asking !@') } /** `[`: what the app's sections list. */ export const ReferenceOpen: Story = { render: () => , play: typeInto('As in [[') } /** `/`: the shared Format section, then the app's Insert rows. */ export const CommandsOpen: Story = { render: () => , play: typeInto('/') } /** Held until the relay answers: the words stay, shut. */ export const Sending: Story = { render: () => , play: typeInto('On its way{Enter}') } /** The relay said no: the words are back in the box, and a line says why. */ export const Refused: Story = { render: () => , play: typeInto('Please refuse this{Enter}') } /** Shown at once: the box empties now, and a refusal puts the words back. Write "refuse" to see it. */ export const ShownAtOnce: Story = { render: () => } /** Editing something sent: the chips stay chips; only the keys on the left. */ export const Editing: Story = { render: () => {}, onSave: answering }} />, } /** A save the relay refused: "Saving…" while it waits, then a line under the box; the changes stay. */ export const EditingRefused: Story = { render: () => ( {}, onSave: () => new Promise((resolve) => setTimeout(() => resolve({ accepted: false, reason: 'rate-limited, try again in a minute' }), 900)), }} /> ), play: typeInto('{Enter}'), } /** A file attached and ready: its card under the text, and Send can send it with no words. */ export const WithAFile: Story = { render: () => , play: pickAFile } /** A file uploading: Send waits for it. */ export const FileUploading: Story = { render: () => , play: pickAFile } /** A file that failed: it says why, Send holds, and its ✕ removes it. */ export const FileFailed: Story = { render: () => , play: pickAFile } /** Read-only, archived: a line in the box's place. */ export const ReadOnlyArchived: Story = { render: () => } /** Read-only, signed out: a line in the box's place. */ export const ReadOnlySignedOut: Story = { render: () => } const ITEM_URL = 'https://app.example/item/one' const PART_URL = 'https://app.example/page/two#block' /** * What the app reads under the box: one item named gets its card; a link to * a block of something is its live part, and then no card. */ const under: BoxUnder = { read: (body) => { const text = plain(body) const item = text.includes(ITEM_URL) const part = text.includes(PART_URL) return { card: item && !part ? { value: 'item-one', view: } : undefined, parts: part ? [ { key: 'item-two', view: (

The block, as it is now. It changes when the text it came from changes.

), }, ] : [], names: [...(item ? ['item-one'] : []), ...(part ? ['item-two'] : [])], } }, } /** One item named: its card under the box, with ✕ to take it off. Take the link out and put it back and the card comes back. */ export const CardUnderTheBox: Story = { render: () => , play: typeInto(`As in ${ITEM_URL} `) } /** A link to a block: its live part under the box, and no card. */ export const PartUnderTheBox: Story = { render: () => , play: typeInto(`As in ${ITEM_URL} and ${PART_URL} `) } /** Editing: a link the edit adds shows the card Save will add. A part never shows here, since an edit carries none. */ export const EditingWithACard: Story = { render: () => ( {}, onSave: answering, }} /> ), // The edit box puts the caret at the end as it opens; a click would move it. play: async ({ canvasElement }) => { const box = within(canvasElement).getByRole('textbox', { name: 'Write' }) await waitFor(() => expect(box.ownerDocument.activeElement).toBe(box)) await userEvent.keyboard(` ${ITEM_URL} `) }, } /** An app's own `/` rows, after the kit's: each puts a label at the start of the box, which the app marks. */ const OWN_ROWS: SlashCommand[] = ['one', 'two', 'three', 'four', 'five'].map((n) => ({ key: `own-${n}`, row: { label: `Item ${n}` }, keywords: ['item'], run: (editor) => editor.chain().focus().insertContentAt(1, `Item ${n}: `).run(), })) /** The app's own chip for a reference the box holds. */ const OwnChip = ({ uri }: ReferenceViewProps) => ( }>{uri.endsWith('one') ? 'Item one' : 'Item'} ) const WITH_A_REFERENCE: JSONContent = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'As in ' }, { type: 'reference', attrs: { uri: 'nostr:item-one' } }] }], } /** What an app adds around the box: its own rows and mark, its chip, and a control outside the box that writes into the box last used. */ function AppAround() { const [last, setLast] = useState(null) return (
editor.getText().startsWith('Item ')} onEditor={(editor) => setLast(editor)} />
) } /** * An app's own `/` rows after the kit's, its own mark (the bar down the text, * as an urgent mention draws it), its chip for a reference, a pasted picture * becoming a file, and a control outside the box writing into it. */ export const WithAnAppsOwnRows: Story = { render: () => , play: typeInto('/item') } /** An urgent mention in the box: the bar down the text while it is there. */ export const UrgentMarked: Story = { render: () => ( ), } /** A draft longer than the box: it stops at half the window and scrolls inside, so its first line can be reached. */ export const LongDraft: Story = { render: () => ( ({ type: 'paragraph', content: [{ type: 'text', text: `Line ${i + 1} of a long draft.` }] })), }} /> ), } /** The narrowest column it sits in, 320px, with a file uploading. */ export const Narrow: Story = { decorators: [ (Story) => (
), ], render: () => , play: pickAFile, }