feat: edit photos in conversation replies (Closes #947)
All checks were successful
CI / lint (pull_request) Successful in 2m4s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-16 07:28:10 +00:00
parent 6270253c24
commit 0d5fa13d65
7 changed files with 82 additions and 6 deletions

View File

@ -19,7 +19,7 @@ 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, 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.
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. Before sending, the selected conversation photo can use the same touch editor as New issue evidence to crop, privacy-redact, highlight, or add an arrow; Apply replaces only that flattened derivative while preserving its caption and bundle position, and Cancel leaves the original unchanged.
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

View File

@ -704,7 +704,7 @@ textarea { resize: vertical; min-height: 120px; }
.conversation-evidence-review { grid-template-columns:1fr; width:100%; min-width:0; box-sizing:border-box; overflow-x:hidden; }
.conversation-evidence-review > img { grid-row:auto; width:100%; height:auto; max-height:280px; }
.conversation-evidence-review .issue-evidence-tray { overflow-x:auto; }
.conversation-evidence-review .issue-evidence-review-actions { grid-template-columns:repeat(3,minmax(0,1fr)); }
.conversation-evidence-review .issue-evidence-review-actions { grid-template-columns:repeat(4,minmax(0,1fr)); }
.conversation-evidence-review .issue-evidence-review-actions button { min-width:44px; min-height:44px; width:100%; }
@media (max-width:390px) {
.conversation-evidence-review .issue-evidence-review-actions { grid-template-columns:1fr; }

View File

@ -508,6 +508,19 @@
note: qs('#issue-attachment-note'),
noteLabel: qs('#issue-attachment-note-label'),
status: qs('#issue-comment-status'),
editor: {
document,
edit: qs('#edit-issue-attachment'),
dialog: qs('#issue-evidence-editor'),
canvas: qs('#issue-evidence-editor-canvas'),
exportCanvas: qs('#issue-evidence-editor-export'),
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
status: qs('#issue-evidence-editor-status'),
appliedMessage: 'Edited photo flattened and ready to send.',
},
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
readDataUrl: file => new Promise((resolve, reject) => {
@ -545,6 +558,19 @@
note: qs('#pull-attachment-note'),
noteLabel: qs('#pull-attachment-note-label'),
status: qs('#pull-comment-status'),
editor: {
document,
edit: qs('#edit-pull-attachment'),
dialog: qs('#issue-evidence-editor'),
canvas: qs('#issue-evidence-editor-canvas'),
exportCanvas: qs('#issue-evidence-editor-export'),
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
status: qs('#issue-evidence-editor-status'),
appliedMessage: 'Edited photo flattened and ready to send.',
},
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
upload: payload => {
@ -575,6 +601,19 @@
status: qs('#update-reply-status'),
readyMessage: 'Screenshot ready to send with this reply.',
removedMessage: 'Screenshot removed. Your reply is unchanged.',
editor: {
document,
edit: qs('#edit-update-reply-attachment'),
dialog: qs('#issue-evidence-editor'),
canvas: qs('#issue-evidence-editor-canvas'),
exportCanvas: qs('#issue-evidence-editor-export'),
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
status: qs('#issue-evidence-editor-status'),
appliedMessage: 'Edited photo flattened and ready to send.',
},
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
upload: payload => fetchReviewJson(

View File

@ -677,6 +677,7 @@
</label>
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
<button id="move-issue-attachment-earlier" type="button">Move earlier</button>
<button id="edit-issue-attachment" type="button">Edit photo</button>
<button id="remove-issue-attachment" type="button">Remove selected</button>
<button id="move-issue-attachment-later" type="button">Move later</button>
</div>
@ -954,10 +955,11 @@
<div id="create-issue-capture-status" class="small" aria-live="assertive"></div>
</form>
</section>
</div>
<section class="issue-evidence-editor" id="issue-evidence-editor" role="dialog" aria-modal="true" aria-labelledby="issue-evidence-editor-heading" hidden>
<div class="issue-evidence-editor-panel">
<header>
<div><h3 id="issue-evidence-editor-heading">Edit screenshot evidence</h3><span class="small">Edits stay on this device until you file.</span></div>
<div><h3 id="issue-evidence-editor-heading">Edit selected photo</h3><span class="small">Edits stay on this device until you send or file.</span></div>
<button id="cancel-issue-evidence-edit" type="button">Cancel</button>
</header>
<canvas id="issue-evidence-editor-canvas" role="img" aria-label="Screenshot editing canvas. Drag to crop, redact, highlight, or point with an arrow."></canvas>
@ -974,7 +976,6 @@
<button class="issue-evidence-editor-apply" id="apply-issue-evidence-edit" type="button">Apply flattened edit</button>
</div>
</section>
</div>
<section class="issue-filing-review" id="issue-filing-review" role="dialog" aria-modal="true" aria-labelledby="issue-filing-review-heading" hidden>
<div class="issue-filing-review-panel">
@ -1077,6 +1078,7 @@
</label>
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
<button id="move-update-reply-attachment-earlier" type="button">Move earlier</button>
<button id="edit-update-reply-attachment" type="button">Edit photo</button>
<button id="remove-update-reply-attachment" type="button">Remove selected</button>
<button id="move-update-reply-attachment-later" type="button">Move later</button>
</div>
@ -1158,6 +1160,7 @@
</label>
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
<button id="move-pull-attachment-earlier" type="button">Move earlier</button>
<button id="edit-pull-attachment" type="button">Edit photo</button>
<button id="remove-pull-attachment" type="button">Remove selected</button>
<button id="move-pull-attachment-later" type="button">Move later</button>
</div>

View File

@ -377,7 +377,8 @@
onApplied: async index => {
const value = await controller.serialize();
review.render(value, index);
options.status.textContent = 'Edited screenshot flattened and ready to file.';
options.status.textContent = options.editor.appliedMessage ||
'Edited screenshot flattened and ready to file.';
},
});
options.editor.edit.disabled = !controller.state();

View File

@ -200,6 +200,7 @@
if (source && model) paint({source, model, canvas:options.canvas, maxDimension:1600});
}
function close() {
if (!source && !model) return;
options.dialog.hidden = true;
if (source && typeof source.close === 'function') source.close();
source = null;

View File

@ -151,3 +151,35 @@ def test_mobile_editor_dialog_is_accessible_touch_sized_and_available_offline():
assert "min-height:44px" in css
assert "max-width:100%" in css
assert "overflow-x:hidden" in css
def test_mobile_conversation_photo_editor_is_available_in_every_composer():
html = INDEX.read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
css = CSS.read_text()
composers = (
("issue-attachment", "issueAttachmentController"),
("pull-attachment", "pullAttachmentController"),
("update-reply-attachment", "updateReplyAttachmentController"),
)
for attachment_id, controller in composers:
assert f'id="edit-{attachment_id}" type="button">Edit photo</button>' in html
mount = dashboard.split(
f"const {controller} = issueAttachment.mount({{", 1
)[1].split("\n });", 1)[0]
assert f"edit: qs('#edit-{attachment_id}')" in mount
assert "dialog: qs('#issue-evidence-editor')" in mount
assert "appliedMessage: 'Edited photo flattened and ready to send.'" in mount
assert '<h3 id="issue-evidence-editor-heading">Edit selected photo</h3>' in html
assert "Edits stay on this device until you send or file." in html
assert (
'</form>\n </section>\n</div>\n'
'<section class="issue-evidence-editor" id="issue-evidence-editor"'
) in html, "the shared editor must not be hidden inside the New issue sheet"
assert (
".conversation-evidence-review .issue-evidence-review-actions "
"{ grid-template-columns:repeat(4,minmax(0,1fr)); }"
) in css
assert "@media (max-width:390px)" in css