@proteos/ui 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist/index.cjs +1302 -516
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +259 -1
  4. package/dist/index.d.ts +259 -1
  5. package/dist/index.js +1292 -520
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/components/draft-message-composer/create-draft-message-client.ts +27 -0
  9. package/src/components/draft-message-composer/draft-message-actions-store.test.ts +62 -0
  10. package/src/components/draft-message-composer/draft-message-actions-store.ts +81 -0
  11. package/src/components/draft-message-composer/draft-message-composer.stories.tsx +175 -0
  12. package/src/components/draft-message-composer/draft-message-composer.test.tsx +276 -0
  13. package/src/components/draft-message-composer/draft-message-composer.tsx +470 -0
  14. package/src/components/draft-message-composer/draft-message-outcome-store.test.ts +59 -0
  15. package/src/components/draft-message-composer/draft-message-outcome-store.ts +56 -0
  16. package/src/components/draft-message-composer/index.ts +19 -0
  17. package/src/components/draft-message-composer/types.ts +32 -0
  18. package/src/components/message-composer/index.ts +13 -0
  19. package/src/components/message-composer/message-composer.stories.tsx +155 -0
  20. package/src/components/message-composer/message-composer.test.tsx +320 -0
  21. package/src/components/message-composer/message-composer.tsx +480 -0
  22. package/src/components/recipient-select/index.ts +2 -0
  23. package/src/components/recipient-select/recipient-select.test.tsx +139 -0
  24. package/src/components/recipient-select/recipient-select.tsx +94 -0
  25. package/src/index.ts +3 -0
