diff --git a/README.md b/README.md index b95ef8c..65a5707 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ 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 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. 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/dashboard.css b/frontend/dashboard.css index e10dfef..3b2df09 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -255,6 +255,14 @@ textarea { resize: vertical; min-height: 120px; } .issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; } .issue-comment-composer { display:grid; gap:8px; margin-top:16px; } .issue-comment-composer button { min-height:44px; width:100%; } +.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } +.issue-attachment-controls { display:flex; max-width:100%; } +.issue-attachment-trigger { min-height:44px; display:inline-flex; align-items:center; justify-content:center; padding:8px 12px; border:1px solid #60a5fa; border-radius:10px; color:#dbeafe; font-weight:700; cursor:pointer; } +.issue-attachment-preview { display:grid; grid-template-columns:64px minmax(0,1fr); gap:8px 12px; align-items:center; max-width:100%; overflow:hidden; padding:10px; border:1px solid #315781; border-radius:10px; background:#101f34; } +.issue-attachment-preview[hidden] { display:none; } +.issue-attachment-preview img { grid-row:span 2; width:64px; height:64px; object-fit:cover; border-radius:8px; } +.issue-attachment-preview .small { min-width:0; overflow-wrap:anywhere; } +.issue-attachment-preview button { min-height:44px; width:auto; justify-self:start; } .mention-options { display:grid; max-width:100%; max-height:220px; overflow:auto; border:1px solid #315781; border-radius:10px; background:#101f34; box-shadow:0 10px 28px rgba(0,0,0,.35); } .mention-options[hidden] { display:none; } .mention-option { min-height:44px; max-width:100%; overflow:hidden; padding:9px 12px; border:0; border-bottom:1px solid #203a5c; border-radius:0; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:#101f34; color:#dbeafe; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 2124e2d..07877b6 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -272,6 +272,37 @@ mobile: window.matchMedia('(max-width: 600px)').matches, }); const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage }); + const issueAttachmentController = issueAttachment.mount({ + input: qs('#issue-attachment'), + preview: qs('#issue-attachment-preview'), + image: qs('#issue-attachment-image'), + meta: qs('#issue-attachment-meta'), + remove: qs('#remove-issue-attachment'), + status: qs('#issue-comment-status'), + 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: payload => { + const repository = payload.repository.split('/').map(encodeURIComponent).join('/'); + return fetchReviewJson( + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(payload.number) + '/attachments', + { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filename: payload.filename, + content_type: payload.content_type, + data: payload.data, + }), + }, + ); + }, + }); const planningLoader = createIssueSheet.createPlanningLoader({ loadLabels: item => issueController.loadLabels(item), loadMilestones: item => issueController.loadMilestones(item), @@ -2143,6 +2174,7 @@ return; } mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel')); + issueAttachmentController.clear(); qs('#issue-sheet').classList.remove('open'); selectedIssue = null; selectedIssueOffline = false; @@ -3729,7 +3761,7 @@ const textarea = qs('#' + kind + '-comment'); const status = qs('#' + kind + '-comment-status'); const body = textarea.value.trim(); - if (!body) { + if (!body && (kind !== 'issue' || !issueAttachmentController.state())) { status.textContent = 'Write a comment before posting.'; textarea.focus(); return; @@ -3741,17 +3773,21 @@ item.repository + '#' + item.number + ':operation'); postButton.disabled = true; nextButton.disabled = true; - status.textContent = 'Posting comment and opening next…'; + status.textContent = kind === 'issue' && issueAttachmentController.state() ? + 'Uploading screenshot before opening next…' : 'Posting comment and opening next…'; try { - const result = await controller.submit(item, body, operationId); + const preparedBody = kind === 'issue' ? + await issueAttachmentController.prepareComment(item, body) : body; + const result = await controller.submit(item, preparedBody, operationId); const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item; if (!stillOpen) return; + if (kind === 'issue') issueAttachmentController.clear(); if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.'; else if (result.delivery === 'posted') status.textContent = 'Comment posted.'; else if (result.background) status.textContent = 'Queued for sync when the connection returns.'; else status.textContent = 'Saved for next launch; background delivery unavailable.'; } catch (error) { - status.textContent = error.message + ' Your draft and Today position are safe; retry.'; + status.textContent = error.message + ' Your draft, screenshot, and Today position are safe; retry.'; textarea.focus(); } finally { postButton.disabled = false; @@ -3763,28 +3799,40 @@ qs('#send-issue-comment').addEventListener('click', async () => { if (!selectedIssue) return; const body = qs('#issue-comment').value.trim(); - if (!body) { + if (!body && !issueAttachmentController.state()) { qs('#issue-comment-status').textContent = 'Write a comment before posting.'; qs('#issue-comment').focus(); return; } const button = qs('#send-issue-comment'); button.disabled = true; - qs('#issue-comment-status').textContent = 'Posting comment…'; + qs('#issue-comment-status').textContent = issueAttachmentController.state() ? + 'Uploading screenshot…' : 'Posting comment…'; + let preparedBody; try { - const comment = await issueController.comment(selectedIssue, body); + preparedBody = await issueAttachmentController.prepareComment(selectedIssue, body); + } catch (error) { + qs('#issue-comment-status').textContent = error.message + ' Your comment and screenshot are safe; retry.'; + qs('#issue-comment').focus(); + button.disabled = false; + return; + } + try { + const comment = await issueController.comment(selectedIssue, preparedBody); if (issueConversation) renderIssueConversation(issueConversation.append(comment)); qs('#issue-comment').value = ''; + issueAttachmentController.clear(); qs('#issue-comment-status').textContent = 'Comment posted.'; } catch (error) { if (canQueueMessage(error)) { const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation'); qs('#issue-comment-status').textContent = 'Saving for background delivery…'; const admission = await authoredOutbox.enqueueDurably({ kind:'issue-comment', repository:selectedIssue.repository, - number:selectedIssue.number, body, operationId }); + number:selectedIssue.number, body:preparedBody, operationId }); refreshMyWorkView(); if (admission.background) { qs('#issue-comment').value = ''; + issueAttachmentController.clear(); qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.'; } else { qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.'; diff --git a/frontend/index.html b/frontend/index.html index 4897fae..f097a01 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -297,6 +297,15 @@
+ +