feat: queue screenshot comments offline (Closes #477)
CI / lint (pull_request) Successful in 58s
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-10 10:56:02 +00:00
1 parent ec339d242d
commit 7ec3330175
9 files changed
+265 -20

No files matched your search

+21 -1
View File
@@ -74,6 +74,13 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
...(message.kind === 'issue-comment' && message.attachment ? {
attachment: {
filename: String(message.attachment.filename || ''),
contentType: String(message.attachment.contentType || ''),
stored: true,
},
} : {}),
...(message.kind === 'pull-review' ? {
decision: String(message.decision || 'comment'),
expectedHeadSha: String(message.expectedHeadSha || ''),
@@ -94,8 +101,21 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
const durableItems = read().map(candidate => candidate.id === item.id && message.attachment ? {
...candidate,
attachment: {
filename: String(message.attachment.filename || ''),
contentType: String(message.attachment.contentType || ''),
data: String(message.attachment.data || ''),
},
} : candidate);
try {
await backgroundSync.reconcile(durableItems, 'authored');
} catch (error) {
write(read().filter(candidate => candidate.id !== item.id), false);
throw error;
}
try {
await backgroundSync.reconcile(read(), 'authored');
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
} catch (error) {
+44 -2
View File
@@ -410,11 +410,53 @@ function createBackgroundIssueSync({
return deliveredIssue;
}
async function deliverScreenshotComment(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
let attachmentMarkdown = item.attachmentMarkdown;
if (!attachmentMarkdown) {
const uploaded = await requestJson(
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.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, attachmentMarkdown }));
}
const text = String(item.body || '').trim();
return requestJson(
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/comments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'comment'),
},
body: JSON.stringify({ body: text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
},
);
}
async function deliver(item) {
const request = deliveryRequest(item);
try {
const delivered = item.attachment && !item.kind ?
await deliverIssueCapture(item) : await requestJson(request.url, request.options);
const delivered = item.attachment && item.kind === 'issue-comment' ?
await deliverScreenshotComment(item) : 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;
+22 -12
View File
@@ -1,6 +1,20 @@
function createCommentNext({ post, queue, queueKind = '', canQueue, accept = () => undefined, complete }) {
let inFlight = null;
async function admitQueued(item, message) {
const admission = await queue(message);
if (!admission || (!admission.item && admission.durable !== true)) {
throw new Error('Comment was not saved for delivery.');
}
accept(item, { delivery: admission.background ? 'queued' : 'saved' });
return {
accepted: true,
delivery: admission.background ? 'queued' : 'saved',
background: Boolean(admission.background),
completed: Boolean(complete(item)),
};
}
function submit(item, body, operationId = '') {
if (inFlight) return inFlight;
inFlight = (async () => {
@@ -16,20 +30,10 @@ function createCommentNext({ post, queue, queueKind = '', canQueue, accept = ()
kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment',
repository: item.repository, number: item.number,
};
const admission = await queue({
return admitQueued(item, {
...identity, body,
operationId: typeof operationId === 'function' ? operationId() : operationId,
});
if (!admission || (!admission.item && admission.durable !== true)) {
throw new Error('Comment was not saved for delivery.');
}
accept(item, { delivery: admission.background ? 'queued' : 'saved' });
return {
accepted: true,
delivery: admission.background ? 'queued' : 'saved',
background: Boolean(admission.background),
completed: Boolean(complete(item)),
};
}
accept(item, { delivery: 'posted', comment });
return {
@@ -45,7 +49,13 @@ function createCommentNext({ post, queue, queueKind = '', canQueue, accept = ()
return inFlight;
}
return { submit, busy: () => Boolean(inFlight) };
function admit(item, message) {
if (inFlight) return inFlight;
inFlight = admitQueued(item, message).finally(() => { inFlight = null; });
return inFlight;
}
return { submit, admit, busy: () => Boolean(inFlight) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentNext;
+53 -3
View File
@@ -3795,6 +3795,19 @@
qs('#issue-milestone').disabled = false;
}
});
async function queueIssueScreenshotComment(item, body, operationId, advance = false) {
const message = {
kind: 'issue-comment', repository: item.repository, number: item.number, body,
operationId: operationId || globalThis.crypto?.randomUUID?.() || String(Date.now()),
attachment: await issueAttachmentController.serialize(),
};
if (advance) return await issueCommentNext.admit(item, message);
const admission = await authoredOutbox.enqueueDurably(message);
issueController.saveDraft(item, '');
if (selectedIssue === item) qs('#issue-comment').value = '';
return admission;
}
async function submitCommentAndNext(kind) {
const item = kind === 'issue' ? selectedIssue : selectedPull;
if (!item || !workSession.checkpointed(item)) return;
@@ -3816,9 +3829,20 @@
status.textContent = kind === 'issue' && issueAttachmentController.state() ?
'Uploading screenshot before opening next…' : 'Posting comment and opening next…';
try {
const preparedBody = kind === 'issue' ?
await issueAttachmentController.prepareComment(item, body) : body;
const result = await controller.submit(item, preparedBody, operationId);
let result;
if (kind === 'issue' && issueAttachmentController.state() && navigator.onLine === false) {
result = await queueIssueScreenshotComment(item, body, operationId(), true);
} else {
let preparedBody;
try {
preparedBody = kind === 'issue' ?
await issueAttachmentController.prepareComment(item, body) : body;
} catch (error) {
if (kind !== 'issue' || !issueAttachmentController.state() || !canQueueMessage(error)) throw error;
result = await queueIssueScreenshotComment(item, body, operationId(), true);
}
if (!result) result = await controller.submit(item, preparedBody, operationId);
}
const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item;
if (!stillOpen) return;
if (kind === 'issue') issueAttachmentController.clear();
@@ -3850,8 +3874,34 @@
'Uploading screenshot…' : 'Posting comment…';
let preparedBody;
try {
if (issueAttachmentController.state() && navigator.onLine === false) {
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId);
refreshMyWorkView();
issueAttachmentController.clear();
qs('#issue-comment-status').textContent = admission.background ?
'Queued with screenshot for sync when the connection returns.' :
'Saved with screenshot for next launch; background delivery unavailable.';
button.disabled = false;
return;
}
preparedBody = await issueAttachmentController.prepareComment(selectedIssue, body);
} catch (error) {
if (issueAttachmentController.state() && canQueueMessage(error)) {
try {
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId);
refreshMyWorkView();
issueAttachmentController.clear();
qs('#issue-comment-status').textContent = admission.background ?
'Queued with screenshot for sync when the connection returns.' :
'Saved with screenshot for next launch; background delivery unavailable.';
button.disabled = false;
return;
} catch (admissionError) {
error = admissionError;
}
}
qs('#issue-comment-status').textContent = error.message + ' Your comment and screenshot are safe; retry.';
qs('#issue-comment').focus();
button.disabled = false;