diff --git a/README.md b/README.md index c569e3c..6a2bbf4 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,8 @@ threads, create and self-assign issues, discover, claim, and release issue assig list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues, inspect/comment on assigned pull requests, merge assigned pull requests, and submit pull-request reviews. -Assigned-issue and assigned-pull-request comments can include one PNG, JPEG, or WebP screenshot; each mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged. -For online delivery, the screenshot uploads before the comment is posted—to the exact assigned issue or pull request—and produces one Markdown comment; validation or upload -failures keep both the typed comment and removable preview available for retry. Offline screenshot comments -admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in -localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate -either stage. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to +Assigned-issue, assigned-pull-request, and unread-update conversation composers accept an ordered bundle of up to five PNG, JPEG, or WebP photos. Repeated camera captures append to the bundle, the gallery picker accepts multiple images, and each composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary. +For online delivery, every photo uploads before the comment is posted to the exact conversation target, producing one ordered Markdown comment or reply; validation or upload failures keep the typed text and removable preview available for retry. Offline photo conversations admit every image Blob to IndexedDB before confirmation, keep only bounded metadata in localStorage, and checkpoint each upload separately so reconnect resumes at the first unconfirmed photo without duplicating an upload, comment, reply, or reply-and-read transition. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to IndexedDB before confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js index bc37fca..9dbbb33 100644 --- a/frontend/authored-outbox.js +++ b/frontend/authored-outbox.js @@ -6,6 +6,28 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, const pending = new Map(); const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']); + function messageAttachments(message) { + const values = Array.isArray(message?.attachments) ? message.attachments : + (Array.isArray(message?.attachment) ? message.attachment : (message?.attachment ? [message.attachment] : [])); + return values.filter(Boolean).slice(0, 5); + } + + function attachmentMetadata(value) { + return { + filename: String(value.filename || ''), + contentType: String(value.contentType || ''), + stored: true, + }; + } + + function durableAttachment(value) { + return { + filename: String(value.filename || ''), + contentType: String(value.contentType || ''), + ...(value.blob ? { blob:value.blob } : { data:String(value.data || '') }), + }; + } + function checklistOperation(value) { const action = String(value?.action || ''); if (!['rename', 'remove', 'move-earlier', 'move-later'].includes(action)) return null; @@ -90,6 +112,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, } if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.'); const id = String(requestedOperationId || makeId()).slice(0, 128); + const attachments = messageAttachments(message); const item = { id, operationId: requestedOperationId || id, @@ -102,13 +125,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, status: 'queued', queuedAt: Number(now()), ...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}), - ...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? { - attachment: { - filename: String(message.attachment.filename || ''), - contentType: String(message.attachment.contentType || ''), - stored: true, - }, - } : {}), + ...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && attachments.length ? + (attachments.length === 1 ? { attachment:attachmentMetadata(attachments[0]) } : + { attachments:attachments.map(attachmentMetadata) }) : {}), ...(message.kind === 'pull-review' ? { decision: String(message.decision || 'comment'), expectedHeadSha: String(message.expectedHeadSha || ''), @@ -140,7 +159,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, async function enqueueDurably(message) { const previousItems = read(); const item = enqueue(message, false); - if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) && + const attachments = messageAttachments(message); + if (attachments.length && ['update-reply', 'update-reply-read'].includes(message.kind) && (!backgroundSync?.reconcile || !backgroundSync?.requestSync)) { write(read().filter(candidate => candidate.id !== item.id), false); throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.'); @@ -148,14 +168,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) { return { item, background: false, durability: 'foreground-only' }; } - const durableItems = read().map(candidate => candidate.id === item.id && message.attachment ? { + const durableItems = read().map(candidate => candidate.id === item.id && attachments.length ? { ...candidate, - attachment: { - filename: String(message.attachment.filename || ''), - contentType: String(message.attachment.contentType || ''), - ...(message.attachment.blob ? { blob: message.attachment.blob } : - { data: String(message.attachment.data || '') }), - }, + ...(attachments.length === 1 ? { attachment:durableAttachment(attachments[0]) } : + { attachments:attachments.map(durableAttachment) }), } : candidate); try { await backgroundSync.reconcile(durableItems, 'authored'); diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 5b2cc2a..8576c89 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -186,10 +186,15 @@ function createIssueSyncStore({ const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) && item?.attachment?.stored && !item.attachment.data && !item.attachment.blob ? { attachment: current.attachment } : {}; + const preservedAttachments = current?.operationId === item?.operationId && + current?.attachments?.every(value => value?.data || value?.blob) && + item?.attachments?.every(value => value?.stored && !value.data && !value.blob) + ? { attachments:current.attachments } : {}; const next = current && current.operationId === item.operationId ? { - ...item, ...preservedAttachment, + ...item, ...preservedAttachment, ...preservedAttachments, ...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}), ...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}), + ...(current.attachmentMarkdowns ? { attachmentMarkdowns:current.attachmentMarkdowns } : {}), } : { ...item }; await records.put(next); return next; @@ -508,31 +513,65 @@ function createBackgroundIssueSync({ return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown; } + function conversationAttachments(item) { + return (Array.isArray(item?.attachments) ? item.attachments : [item?.attachment]).filter(Boolean); + } + + async function uploadConversationAttachments(item, url) { + let current = item; + const attachments = conversationAttachments(current); + const markdowns = Array.isArray(current.attachmentMarkdowns) ? + current.attachmentMarkdowns.slice(0, attachments.length) : + (current.attachmentMarkdown ? [current.attachmentMarkdown] : []); + for (let index = markdowns.length; index < attachments.length; index += 1) { + const uploaded = await requestStage(current, url, { + method:'POST', + headers:{ + Accept:'application/json', + 'Idempotency-Key':stageOperationId( + current.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index, + ), + }, + body:attachmentMultipart(attachments[index]), + }); + const markdown = String(uploaded?.markdown || ''); + if (!markdown) { + const error = new Error('The server did not confirm the screenshot upload.'); + error.status = 422; + throw error; + } + markdowns.push(markdown); + await checkpointClaim(current, stored => ({ + ...stored, attachmentMarkdowns:markdowns.slice(), + ...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}), + })); + current = { + ...current, attachmentMarkdowns:markdowns.slice(), + ...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}), + }; + } + return { + current, + markdown:markdowns.map((value, index) => + evidenceMarkdown(attachments[index], value, index)).join('\n\n'), + }; + } + async function deliverReplyRead(item) { let current = item; if (!current.replyConfirmed) { - let attachmentMarkdown = current.attachmentMarkdown; - if (current.attachment && !attachmentMarkdown) { - const uploaded = await requestStage(current, - base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', { - method:'POST', - headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') }, - body:attachmentMultipart(current.attachment), - }); - attachmentMarkdown = String(uploaded?.markdown || ''); - if (!attachmentMarkdown) { - const error = new Error('The server did not confirm the screenshot upload.'); - error.status = 422; - throw error; - } - await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown })); - current = { ...current, attachmentMarkdown }; + let attachmentMarkdown = ''; + if (conversationAttachments(current).length) { + const uploaded = await uploadConversationAttachments(current, + base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments'); + current = uploaded.current; + attachmentMarkdown = uploaded.markdown; } const text = String(current.body || '').trim(); const replyBody = attachmentMarkdown ? (text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text; const options = authoredRequest('', { ...current, body:replyBody }).options; - if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply'); + if (conversationAttachments(current).length) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply'); await requestStage( current, base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', @@ -638,28 +677,9 @@ function createBackgroundIssueSync({ async function deliverScreenshotComment(item) { const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues'; - let attachmentMarkdown = item.attachmentMarkdown; - if (!attachmentMarkdown) { - const uploaded = await requestStage( - item, - base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments', - { - method: 'POST', - headers: { - Accept: 'application/json', - 'Idempotency-Key': stageOperationId(item.operationId, 'attachment'), - }, - body: attachmentMultipart(item.attachment), - }, - ); - attachmentMarkdown = String(uploaded?.markdown || ''); - if (!attachmentMarkdown) { - const error = new Error('The server did not confirm the screenshot upload.'); - error.status = 422; - throw error; - } - await checkpointClaim(item, current => ({ ...current, attachmentMarkdown })); - } + const uploaded = await uploadConversationAttachments(item, + base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments'); + const attachmentMarkdown = uploaded.markdown; const text = String(item.body || '').trim(); return requestStage( item, @@ -676,24 +696,10 @@ function createBackgroundIssueSync({ } async function deliverUpdateScreenshotReply(item) { - let current = item; - let attachmentMarkdown = current.attachmentMarkdown; - if (!attachmentMarkdown) { - const uploaded = await requestStage(current, - base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', { - method:'POST', - headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') }, - body:attachmentMultipart(current.attachment), - }); - attachmentMarkdown = String(uploaded?.markdown || ''); - if (!attachmentMarkdown) { - const error = new Error('The server did not confirm the screenshot upload.'); - error.status = 422; - throw error; - } - await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown })); - current = { ...current, attachmentMarkdown }; - } + const uploaded = await uploadConversationAttachments(item, + base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/attachments'); + const current = uploaded.current; + const attachmentMarkdown = uploaded.markdown; const text = String(current.body || '').trim(); return requestStage(current, base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', { @@ -708,8 +714,8 @@ function createBackgroundIssueSync({ const request = deliveryRequest(item); try { const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) : - item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) : - item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ? + item.kind === 'update-reply' && conversationAttachments(item).length ? await deliverUpdateScreenshotReply(item) : + conversationAttachments(item).length && ['issue-comment', 'pull-comment'].includes(item.kind) ? await deliverScreenshotComment(item) : item.attachment && !item.kind ? await deliverIssueCapture(item) : item.attachments?.length && !item.kind ? await deliverIssueCapture(item) : item.blockers?.length && !item.kind ? diff --git a/frontend/dashboard.js b/frontend/dashboard.js index db12b5a..4b195d9 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -495,6 +495,7 @@ qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.'; }); const issueAttachmentController = issueAttachment.mount({ + maxFiles: 5, input: qs('#issue-attachment'), inputs: [qs('#take-issue-comment-photo'), qs('#issue-attachment')], preview: qs('#issue-attachment-preview'), @@ -526,6 +527,7 @@ }, }); const pullAttachmentController = issueAttachment.mount({ + maxFiles: 5, input: qs('#pull-attachment'), inputs: [qs('#take-pull-comment-photo'), qs('#pull-attachment')], preview: qs('#pull-attachment-preview'), @@ -548,6 +550,7 @@ }, }); const updateReplyAttachmentController = issueAttachment.mount({ + maxFiles: 5, input: qs('#update-reply-attachment'), inputs: [qs('#take-update-reply-photo'), qs('#update-reply-attachment')], preview: qs('#update-reply-attachment-preview'), diff --git a/frontend/index.html b/frontend/index.html index 16473e7..8a521f8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -665,7 +665,7 @@ - +