feat: explain mobile evidence screenshots (Closes #827)
CI / lint (pull_request) Successful in 1m47s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-14 13:41:02 +00:00
1 parent 807364c63b
commit af5fe9b69d
14 files changed
+243 -25

No files matched your search

+9 -1
View File
@@ -473,6 +473,13 @@ function createBackgroundIssueSync({
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
function evidenceMarkdown(attachment, markdown, index) {
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
if (!note) return markdown;
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
}
async function deliverReplyRead(item) {
let current = item;
if (!current.replyConfirmed) {
@@ -563,7 +570,8 @@ function createBackgroundIssueSync({
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
}));
}
const attachmentMarkdown = attachmentMarkdowns.join('\n\n');
const attachmentMarkdown = attachmentMarkdowns.map((markdown, index) =>
evidenceMarkdown(attachments[index], markdown, index)).join('\n\n');
await requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
+3
View File
@@ -630,6 +630,9 @@ textarea { resize: vertical; min-height: 120px; }
.issue-evidence-thumbnail[aria-pressed="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
.issue-evidence-thumbnail img { display:block; width:56px; height:56px; border-radius:6px; object-fit:cover; }
.issue-evidence-thumbnail span { position:absolute; right:3px; bottom:3px; min-width:20px; padding:1px 4px; border-radius:999px; background:#07101d; color:#fff; font-size:12px; text-align:center; }
.issue-evidence-note { display:grid; gap:6px; min-width:0; }
.issue-evidence-note textarea { box-sizing:border-box; width:100%; min-height:72px; padding:10px; resize:vertical; border:1px solid #2a496e; border-radius:8px; background:#07101d; color:#e5e7eb; font:inherit; }
.issue-evidence-note textarea:focus-visible { outline:2px solid #60a5fa; outline-offset:2px; }
.issue-evidence-review-actions { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
.issue-evidence-review-actions button { min-width:44px; min-height:44px; }
.create-issue-repository-more { min-height:44px; width:100%; }
+2
View File
@@ -482,6 +482,8 @@
tray: qs('#create-issue-evidence-tray'),
earlier: qs('#move-create-issue-attachment-earlier'),
later: qs('#move-create-issue-attachment-later'),
note: qs('#create-issue-evidence-note'),
noteLabel: qs('#create-issue-evidence-note-label'),
status: qs('#create-issue-attachment-status'),
readyMessage: 'Screenshot ready to file with this issue.',
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',
+4
View File
@@ -763,6 +763,10 @@
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="create-issue-attachment-meta"></span>
<div class="issue-evidence-tray" id="create-issue-evidence-tray" role="toolbar" aria-label="Evidence screenshots" hidden></div>
<label class="issue-evidence-note" for="create-issue-evidence-note">
<span id="create-issue-evidence-note-label">Evidence note (optional)</span>
<textarea id="create-issue-evidence-note" maxlength="240" rows="2" disabled placeholder="What does this screenshot show?"></textarea>
</label>
<div class="issue-evidence-review-actions" aria-label="Reorder selected screenshot">
<button id="move-create-issue-attachment-earlier" type="button">Move earlier</button>
<button id="remove-create-issue-attachment" type="button">Remove selected</button>
+34 -4
View File
@@ -11,6 +11,14 @@
const MAX_FILES_MESSAGE = 'Up to 5 screenshots. Remove one before adding another.';
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function normalizeNote(value) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
}
function escapeMarkdown(value) {
return value.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
}
const optimizeImage = issueEvidenceReview.optimizeImage;
function multipart(attachment) {
@@ -48,7 +56,7 @@
if (maxFiles === 1) selected = [];
else throw new Error(MAX_FILES_MESSAGE);
}
selected.push({file, confirmed:null, serialized:null, operationId:createOperationId()});
selected.push({file, note:'', confirmed:null, serialized:null, operationId:createOperationId()});
return state();
}
@@ -95,6 +103,19 @@
return state();
}
function setNote(index, value) {
const position = Number(index);
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
selected[position].note = normalizeNote(value);
selected[position].serialized = null;
return state();
}
function note(index) {
const position = Number(index);
return Number.isInteger(position) && selected[position] ? selected[position].note : '';
}
function restore(value) {
if (Array.isArray(value)) {
clear();
@@ -118,7 +139,12 @@
const padding = (data.match(/=*$/) || [''])[0].length;
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
commitSelection({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
selected[selected.length - 1].serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
const item = selected[selected.length - 1];
item.note = normalizeNote(value?.note);
item.serialized = {
...(blob ? { filename, contentType, blob } : { filename, contentType, data }),
...(item.note ? {note:item.note} : {}),
};
}
function state() {
@@ -133,6 +159,7 @@
function serializeItem(item) {
if (!item.serialized) item.serialized = {
filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file,
...(item.note ? {note:item.note} : {}),
};
return { ...item.serialized };
}
@@ -163,13 +190,16 @@
throw new Error('The server did not confirm the screenshot upload.');
}
}
markdown.push(evidence.confirmed.markdown);
if (evidence.note) {
markdown.push('**Screenshot ' + (markdown.length + 1) + ' — ' + escapeMarkdown(evidence.note) + '**\n\n' +
evidence.confirmed.markdown);
} else markdown.push(evidence.confirmed.markdown);
}
const evidence = markdown.join('\n\n');
return text ? text + '\n\n' + evidence : evidence;
}
return { select, restore, remove, move, clear, state, serialize, prepareComment };
return { select, restore, remove, move, setNote, note, clear, state, serialize, prepareComment };
}
function mount(options) {
+20
View File
@@ -68,9 +68,20 @@
busy = Boolean(value);
options.earlier.disabled = busy || activeIndex <= 0;
options.later.disabled = busy || activeIndex >= count() - 1;
if (options.note) options.note.disabled = busy;
Array.from(options.tray.children || []).forEach(button => { button.disabled = busy; });
}
function updateNote(values) {
if (!options.note) return;
options.note.value = controller.note(activeIndex);
options.note.disabled = busy;
if (options.noteLabel) {
options.noteLabel.textContent = 'Evidence note for screenshot ' + (activeIndex + 1) + ' of ' +
values.length + ' (optional)';
}
}
function update(values, optimized = false) {
if (!values.length) return;
activeIndex = Math.max(0, Math.min(activeIndex, values.length - 1));
@@ -86,6 +97,7 @@
button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false'));
options.earlier.disabled = busy || activeIndex === 0;
options.later.disabled = busy || activeIndex === values.length - 1;
updateNote(values);
options.status.textContent = optimized ? 'Screenshots optimized and ready to file in this order.' :
values.length + ' screenshots ready to file in this order.';
}
@@ -125,6 +137,11 @@
options.tray.hidden = true;
options.earlier.disabled = true;
options.later.disabled = true;
if (options.note) {
options.note.value = '';
options.note.disabled = true;
}
if (options.noteLabel) options.noteLabel.textContent = 'Evidence note (optional)';
}
function afterRemoval(removedIndex, previousCount) {
@@ -145,6 +162,9 @@
options.earlier.addEventListener('click', () => move(-1));
options.later.addEventListener('click', () => move(1));
options.note?.addEventListener('input', event => {
controller.setNote(activeIndex, event.target.value);
});
clear();
return { activeIndex: () => activeIndex, afterRemoval, clear, render, setBusy };
}
+33 -11
View File
@@ -11,10 +11,12 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const blob = value?.blob;
const data = String(value?.data || '');
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
if (blob) return { filename, contentType, blob };
if (!data && value?.stored === true) return { filename, contentType, stored: true };
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
const noteValue = note ? { note } : {};
if (blob) return { filename, contentType, blob, ...noteValue };
if (!data && value?.stored === true) return { filename, contentType, stored: true, ...noteValue };
if (!data) return undefined;
return { filename, contentType, data };
return { filename, contentType, data, ...noteValue };
}
function captureAttachments(values) {
@@ -90,6 +92,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}} : {}),
...(Array.isArray(item.attachments) ? {attachments:item.attachments.map(value => ({
filename:value.filename, contentType:value.contentType, stored:true,
...(value.note ? {note:value.note} : {}),
}))} : {}),
};
}
@@ -247,6 +250,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
function evidenceMarkdown(attachment, markdown, index) {
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
if (!note) return markdown;
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
}
function attachmentMultipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
@@ -275,30 +285,42 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
if (item.attachment || item.attachments) persistDeliveryStage(item.id, { deliveredIssue: issue });
}
if (!item.attachment) return issue;
let markdown = item.attachmentMarkdown;
if (!markdown) {
const attachments = Array.isArray(item.attachments) ? item.attachments :
(item.attachment ? [item.attachment] : []);
if (!attachments.length) return issue;
const markdowns = Array.isArray(item.attachmentMarkdowns)
? item.attachmentMarkdowns.slice(0, attachments.length)
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
for (let index = markdowns.length; index < attachments.length; index += 1) {
const uploaded = await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
'Idempotency-Key': stageOperationId(
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
),
},
body: attachmentMultipart(item.attachment),
body: attachmentMultipart(attachments[index]),
},
);
markdown = String(uploaded?.markdown || '');
const markdown = String(uploaded?.markdown || '');
if (!markdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
markdowns.push(markdown);
persistDeliveryStage(item.id, {
deliveredIssue: issue, attachmentMarkdowns:markdowns.slice(),
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
});
}
const markdown = markdowns.map((value, index) =>
evidenceMarkdown(attachments[index], value, index)).join('\n\n');
await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
{
+1
View File
@@ -86,6 +86,7 @@ function createUnfiledCaptures({
attachments:prepared.attachments.map(value => ({
filename:String(value.filename).slice(0, 255),
contentType:String(value.contentType), blob:value.blob,
...(String(value.note || '').trim() ? {note:String(value.note).trim().slice(0, 240)} : {}),
})),
} : {
filename:String(prepared.attachment.filename).slice(0, 255),