feat: attach screenshots to update replies (Closes #527)
CI / lint (pull_request) Successful in 1m13s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 01:04:18 +00:00
1 parent beaf5e0587
commit 31f6097afc
21 files changed
+437 -32

No files matched your search

+6 -1
View File
@@ -75,7 +75,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
status: 'queued',
queuedAt: Number(now()),
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
...(['issue-comment', 'pull-comment'].includes(message.kind) && message.attachment ? {
...(['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 || ''),
@@ -99,6 +99,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
async function enqueueDurably(message) {
const item = enqueue(message, false);
if (message.attachment && ['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.');
}
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
+53 -1
View File
@@ -437,10 +437,32 @@ function createBackgroundIssueSync({
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 };
}
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');
await requestStage(
current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
authoredRequest('', current).options,
options,
);
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
@@ -548,10 +570,40 @@ 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 text = String(current.body || '').trim();
return requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
method:'POST',
headers:{ Accept:'application/json', 'Content-Type':'application/json',
'Idempotency-Key':stageOperationId(current.operationId, 'reply') },
body:JSON.stringify({ body:text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
});
}
async function deliver(item) {
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) ?
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
+6
View File
@@ -255,6 +255,12 @@ textarea { resize: vertical; min-height: 120px; }
.update-reply button { min-height:44px; width:100%; }
.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.update-reply-actions button { min-height:44px; width:100%; }
.update-reply .issue-attachment-preview { width:100%; min-width:0; }
@media (max-width:390px) {
.update-reply-actions { grid-template-columns:1fr; }
.update-reply .issue-attachment-preview { grid-template-columns:56px minmax(0,1fr); padding:8px; }
.update-reply .issue-attachment-preview img { width:56px; height:56px; }
}
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.update-ownership-actions button { min-width:0; width:100%; }
+37 -6
View File
@@ -324,6 +324,23 @@
);
},
});
const updateReplyAttachmentController = issueAttachment.mount({
input: qs('#update-reply-attachment'),
preview: qs('#update-reply-attachment-preview'),
image: qs('#update-reply-attachment-image'),
meta: qs('#update-reply-attachment-meta'),
remove: qs('#remove-update-reply-attachment'),
status: qs('#update-reply-status'),
readyMessage: 'Screenshot ready to send with this reply.',
removedMessage: 'Screenshot removed. Your reply is unchanged.',
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
upload: payload => fetchReviewJson(
'api/v1/notifications/' + encodeURIComponent(payload.notificationId) + '/attachments',
{ method:'POST', headers:{ Accept:'application/json', 'Idempotency-Key':payload.operation_id },
body:issueAttachment.multipart(payload) },
),
});
const createIssueAttachmentController = issueAttachment.mount({
input: qs('#create-issue-attachment'),
preview: qs('#create-issue-attachment-preview'),
@@ -635,6 +652,9 @@
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
onOpen: item => {
if (selectedUpdate && selectedUpdate.notification_id !== item.notification_id) {
updateReplyAttachmentController.clear();
}
selectedUpdate = item;
updateMentions.dismiss();
qs('#update-sheet').classList.add('open');
@@ -1093,6 +1113,7 @@
post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId),
markRead: markNotificationRead,
queue: message => authoredOutbox.enqueueDurably(message),
deliver: item => authoredOutbox.retry(item.id, activeFlushLogin),
canQueue: canQueueMessage,
accept: item => {
notificationReplier.saveDraft(item, '');
@@ -3080,6 +3101,7 @@
return;
}
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
updateReplyAttachmentController.clear();
qs('#update-sheet').classList.remove('open');
selectedUpdate = null;
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
@@ -4448,21 +4470,26 @@
qs('#send-update-reply').addEventListener('click', async () => {
if (!selectedUpdate) return;
const body = qs('#update-reply').value.trim();
if (!body) {
qs('#update-reply-status').textContent = 'Write a reply before sending.';
const attachment = await updateReplyAttachmentController.serialize();
if (!body && !attachment) {
qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
qs('#update-reply').focus();
return;
}
qs('#send-update-reply').disabled = true;
const result = await notificationReplier.submit(selectedUpdate, body);
updateReplyAttachmentController.setBusy(true);
const result = await notificationReplier.submit(selectedUpdate, body, attachment);
qs('#send-update-reply').disabled = false;
updateReplyAttachmentController.setBusy(false);
if (result?.queued) {
qs('#update-reply').value = '';
updateReplyAttachmentController.clear();
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Reply queued for sync.';
} else if (result) {
notificationReader.appendReply(result);
qs('#update-reply').value = '';
updateReplyAttachmentController.clear();
qs('#mark-update-read-next').focus();
} else {
qs('#update-reply').focus();
@@ -4472,8 +4499,9 @@
if (!selectedUpdate) return;
const item = selectedUpdate;
const body = qs('#update-reply').value.trim();
if (!body) {
qs('#update-reply-status').textContent = 'Write a reply before sending.';
const attachment = await updateReplyAttachmentController.serialize();
if (!body && !attachment) {
qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
qs('#update-reply').focus();
return;
}
@@ -4481,10 +4509,12 @@
const sendButton = qs('#send-update-reply');
button.disabled = true;
sendButton.disabled = true;
updateReplyAttachmentController.setBusy(true);
qs('#update-reply-status').textContent = 'Replying, then marking read…';
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
try {
const result = await updateReplyReadNext.submit(item, body, operationId);
const result = await updateReplyReadNext.submit(item, body, operationId, attachment);
if (result?.accepted) updateReplyAttachmentController.clear();
if (result?.accepted) qs('#my-work-action-status').textContent =
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
'Reply and read acknowledgement queued for sync.';
@@ -4494,6 +4524,7 @@
} finally {
button.disabled = false;
sendButton.disabled = false;
updateReplyAttachmentController.setBusy(false);
}
});
qs('#mark-update-read-next').addEventListener('click', async () => {
+9
View File
@@ -516,6 +516,15 @@
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<input class="visually-hidden" id="update-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="update-reply-attachment">Attach screenshot</label>
</div>
<div class="issue-attachment-preview" id="update-reply-attachment-preview" hidden>
<img id="update-reply-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="update-reply-attachment-meta"></span>
<button id="remove-update-reply-attachment" type="button">Remove screenshot</button>
</div>
<div class="update-reply-actions">
<button id="send-update-reply" type="button">Send reply</button>
<button id="send-update-reply-read-next" type="button">Reply, mark read &amp; next</button>
+18 -1
View File
@@ -404,7 +404,7 @@ function createNotificationReplier({
}
catch (_error) { /* Keep the editable textarea as the fallback. */ }
},
async submit(item, body) {
async submit(item, body, attachment = null) {
if (pending) return false;
pending = true;
this.saveDraft(item, body);
@@ -415,6 +415,23 @@ function createNotificationReplier({
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
onStatus('Sending reply…');
try {
if (attachment) {
onStatus('Saving screenshot for durable delivery…');
const admission = await authoredOutbox.enqueueDurably({
kind:'update-reply', notificationId:item.notification_id, body, operationId, attachment,
});
const delivery = await authoredOutbox.retry(admission.item.id, admission.item.ownerLogin);
if (!delivery.confirmed?.length) {
const remaining = delivery.remaining?.find(candidate => candidate.id === admission.item.id);
if (remaining?.status === 'attention') return false;
onStatus('Queued for sync when the connection returns.');
return { queued:true };
}
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
catch (_error) { /* Confirmed delivery is authoritative. */ }
onStatus('Reply posted. You can mark this update read when ready.');
return delivery.confirmed[0];
}
const result = await post(item.notification_id, body, operationId);
try { storage.removeItem(keyFor(item)); }
catch (_error) { /* The posted reply is still authoritative. */ }
+1 -1
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v86';
const CACHE = 'stackchain-dashboard-shell-v87';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
+16 -2
View File
@@ -1,5 +1,5 @@
function createUpdateReplyReadNext({
post, markRead, queue, canQueue, accept = () => undefined, next = () => undefined,
post, markRead, queue, canQueue, deliver, accept = () => undefined, next = () => undefined,
}) {
let inFlight = null;
@@ -22,11 +22,25 @@ function createUpdateReplyReadNext({
};
}
function submit(item, body, operationId) {
function submit(item, body, operationId, attachment = null) {
if (inFlight) return inFlight;
inFlight = (async () => {
let replyConfirmed = false;
try {
if (attachment) {
const admission = await queue({
kind:'update-reply-read', notificationId:item.notification_id, body,
operationId, replyConfirmed:false, attachment,
});
if (!admission?.item) throw new Error('Reply and screenshot were not saved for delivery.');
const outcome = deliver ? await deliver(admission.item) : null;
const remaining = outcome?.remaining?.find(candidate => candidate.id === admission.item.id);
if (remaining?.status === 'attention') {
throw new Error(remaining.error || 'Reply and screenshot need attention.');
}
accept(item);
return { accepted:true, delivery:outcome?.confirmed?.length ? 'posted' : 'queued', next:await next(item) };
}
try {
await post(item, body, operationId);
replyConfirmed = true;