diff --git a/README.md b/README.md index 65a5707..3d0d439 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,11 @@ inspect/comment on assigned pull requests, merge assigned pull requests, and submit pull-request reviews. Assigned-issue comments can include one PNG, JPEG, or WebP screenshot up to 2 MB. The screenshot uploads before the comment is posted; validation or upload failures keep -both the typed comment and removable preview available for retry. +both the typed comment and removable preview available for retry. The mobile **New issue** +sheet accepts the same image formats and stores the screenshot with its account-bound +outbox capture. Delivery creates the issue exactly once, then uploads and comments with +the image; after a partial failure, retry resumes with the confirmed issue instead of +creating a duplicate. Pull-request replies and mobile My Work issue and PR comments use Gitea's issue-comment API. In issue, pull-request, and unread-update conversations, typing at least two characters after `@` offers repository-scoped teammate suggestions; diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 057d23c..20ce8b1 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -141,8 +141,13 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) || (current.status === 'attention' && item.status === 'attention') || current.status === 'sent')) return current; - await records.put({ ...item }); - return item; + const next = current && current.operationId === item.operationId ? { + ...item, + ...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}), + ...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}), + } : { ...item }; + await records.put(next); + return next; }); } @@ -177,6 +182,7 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n return { reconcile, upsert, + update, claim, claimNext, claimBatch, @@ -343,10 +349,63 @@ function createBackgroundIssueSync({ }; } + function stageOperationId(operationId, stage) { + const suffix = ':' + stage; + return String(operationId || '').slice(0, 128 - suffix.length) + suffix; + } + + async function deliverIssueCapture(item) { + const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); + let deliveredIssue = item.deliveredIssue; + if (!deliveredIssue) { + const request = deliveryRequest(item); + deliveredIssue = await requestJson(request.url, request.options); + await store.update?.(item.id, current => ({ ...current, deliveredIssue })); + } + let attachmentMarkdown = item.attachmentMarkdown; + if (!attachmentMarkdown) { + const uploaded = await requestJson( + base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments', + { + method: 'POST', + headers: { + Accept: 'application/json', 'Content-Type': 'application/json', + 'Idempotency-Key': stageOperationId(item.operationId, 'attachment'), + }, + body: JSON.stringify({ + filename: item.attachment.filename, + content_type: item.attachment.contentType, + data: item.attachment.data, + }), + }, + ); + attachmentMarkdown = String(uploaded?.markdown || ''); + if (!attachmentMarkdown) { + const error = new Error('The server did not confirm the screenshot upload.'); + error.status = 422; + throw error; + } + await store.update?.(item.id, current => ({ ...current, deliveredIssue, attachmentMarkdown })); + } + await requestJson( + base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments', + { + method: 'POST', + headers: { + Accept: 'application/json', 'Content-Type': 'application/json', + 'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'), + }, + body: JSON.stringify({ body: attachmentMarkdown }), + }, + ); + return deliveredIssue; + } + async function deliver(item) { const request = deliveryRequest(item); try { - const delivered = await requestJson(request.url, request.options); + const delivered = item.attachment && !item.kind ? + await deliverIssueCapture(item) : await requestJson(request.url, request.options); if (item.kind === 'issue-close' && delivered?.state !== 'closed') { const error = new Error('Issue closure was not confirmed.'); error.status = 422; diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 3b2df09..2a85b4a 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -337,10 +337,11 @@ textarea { resize: vertical; min-height: 120px; } @media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } } .create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } .create-issue-sheet.open { display:flex; } -.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; } +.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; } .create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } .create-issue-header button, .create-issue-actions button { min-height:44px; } .create-issue-form { display:grid; gap:12px; } +.create-issue-attachment { display:grid; gap:8px; min-width:0; } .create-issue-repository-more { min-height:44px; width:100%; } .create-issue-form label { display:grid; gap:6px; } .create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 07877b6..5aedcb9 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -303,6 +303,25 @@ ); }, }); + const createIssueAttachmentController = issueAttachment.mount({ + input: qs('#create-issue-attachment'), + preview: qs('#create-issue-attachment-preview'), + image: qs('#create-issue-attachment-image'), + meta: qs('#create-issue-attachment-meta'), + remove: qs('#remove-create-issue-attachment'), + status: qs('#create-issue-attachment-status'), + readyMessage: 'Screenshot ready to file with this issue.', + removedMessage: 'Screenshot removed. Your issue draft is unchanged.', + createObjectURL: file => URL.createObjectURL(file), + revokeObjectURL: url => URL.revokeObjectURL(url), + readDataUrl: file => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(new Error('The screenshot could not be read. Choose it again.')); + reader.readAsDataURL(file); + }), + upload: async () => { throw new Error('Create the issue before uploading its screenshot.'); }, + }); const planningLoader = createIssueSheet.createPlanningLoader({ loadLabels: item => issueController.loadLabels(item), loadMilestones: item => issueController.loadMilestones(item), @@ -1694,6 +1713,8 @@ editingOutboxId = queued.id; issueCapture.saveDraft(queued); openCreateIssueSheet(); + if (queued.attachment) createIssueAttachmentController.restore(queued.attachment); + else createIssueAttachmentController.clear(); qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.'; }); }); @@ -3542,7 +3563,11 @@ qs('#create-issue-status').textContent = createAndStartRequested ? 'Creating issue and adding it to Today…' : 'Saving for background delivery…'; try { - const durableDraft = createAndStartRequested ? { ...captureDraft, completionIntent: 'create-and-start' } : captureDraft; + const durableDraft = { + ...captureDraft, + attachment: await createIssueAttachmentController.serialize(), + ...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}), + }; const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) : await issueOutbox.enqueueDurably(durableDraft); const queued = admission.item; @@ -3556,6 +3581,7 @@ } editingOutboxId = null; issueCapture.clearDraft(); + createIssueAttachmentController.clear(); suppressCreateDraftOnHistoryClose = true; taskOverlayHistory.leave(); refreshMyWorkView(); diff --git a/frontend/index.html b/frontend/index.html index f097a01..ab44127 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -418,6 +418,19 @@ +
+ Screenshot Optional · PNG, JPEG, or WebP · 2 MB max +
+ + +
+ +
+