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 (
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 (
)
}
/**
* 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) => (