@@ -0,0 +1,155 @@
1
+ import type { ContactAddress } from '@proteos/sdk'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { useState } from 'react'
4
+
5
+ import { type ComposerValue, MessageComposer, type SearchRecipients } from './message-composer'
6
+
7
+ const meta: Meta<typeof MessageComposer> = {
8
+ title: 'Components/MessageComposer',
9
+ component: MessageComposer,
10
+ tags: ['autodocs'],
11
+ decorators: [
12
+ (Story) => (
13
+ <div className="w-[640px]">
14
+ <Story />
15
+ </div>
16
+ ),
17
+ ],
18
+ }
19
+
20
+ export default meta
21
+ type Story = StoryObj<typeof MessageComposer>
22
+
23
+ /* Self-contained mock directory — the lib never fetches; hosts inject the
24
+ search. Filters on name/value like a server-side `q` would. */
25
+ const directory = [
26
+ {
27
+ id: 'addr-1',
28
+ value: 'alice@example.com',
29
+ raw_value: 'alice@example.com',
30
+ name: 'Alice Johnson',
31
+ },
32
+ { id: 'addr-2', value: 'bob@example.com', raw_value: 'bob@example.com', name: 'Bob Meyer' },
33
+ {
34
+ id: 'addr-3',
35
+ value: 'carol@example.com',
36
+ raw_value: 'carol@example.com',
37
+ name: 'Carol Fischer',
38
+ },
39
+ { id: 'addr-4', value: 'dan@example.com', raw_value: 'dan@example.com', name: 'Dan Okafor' },
40
+ ] as ContactAddress[]
41
+
42
+ const searchRecipients: SearchRecipients = async (_connectionId, q) => {
43
+ const needle = q.trim().toLowerCase()
44
+ return directory.filter(
45
+ (address) =>
46
+ needle === '' ||
47
+ address.name.toLowerCase().includes(needle) ||
48
+ address.value.includes(needle),
49
+ )
50
+ }
51
+
52
+ const sendAfterAMoment = async () => {
53
+ await new Promise((resolve) => setTimeout(resolve, 400))
54
+ }
55
+
56
+ export const Chat: Story = {
57
+ render: () => {
58
+ const [value, setValue] = useState<ComposerValue>({ text: '' })
59
+ return (
60
+ <MessageComposer
61
+ mode="chat"
62
+ value={value}
63
+ onValueChange={setValue}
64
+ placeholder="Message #ops"
65
+ onSend={sendAfterAMoment}
66
+ onAddFiles={() => {}}
67
+ attachments={[
68
+ {
69
+ localId: 'a-1',
70
+ name: 'q3-report.pdf',
71
+ sizeInBytes: 48_213,
72
+ contentType: 'application/pdf',
73
+ status: 'ready',
74
+ },
75
+ { localId: 'a-2', name: 'sync-notes.txt', status: 'uploading' },
76
+ ]}
77
+ />
78
+ )
79
+ },
80
+ }
81
+
82
+ export const Email: Story = {
83
+ render: () => {
84
+ const [value, setValue] = useState<ComposerValue>({ text: '' })
85
+ return (
86
+ <MessageComposer
87
+ mode="email"
88
+ value={value}
89
+ onValueChange={setValue}
90
+ placeholder="Write your message…"
91
+ connectionId="conn-1"
92
+ searchRecipients={searchRecipients}
93
+ showToField
94
+ showRecipientFields
95
+ onSend={sendAfterAMoment}
96
+ />
97
+ )
98
+ },
99
+ }
100
+
101
+ export const Disabled: Story = {
102
+ render: () => {
103
+ const [value, setValue] = useState<ComposerValue>({ text: '' })
104
+ return (
105
+ <MessageComposer
106
+ mode="chat"
107
+ value={value}
108
+ onValueChange={setValue}
109
+ placeholder="Reply…"
110
+ disabledReason="This conversation ended — replies are closed."
111
+ onSend={sendAfterAMoment}
112
+ />
113
+ )
114
+ },
115
+ }
116
+
117
+ /** Submission delegated to an external driver (draft review): no send
118
+ * button, no keybinding hint, Enter never submits. */
119
+ export const HiddenSendButton: Story = {
120
+ render: () => {
121
+ const [value, setValue] = useState<ComposerValue>({ text: 'Edited draft body…' })
122
+ return (
123
+ <MessageComposer
124
+ mode="chat"
125
+ value={value}
126
+ onValueChange={setValue}
127
+ placeholder="Edit the draft…"
128
+ showSendButton={false}
129
+ onSend={sendAfterAMoment}
130
+ />
131
+ )
132
+ },
133
+ }
134
+
135
+ /** Reply drafts derive the subject from the thread — rendered as static
136
+ * text; only the body is editable. */
137
+ export const ReadOnlySubject: Story = {
138
+ render: () => {
139
+ const [value, setValue] = useState<ComposerValue>({ text: '' })
140
+ return (
141
+ <MessageComposer
142
+ mode="email"
143
+ value={value}
144
+ onValueChange={setValue}
145
+ placeholder="Reply…"
146
+ subjectPrefill="re: Quarterly numbers"
147
+ isSubjectReadOnly
148
+ connectionId="conn-1"
149
+ searchRecipients={searchRecipients}
150
+ showRecipientFields
151
+ onSend={sendAfterAMoment}
152
+ />
153
+ )
154
+ },
155
+ }
@@ -0,0 +1,320 @@
1
+ import type { ContactAddress } from '@proteos/sdk'
2
+ import { render, screen } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { useState } from 'react'
5
+ import { describe, expect, it, vi } from 'vitest'
6
+
7
+ import {
8
+ type ComposerAttachment,
9
+ type ComposerValue,
10
+ MessageComposer,
11
+ type MessageComposerProps,
12
+ } from './message-composer'
13
+
14
+ function composerAttachment(overrides: Partial<ComposerAttachment> = {}): ComposerAttachment {
15
+ return {
16
+ localId: 'local-1',
17
+ name: 'report.pdf',
18
+ sizeInBytes: 9,
19
+ contentType: 'application/pdf',
20
+ status: 'ready',
21
+ ...overrides,
22
+ }
23
+ }
24
+
25
+ function contactAddress(overrides: Partial<ContactAddress> = {}): ContactAddress {
26
+ return {
27
+ id: 'addr-1',
28
+ value: 'alice@example.com',
29
+ raw_value: 'alice@example.com',
30
+ name: 'Alice',
31
+ ...overrides,
32
+ } as ContactAddress
33
+ }
34
+
35
+ /** The composer is controlled — a tiny stateful host stands in for the app's
36
+ * draft persistence so typing works like production. */
37
+ type HarnessProps = Omit<MessageComposerProps, 'value' | 'onValueChange'> & {
38
+ initialValue?: ComposerValue
39
+ onValueChange?: (next: ComposerValue) => void
40
+ }
41
+
42
+ function Harness({ initialValue, onValueChange, ...props }: HarnessProps) {
43
+ const [value, setValue] = useState<ComposerValue>(initialValue ?? { text: '' })
44
+ return (
45
+ <MessageComposer
46
+ {...props}
47
+ value={value}
48
+ onValueChange={(next) => {
49
+ setValue(next)
50
+ onValueChange?.(next)
51
+ }}
52
+ />
53
+ )
54
+ }
55
+
56
+ describe('MessageComposer', () => {
57
+ it('chat mode: Enter sends, Shift+Enter makes a newline', async () => {
58
+ const onSend = vi.fn().mockResolvedValue({})
59
+ render(<Harness mode="chat" placeholder="Message #ops" onSend={onSend} />)
60
+ const input = screen.getByPlaceholderText('Message #ops')
61
+
62
+ await userEvent.type(input, 'line one{Shift>}{Enter}{/Shift}line two')
63
+ expect(onSend).not.toHaveBeenCalled()
64
+
65
+ await userEvent.type(input, '{Enter}')
66
+ expect(onSend).toHaveBeenCalledWith({ text: 'line one\nline two' })
67
+ })
68
+
69
+ it('email mode: Enter does NOT send, Cmd+Enter does, subject required', async () => {
70
+ const onSend = vi.fn().mockResolvedValue({})
71
+ render(<Harness mode="email" placeholder="Reply…" onSend={onSend} />)
72
+ const input = screen.getByPlaceholderText('Reply…')
73
+
74
+ await userEvent.type(input, 'hello{Meta>}{Enter}{/Meta}')
75
+ expect(onSend).not.toHaveBeenCalled() // subject still empty
76
+
77
+ await userEvent.type(screen.getByPlaceholderText('Subject'), 'Quarterly numbers')
78
+ await userEvent.type(input, '{Enter}')
79
+ expect(onSend).not.toHaveBeenCalled() // plain Enter is a newline in email mode
80
+
81
+ await userEvent.type(input, '{Meta>}{Enter}{/Meta}')
82
+ expect(onSend).toHaveBeenCalledWith({ text: 'hello', subject: 'Quarterly numbers' })
83
+ })
84
+
85
+ it('a failed send keeps the text and offers retry', async () => {
86
+ const onSend = vi.fn().mockRejectedValue(new Error('slack_channel_archived'))
87
+ render(<Harness mode="chat" placeholder="Message #ops" onSend={onSend} />)
88
+ const input = screen.getByPlaceholderText('Message #ops')
89
+
90
+ await userEvent.type(input, 'important text{Enter}')
91
+ expect(await screen.findByText(/slack_channel_archived/)).toBeInTheDocument()
92
+ expect(input).toHaveValue('important text')
93
+ expect(screen.getByRole('button', { name: 'retry' })).toBeInTheDocument()
94
+ })
95
+
96
+ it('a successful send clears the input via onValueChange({ text: "" })', async () => {
97
+ const onSend = vi.fn().mockResolvedValue({})
98
+ const onValueChange = vi.fn()
99
+ render(
100
+ <Harness
101
+ mode="chat"
102
+ placeholder="Message #ops"
103
+ onSend={onSend}
104
+ onValueChange={onValueChange}
105
+ />,
106
+ )
107
+ const input = screen.getByPlaceholderText('Message #ops')
108
+ await userEvent.type(input, 'ship it{Enter}')
109
+ expect(input).toHaveValue('')
110
+
111
+ const lastValue = onValueChange.mock.calls.at(-1)?.[0] as ComposerValue
112
+ expect(lastValue).toEqual({ text: '' })
113
+ // The subject KEY is omitted on the cleared value, not set to undefined.
114
+ expect('subject' in lastValue).toBe(false)
115
+ })
116
+
117
+ it('disabledReason replaces the composer with honest copy', () => {
118
+ render(
119
+ <Harness
120
+ mode="chat"
121
+ placeholder="Reply…"
122
+ disabledReason="This conversation ended — replies are closed."
123
+ onSend={vi.fn()}
124
+ />,
125
+ )
126
+ expect(screen.queryByPlaceholderText('Reply…')).not.toBeInTheDocument()
127
+ expect(screen.getByText(/replies are closed/)).toBeInTheDocument()
128
+ })
129
+
130
+ it('renders attachment chips with status and remove', async () => {
131
+ const onRemoveAttachment = vi.fn()
132
+ render(
133
+ <Harness
134
+ mode="chat"
135
+ placeholder="Message #ops"
136
+ onSend={vi.fn()}
137
+ attachments={[
138
+ composerAttachment(),
139
+ {
140
+ localId: 'local-2',
141
+ name: 'huge.bin',
142
+ sizeInBytes: 1,
143
+ status: 'error',
144
+ errorMessage: 'Too large (max 25 MB)',
145
+ },
146
+ ]}
147
+ onAddFiles={vi.fn()}
148
+ onRemoveAttachment={onRemoveAttachment}
149
+ />,
150
+ )
151
+ expect(screen.getByText('report.pdf')).toBeInTheDocument()
152
+ expect(screen.getByText('Too large (max 25 MB)')).toBeInTheDocument()
153
+
154
+ await userEvent.click(screen.getByRole('button', { name: 'Remove report.pdf' }))
155
+ expect(onRemoveAttachment).toHaveBeenCalledWith('local-1')
156
+ })
157
+
158
+ it('the clip button renders only when onAddFiles is provided', () => {
159
+ const { rerender } = render(<Harness mode="chat" placeholder="Message #ops" onSend={vi.fn()} />)
160
+ expect(screen.queryByRole('button', { name: 'Attach files' })).not.toBeInTheDocument()
161
+
162
+ rerender(
163
+ <Harness mode="chat" placeholder="Message #ops" onSend={vi.fn()} onAddFiles={vi.fn()} />,
164
+ )
165
+ expect(screen.getByRole('button', { name: 'Attach files' })).toBeInTheDocument()
166
+ })
167
+
168
+ it('an attachments-only send fires with empty text', async () => {
169
+ const onSend = vi.fn().mockResolvedValue({})
170
+ render(
171
+ <Harness
172
+ mode="chat"
173
+ placeholder="Message #ops"
174
+ onSend={onSend}
175
+ attachments={[composerAttachment()]}
176
+ onAddFiles={vi.fn()}
177
+ />,
178
+ )
179
+ await userEvent.type(screen.getByPlaceholderText('Message #ops'), '{Enter}')
180
+ expect(onSend).toHaveBeenCalledWith({ text: '' })
181
+ })
182
+
183
+ it('an in-flight upload blocks send', async () => {
184
+ const onSend = vi.fn().mockResolvedValue({})
185
+ render(
186
+ <Harness
187
+ mode="chat"
188
+ placeholder="Message #ops"
189
+ onSend={onSend}
190
+ attachments={[composerAttachment({ status: 'uploading' })]}
191
+ onAddFiles={vi.fn()}
192
+ hasUploadingAttachments
193
+ />,
194
+ )
195
+ const input = screen.getByPlaceholderText('Message #ops')
196
+ await userEvent.type(input, 'wait for it{Enter}')
197
+ expect(onSend).not.toHaveBeenCalled()
198
+ expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled()
199
+ })
200
+
201
+ it('disabledReason suppresses chips and clip', () => {
202
+ render(
203
+ <Harness
204
+ mode="chat"
205
+ placeholder="Reply…"
206
+ disabledReason="closed"
207
+ onSend={vi.fn()}
208
+ attachments={[composerAttachment()]}
209
+ onAddFiles={vi.fn()}
210
+ />,
211
+ )
212
+ expect(screen.queryByText('report.pdf')).not.toBeInTheDocument()
213
+ expect(screen.queryByRole('button', { name: 'Attach files' })).not.toBeInTheDocument()
214
+ })
215
+
216
+ it('showSendButton=false hides the button and hint and disables the Enter combo', async () => {
217
+ const onSend = vi.fn().mockResolvedValue({})
218
+ render(
219
+ <Harness mode="chat" placeholder="Message #ops" onSend={onSend} showSendButton={false} />,
220
+ )
221
+ expect(screen.queryByRole('button', { name: 'Send' })).not.toBeInTheDocument()
222
+ expect(screen.queryByText('↵')).not.toBeInTheDocument()
223
+
224
+ await userEvent.type(screen.getByPlaceholderText('Message #ops'), 'external driver{Enter}')
225
+ expect(onSend).not.toHaveBeenCalled()
226
+ })
227
+
228
+ it('isSubjectReadOnly renders the subject as text, not an input', async () => {
229
+ const onSend = vi.fn().mockResolvedValue({})
230
+ render(
231
+ <Harness
232
+ mode="email"
233
+ placeholder="Reply…"
234
+ subjectPrefill="re: Quarterly numbers"
235
+ isSubjectReadOnly
236
+ onSend={onSend}
237
+ />,
238
+ )
239
+ // No Subject input to type into — the subject is static text.
240
+ expect(screen.queryByPlaceholderText('Subject')).not.toBeInTheDocument()
241
+ expect(screen.getByText('re: Quarterly numbers')).toBeInTheDocument()
242
+
243
+ // The read-only subject still satisfies email's subject-required rule.
244
+ await userEvent.type(screen.getByPlaceholderText('Reply…'), 'hello{Meta>}{Enter}{/Meta}')
245
+ expect(onSend).toHaveBeenCalledWith({ text: 'hello', subject: 're: Quarterly numbers' })
246
+ })
247
+
248
+ it('the To row renders only with searchRecipients + connectionId, and gates send', async () => {
249
+ const onSend = vi.fn().mockResolvedValue({})
250
+ const searchRecipients = vi.fn().mockResolvedValue([contactAddress()])
251
+
252
+ const { rerender } = render(
253
+ <Harness
254
+ mode="chat"
255
+ placeholder="Message…"
256
+ onSend={onSend}
257
+ connectionId="conn-1"
258
+ showToField
259
+ />,
260
+ )
261
+ // No injected search — the composer cannot own addressing.
262
+ expect(screen.queryByText('To')).not.toBeInTheDocument()
263
+
264
+ rerender(
265
+ <Harness
266
+ mode="chat"
267
+ placeholder="Message…"
268
+ onSend={onSend}
269
+ connectionId="conn-1"
270
+ showToField
271
+ searchRecipients={searchRecipients}
272
+ />,
273
+ )
274
+ expect(screen.getByText('To')).toBeInTheDocument()
275
+
276
+ // No recipient picked yet — send is gated.
277
+ const input = screen.getByPlaceholderText('Message…')
278
+ await userEvent.type(input, 'hi there{Enter}')
279
+ expect(onSend).not.toHaveBeenCalled()
280
+
281
+ // Pick Alice from the injected directory, then send.
282
+ await userEvent.click(screen.getByRole('combobox'))
283
+ await userEvent.click(await screen.findByRole('option', { name: 'Alice' }))
284
+ // The pick renders as a removable chip in the trigger.
285
+ expect(screen.getAllByRole('button', { name: 'Remove', hidden: true }).length).toBe(1)
286
+ // Click outside to dismiss the popover (Escape would reach the focused
287
+ // option and RAC-clear the fresh selection), then send via the button.
288
+ await userEvent.click(input)
289
+ await userEvent.click(screen.getByRole('button', { name: 'Send' }))
290
+ expect(onSend).toHaveBeenCalledWith({
291
+ text: 'hi there',
292
+ to: [{ kind: 'contact-address', external_id: 'alice@example.com' }],
293
+ })
294
+ })
295
+
296
+ it('controlled recipients are not cleared by a successful send', async () => {
297
+ const onSend = vi.fn().mockResolvedValue({})
298
+ const onToChange = vi.fn()
299
+ const searchRecipients = vi.fn().mockResolvedValue([contactAddress()])
300
+ render(
301
+ <Harness
302
+ mode="chat"
303
+ placeholder="Message…"
304
+ onSend={onSend}
305
+ connectionId="conn-1"
306
+ showToField
307
+ searchRecipients={searchRecipients}
308
+ to={[{ kind: 'contact-address', external_id: 'alice@example.com' }]}
309
+ onToChange={onToChange}
310
+ />,
311
+ )
312
+ await userEvent.type(screen.getByPlaceholderText('Message…'), 'hi{Enter}')
313
+ expect(onSend).toHaveBeenCalledWith({
314
+ text: 'hi',
315
+ to: [{ kind: 'contact-address', external_id: 'alice@example.com' }],
316
+ })
317
+ // The controlling parent owns the reset — the composer never calls back.
318
+ expect(onToChange).not.toHaveBeenCalled()
319
+ })
320
+ })