@startsimpli/ui 0.4.25 → 0.4.26
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.
- package/package.json +1 -1
- package/src/components/__tests__/chat.test.tsx +188 -1
- package/src/components/__tests__/document-status-badge.test.tsx +48 -0
- package/src/components/badge/DocumentStatusBadge.tsx +76 -0
- package/src/components/badge/StatusBadge.tsx +5 -0
- package/src/components/badge/index.ts +3 -0
- package/src/components/chat/ChatComposer.tsx +307 -27
- package/src/components/chat/ChatMessage.tsx +57 -3
- package/src/components/chat/ChatThread.tsx +29 -2
- package/src/components/chat/attachment-utils.ts +24 -0
- package/src/components/chat/image-downscale.test.ts +28 -0
- package/src/components/chat/image-downscale.ts +88 -0
- package/src/components/chat/index.ts +12 -0
- package/src/components/chat/types.ts +31 -0
- package/src/components/chat/useComposerUploads.test.tsx +125 -0
- package/src/components/chat/useComposerUploads.ts +285 -0
|
@@ -4,9 +4,21 @@ export { ChatThread } from './ChatThread'
|
|
|
4
4
|
export type { ChatThreadProps } from './ChatThread'
|
|
5
5
|
export { ChatComposer } from './ChatComposer'
|
|
6
6
|
export type { ChatComposerProps } from './ChatComposer'
|
|
7
|
+
export { useComposerUploads } from './useComposerUploads'
|
|
8
|
+
export type {
|
|
9
|
+
AttachmentUploader,
|
|
10
|
+
UploadedAttachmentRef,
|
|
11
|
+
UseComposerUploadsOptions,
|
|
12
|
+
UseComposerUploadsResult,
|
|
13
|
+
} from './useComposerUploads'
|
|
14
|
+
export { downscaleImage } from './image-downscale'
|
|
15
|
+
export type { DownscaleOptions } from './image-downscale'
|
|
7
16
|
export type {
|
|
8
17
|
ChatMessageData,
|
|
18
|
+
ChatMessageAttachment,
|
|
19
|
+
PendingAttachment,
|
|
9
20
|
ChatRole,
|
|
10
21
|
ChatMessageStatus,
|
|
11
22
|
ChatMessageKind,
|
|
12
23
|
} from './types'
|
|
24
|
+
export { formatBytes, isImageName } from './attachment-utils'
|
|
@@ -4,6 +4,18 @@ export type ChatMessageStatus = 'streaming' | 'complete' | 'error'
|
|
|
4
4
|
|
|
5
5
|
export type ChatMessageKind = 'text' | 'tool' | 'status'
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* A reference attachment already persisted on a message (server shape). Rendered
|
|
9
|
+
* as a chip/thumbnail on the message that carries it.
|
|
10
|
+
*/
|
|
11
|
+
export interface ChatMessageAttachment {
|
|
12
|
+
id: string
|
|
13
|
+
/** Asset url (image thumbnails / download link); may be null while pending. */
|
|
14
|
+
fileUrl: string | null
|
|
15
|
+
filename: string
|
|
16
|
+
contentType: string
|
|
17
|
+
}
|
|
18
|
+
|
|
7
19
|
export interface ChatMessageData {
|
|
8
20
|
id: string
|
|
9
21
|
role: ChatRole
|
|
@@ -17,4 +29,23 @@ export interface ChatMessageData {
|
|
|
17
29
|
kind?: ChatMessageKind
|
|
18
30
|
/** Name of the tool when kind === 'tool'. */
|
|
19
31
|
toolName?: string
|
|
32
|
+
/** Reference attachments carried by this message (rendered as chips). */
|
|
33
|
+
attachments?: ChatMessageAttachment[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A composer-side pending attachment. The APP owns upload state and feeds these
|
|
38
|
+
* down as a controlled prop; the composer renders them as chips with progress.
|
|
39
|
+
*/
|
|
40
|
+
export interface PendingAttachment {
|
|
41
|
+
id: string
|
|
42
|
+
name: string
|
|
43
|
+
status: 'uploading' | 'ready' | 'error'
|
|
44
|
+
/** Upload progress 0..1 while `status === 'uploading'`. */
|
|
45
|
+
progress?: number
|
|
46
|
+
/** Object/asset url for an image preview thumbnail. */
|
|
47
|
+
thumbnailUrl?: string
|
|
48
|
+
sizeBytes?: number
|
|
49
|
+
/** Human-readable failure reason shown on `status === 'error'` chips. */
|
|
50
|
+
error?: string
|
|
20
51
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { renderHook, act, waitFor } from '@testing-library/react'
|
|
2
|
+
import { useComposerUploads, type AttachmentUploader } from './useComposerUploads'
|
|
3
|
+
|
|
4
|
+
function fakeUploader(overrides: Partial<AttachmentUploader> = {}): AttachmentUploader {
|
|
5
|
+
return {
|
|
6
|
+
uploadAttachment: jest.fn(async (_id: string, file: File) => ({
|
|
7
|
+
id: `srv-${file.name}`,
|
|
8
|
+
fileUrl: `https://cdn/${file.name}`,
|
|
9
|
+
})),
|
|
10
|
+
deleteAttachment: jest.fn(async () => undefined),
|
|
11
|
+
...overrides,
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function file(name: string, type = 'image/png') {
|
|
16
|
+
return new File(['x'], name, { type })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
if (!('createObjectURL' in URL)) {
|
|
21
|
+
// @ts-expect-error test shim
|
|
22
|
+
URL.createObjectURL = jest.fn(() => 'blob:preview')
|
|
23
|
+
}
|
|
24
|
+
if (!('revokeObjectURL' in URL)) {
|
|
25
|
+
// @ts-expect-error test shim
|
|
26
|
+
URL.revokeObjectURL = jest.fn()
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('useComposerUploads', () => {
|
|
31
|
+
it('uploads immediately when a real documentId exists and reports ready', async () => {
|
|
32
|
+
const uploader = fakeUploader()
|
|
33
|
+
const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
|
|
34
|
+
|
|
35
|
+
act(() => result.current.attachFiles([file('logo.png')]))
|
|
36
|
+
expect(result.current.attachments).toHaveLength(1)
|
|
37
|
+
|
|
38
|
+
await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
|
|
39
|
+
expect(uploader.uploadAttachment).toHaveBeenCalledTimes(1)
|
|
40
|
+
expect(uploader.uploadAttachment).toHaveBeenCalledWith('doc-1', expect.any(File), expect.any(Object))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('non-image files pass through downscale untouched (still upload)', async () => {
|
|
44
|
+
const uploader = fakeUploader()
|
|
45
|
+
const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
|
|
46
|
+
const pdf = file('notes.pdf', 'application/pdf')
|
|
47
|
+
act(() => result.current.attachFiles([pdf]))
|
|
48
|
+
await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
|
|
49
|
+
// The exact File instance is forwarded (downscale is a no-op on non-images).
|
|
50
|
+
expect((uploader.uploadAttachment as jest.Mock).mock.calls[0][1]).toBe(pdf)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('holds files client-side when there is no documentId yet, then uploads once it appears (new-deck case)', async () => {
|
|
54
|
+
const uploader = fakeUploader()
|
|
55
|
+
let docId: string | undefined
|
|
56
|
+
const { result, rerender } = renderHook(({ id }: { id?: string }) => useComposerUploads(uploader, id), {
|
|
57
|
+
initialProps: { id: docId },
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
act(() => result.current.attachFiles([file('brand.png')]))
|
|
61
|
+
expect(result.current.attachments).toHaveLength(1)
|
|
62
|
+
expect(uploader.uploadAttachment).not.toHaveBeenCalled()
|
|
63
|
+
|
|
64
|
+
docId = 'doc-new'
|
|
65
|
+
rerender({ id: docId })
|
|
66
|
+
|
|
67
|
+
await waitFor(() => expect(uploader.uploadAttachment).toHaveBeenCalledTimes(1))
|
|
68
|
+
expect(uploader.uploadAttachment).toHaveBeenCalledWith('doc-new', expect.any(File), expect.any(Object))
|
|
69
|
+
await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('marks a chip as error (with the failure reason) when the upload fails', async () => {
|
|
73
|
+
const uploader = fakeUploader({
|
|
74
|
+
uploadAttachment: jest.fn().mockRejectedValue(new Error('boom')),
|
|
75
|
+
})
|
|
76
|
+
const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
|
|
77
|
+
act(() => result.current.attachFiles([file('bad.png')]))
|
|
78
|
+
await waitFor(() => expect(result.current.attachments[0].status).toBe('error'))
|
|
79
|
+
expect(result.current.attachments[0].error).toBe('boom')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('retryAttachment re-uploads a failed chip and reaches ready', async () => {
|
|
83
|
+
const upload = jest
|
|
84
|
+
.fn()
|
|
85
|
+
.mockRejectedValueOnce(new Error('flaky network'))
|
|
86
|
+
.mockResolvedValueOnce({ id: 'srv-retry', fileUrl: 'https://cdn/r.png' })
|
|
87
|
+
const uploader = fakeUploader({ uploadAttachment: upload })
|
|
88
|
+
const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
|
|
89
|
+
act(() => result.current.attachFiles([file('r.png')]))
|
|
90
|
+
await waitFor(() => expect(result.current.attachments[0].status).toBe('error'))
|
|
91
|
+
const chipId = result.current.attachments[0].id
|
|
92
|
+
|
|
93
|
+
act(() => result.current.retryAttachment(chipId))
|
|
94
|
+
await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
|
|
95
|
+
expect(result.current.attachments[0].error).toBeUndefined()
|
|
96
|
+
expect(upload).toHaveBeenCalledTimes(2)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('removes a pending chip and deletes an uploaded attachment server-side', async () => {
|
|
100
|
+
const uploader = fakeUploader()
|
|
101
|
+
const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
|
|
102
|
+
act(() => result.current.attachFiles([file('a.png')]))
|
|
103
|
+
await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
|
|
104
|
+
const chipId = result.current.attachments[0].id
|
|
105
|
+
|
|
106
|
+
act(() => result.current.removeAttachment(chipId))
|
|
107
|
+
expect(result.current.attachments).toHaveLength(0)
|
|
108
|
+
expect(uploader.deleteAttachment).toHaveBeenCalledWith('doc-1', 'srv-a.png')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('surfaces uploaded refs via onAttachmentsChange when a chip reaches ready', async () => {
|
|
112
|
+
const uploader = fakeUploader()
|
|
113
|
+
const onAttachmentsChange = jest.fn()
|
|
114
|
+
const { result } = renderHook(() =>
|
|
115
|
+
useComposerUploads(uploader, 'doc-1', { onAttachmentsChange })
|
|
116
|
+
)
|
|
117
|
+
act(() => result.current.attachFiles([file('hero.png')]))
|
|
118
|
+
await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
|
|
119
|
+
await waitFor(() =>
|
|
120
|
+
expect(onAttachmentsChange).toHaveBeenLastCalledWith([
|
|
121
|
+
{ id: 'srv-hero.png', fileUrl: 'https://cdn/hero.png', name: 'hero.png' },
|
|
122
|
+
])
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import type { PendingAttachment } from './types'
|
|
3
|
+
import { downscaleImage } from './image-downscale'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Structural interface for the upload backend the composer drives. The app's
|
|
7
|
+
* `@startsimpli/api` `AttachmentsApi` satisfies this shape WITHOUT this package
|
|
8
|
+
* importing `@startsimpli/api` — `@startsimpli/ui` stays dependency-free.
|
|
9
|
+
*/
|
|
10
|
+
export interface AttachmentUploader {
|
|
11
|
+
uploadAttachment(
|
|
12
|
+
documentId: string,
|
|
13
|
+
file: File,
|
|
14
|
+
opts?: { purpose?: string; onProgress?: (fraction: number) => void }
|
|
15
|
+
): Promise<{ id: string; fileUrl: string | null }>
|
|
16
|
+
deleteAttachment(documentId: string, attachmentId: string): Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A successfully-uploaded reference, surfaced to the app via `onAttachmentsChange`. */
|
|
20
|
+
export interface UploadedAttachmentRef {
|
|
21
|
+
/** Server attachment id. */
|
|
22
|
+
id: string
|
|
23
|
+
fileUrl: string | null
|
|
24
|
+
name: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Hard ceiling on a single upload so a stalled request surfaces as an error
|
|
28
|
+
* (an error chip) instead of sitting at 0% forever. Downscaled references are
|
|
29
|
+
* small, so this is generous headroom, not a normal-case limit. */
|
|
30
|
+
const UPLOAD_TIMEOUT_MS = 90_000
|
|
31
|
+
|
|
32
|
+
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
33
|
+
return new Promise<T>((resolve, reject) => {
|
|
34
|
+
const t = setTimeout(() => reject(new Error('upload timed out')), ms)
|
|
35
|
+
promise.then(
|
|
36
|
+
(v) => {
|
|
37
|
+
clearTimeout(t)
|
|
38
|
+
resolve(v)
|
|
39
|
+
},
|
|
40
|
+
(e) => {
|
|
41
|
+
clearTimeout(t)
|
|
42
|
+
reject(e)
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface TrackedAttachment {
|
|
49
|
+
/** Stable client id for the chip (also the composer key). */
|
|
50
|
+
id: string
|
|
51
|
+
file: File
|
|
52
|
+
name: string
|
|
53
|
+
sizeBytes: number
|
|
54
|
+
thumbnailUrl?: string
|
|
55
|
+
/** `queued` = held client-side until a documentId exists. */
|
|
56
|
+
status: 'queued' | 'uploading' | 'ready' | 'error'
|
|
57
|
+
progress: number
|
|
58
|
+
/** Server attachment id once uploaded (for delete). */
|
|
59
|
+
serverId?: string
|
|
60
|
+
/** Server asset url once uploaded. */
|
|
61
|
+
fileUrl?: string | null
|
|
62
|
+
/** Failure reason when status==='error'. */
|
|
63
|
+
error?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isRealId(id: string | undefined): id is string {
|
|
67
|
+
return !!id && id !== 'new'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let seq = 0
|
|
71
|
+
const nextChipId = () => `pa-${Date.now().toString(36)}-${++seq}`
|
|
72
|
+
|
|
73
|
+
function makeThumbnail(file: File): string | undefined {
|
|
74
|
+
if (!file.type.startsWith('image/')) return undefined
|
|
75
|
+
try {
|
|
76
|
+
return URL.createObjectURL(file)
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface UseComposerUploadsOptions {
|
|
83
|
+
/** Upload purpose tag forwarded to the backend. Default 'reference'. */
|
|
84
|
+
purpose?: string
|
|
85
|
+
/** Fired whenever the set of successfully-uploaded attachments changes. */
|
|
86
|
+
onAttachmentsChange?: (refs: UploadedAttachmentRef[]) => void
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface UseComposerUploadsResult {
|
|
90
|
+
/** Chips for the composer's controlled `attachments` rendering. */
|
|
91
|
+
attachments: PendingAttachment[]
|
|
92
|
+
/** Add files (attach button / drop / paste). */
|
|
93
|
+
attachFiles: (files: File[]) => void
|
|
94
|
+
/** Remove a chip by id (deletes server-side if already uploaded). */
|
|
95
|
+
removeAttachment: (id: string) => void
|
|
96
|
+
/** Retry a failed chip's upload. */
|
|
97
|
+
retryAttachment: (id: string) => void
|
|
98
|
+
/** Clear all chips (e.g. after a send that consumed them). */
|
|
99
|
+
clear: () => void
|
|
100
|
+
/** True while any chip is still queued/uploading. */
|
|
101
|
+
isUploading: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* App-agnostic upload-state manager for chat reference attachments. Bridges a
|
|
106
|
+
* document-scoped {@link AttachmentUploader} to the composer's chip rendering,
|
|
107
|
+
* so the composer can self-manage uploads when an `uploader` prop is supplied.
|
|
108
|
+
*
|
|
109
|
+
* NEW-DECK handling: the upload endpoint is document-scoped, but a brand-new
|
|
110
|
+
* deck has no document until the first turn creates it. When `documentId` is
|
|
111
|
+
* absent (or 'new'), selected files are held client-side as queued chips (shown
|
|
112
|
+
* as `uploading`/indeterminate). The moment a real `documentId` arrives — i.e.
|
|
113
|
+
* the first turn created the document — every queued file auto-uploads onto it.
|
|
114
|
+
*/
|
|
115
|
+
export function useComposerUploads(
|
|
116
|
+
uploader: AttachmentUploader,
|
|
117
|
+
documentId: string | undefined,
|
|
118
|
+
options: UseComposerUploadsOptions = {}
|
|
119
|
+
): UseComposerUploadsResult {
|
|
120
|
+
const { purpose = 'reference', onAttachmentsChange } = options
|
|
121
|
+
const [items, setItems] = React.useState<TrackedAttachment[]>([])
|
|
122
|
+
const itemsRef = React.useRef(items)
|
|
123
|
+
itemsRef.current = items
|
|
124
|
+
// Track which chips have an upload in flight to avoid double-uploading.
|
|
125
|
+
const inFlight = React.useRef<Set<string>>(new Set())
|
|
126
|
+
|
|
127
|
+
const patch = React.useCallback((id: string, next: Partial<TrackedAttachment>) => {
|
|
128
|
+
setItems((cur) => cur.map((it) => (it.id === id ? { ...it, ...next } : it)))
|
|
129
|
+
}, [])
|
|
130
|
+
|
|
131
|
+
const upload = React.useCallback(
|
|
132
|
+
async (item: TrackedAttachment, docId: string) => {
|
|
133
|
+
if (inFlight.current.has(item.id)) return
|
|
134
|
+
inFlight.current.add(item.id)
|
|
135
|
+
patch(item.id, { status: 'uploading', progress: 0, error: undefined })
|
|
136
|
+
try {
|
|
137
|
+
// Shrink big reference images before upload (backend normalizes to
|
|
138
|
+
// ~1536px anyway); best-effort, falls back to the original.
|
|
139
|
+
const file = await downscaleImage(item.file)
|
|
140
|
+
const res = await withTimeout(
|
|
141
|
+
uploader.uploadAttachment(docId, file, {
|
|
142
|
+
purpose,
|
|
143
|
+
onProgress: (fraction: number) => patch(item.id, { progress: fraction }),
|
|
144
|
+
}),
|
|
145
|
+
UPLOAD_TIMEOUT_MS
|
|
146
|
+
)
|
|
147
|
+
patch(item.id, { status: 'ready', progress: 1, serverId: res.id, fileUrl: res.fileUrl })
|
|
148
|
+
} catch (err) {
|
|
149
|
+
patch(item.id, {
|
|
150
|
+
status: 'error',
|
|
151
|
+
error: err instanceof Error ? err.message : 'Upload failed',
|
|
152
|
+
})
|
|
153
|
+
} finally {
|
|
154
|
+
inFlight.current.delete(item.id)
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
[uploader, patch, purpose]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
const attachFiles = React.useCallback(
|
|
161
|
+
(files: File[]) => {
|
|
162
|
+
if (files.length === 0) return
|
|
163
|
+
const docId = documentId
|
|
164
|
+
const added: TrackedAttachment[] = files.map((file) => ({
|
|
165
|
+
id: nextChipId(),
|
|
166
|
+
file,
|
|
167
|
+
name: file.name,
|
|
168
|
+
sizeBytes: file.size,
|
|
169
|
+
thumbnailUrl: makeThumbnail(file),
|
|
170
|
+
// No document yet → hold queued; otherwise upload right away.
|
|
171
|
+
status: isRealId(docId) ? 'uploading' : 'queued',
|
|
172
|
+
progress: 0,
|
|
173
|
+
}))
|
|
174
|
+
setItems((cur) => [...cur, ...added])
|
|
175
|
+
if (isRealId(docId)) {
|
|
176
|
+
added.forEach((it) => void upload(it, docId))
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
[documentId, upload]
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
// NEW-DECK: when a real documentId arrives, flush any queued files onto it.
|
|
183
|
+
React.useEffect(() => {
|
|
184
|
+
if (!isRealId(documentId)) return
|
|
185
|
+
itemsRef.current
|
|
186
|
+
.filter((it) => it.status === 'queued')
|
|
187
|
+
.forEach((it) => void upload(it, documentId))
|
|
188
|
+
}, [documentId, upload])
|
|
189
|
+
|
|
190
|
+
/** Retry a failed chip's upload (no-op unless it's errored + a doc exists). */
|
|
191
|
+
const retryAttachment = React.useCallback(
|
|
192
|
+
(id: string) => {
|
|
193
|
+
const item = itemsRef.current.find((it) => it.id === id)
|
|
194
|
+
if (!item || item.status !== 'error') return
|
|
195
|
+
if (isRealId(documentId)) {
|
|
196
|
+
void upload(item, documentId)
|
|
197
|
+
} else {
|
|
198
|
+
// No document yet → requeue; the documentId effect will flush it.
|
|
199
|
+
patch(id, { status: 'queued', progress: 0, error: undefined })
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
[documentId, upload, patch]
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
const removeAttachment = React.useCallback(
|
|
206
|
+
(id: string) => {
|
|
207
|
+
const item = itemsRef.current.find((it) => it.id === id)
|
|
208
|
+
setItems((cur) => cur.filter((it) => it.id !== id))
|
|
209
|
+
if (item?.thumbnailUrl) {
|
|
210
|
+
try {
|
|
211
|
+
URL.revokeObjectURL(item.thumbnailUrl)
|
|
212
|
+
} catch {
|
|
213
|
+
/* noop */
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// If it was already uploaded, delete it server-side too.
|
|
217
|
+
if (item?.serverId && isRealId(documentId)) {
|
|
218
|
+
void uploader.deleteAttachment(documentId, item.serverId)
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
[uploader, documentId]
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
const clear = React.useCallback(() => {
|
|
225
|
+
itemsRef.current.forEach((it) => {
|
|
226
|
+
if (it.thumbnailUrl) {
|
|
227
|
+
try {
|
|
228
|
+
URL.revokeObjectURL(it.thumbnailUrl)
|
|
229
|
+
} catch {
|
|
230
|
+
/* noop */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
setItems([])
|
|
235
|
+
}, [])
|
|
236
|
+
|
|
237
|
+
// Revoke object urls on unmount.
|
|
238
|
+
React.useEffect(
|
|
239
|
+
() => () => {
|
|
240
|
+
itemsRef.current.forEach((it) => {
|
|
241
|
+
if (it.thumbnailUrl) {
|
|
242
|
+
try {
|
|
243
|
+
URL.revokeObjectURL(it.thumbnailUrl)
|
|
244
|
+
} catch {
|
|
245
|
+
/* noop */
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
},
|
|
250
|
+
[]
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
const attachments: PendingAttachment[] = React.useMemo(
|
|
254
|
+
() =>
|
|
255
|
+
items.map((it) => ({
|
|
256
|
+
id: it.id,
|
|
257
|
+
name: it.name,
|
|
258
|
+
// A queued chip reads as uploading (indeterminate) to the user.
|
|
259
|
+
status: it.status === 'queued' ? 'uploading' : it.status,
|
|
260
|
+
progress: it.status === 'queued' ? 0 : it.progress,
|
|
261
|
+
thumbnailUrl: it.thumbnailUrl,
|
|
262
|
+
sizeBytes: it.sizeBytes,
|
|
263
|
+
error: it.error,
|
|
264
|
+
})),
|
|
265
|
+
[items]
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
// Surface the uploaded refs to the app whenever the ready set changes.
|
|
269
|
+
const readyRefs = React.useMemo<UploadedAttachmentRef[]>(
|
|
270
|
+
() =>
|
|
271
|
+
items
|
|
272
|
+
.filter((it): it is TrackedAttachment & { serverId: string } => it.status === 'ready' && !!it.serverId)
|
|
273
|
+
.map((it) => ({ id: it.serverId, fileUrl: it.fileUrl ?? null, name: it.name })),
|
|
274
|
+
[items]
|
|
275
|
+
)
|
|
276
|
+
const onChangeRef = React.useRef(onAttachmentsChange)
|
|
277
|
+
onChangeRef.current = onAttachmentsChange
|
|
278
|
+
React.useEffect(() => {
|
|
279
|
+
onChangeRef.current?.(readyRefs)
|
|
280
|
+
}, [readyRefs])
|
|
281
|
+
|
|
282
|
+
const isUploading = items.some((it) => it.status === 'queued' || it.status === 'uploading')
|
|
283
|
+
|
|
284
|
+
return { attachments, attachFiles, removeAttachment, retryAttachment, clear, isUploading }
|
|
285
|
+
}
|