feat: queue offline Search replies (Closes #959)
CI / lint (pull_request) Successful in 2m7s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Failing after 1m10s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-16 11:00:11 +00:00
1 parent 2e6c20d68d
commit 7fa43a9e50
9 files changed
+209 -22

No files matched your search

+15 -3
View File
@@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
function messageAttachments(message) {
const values = Array.isArray(message?.attachments) ? message.attachments :
@@ -19,6 +19,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
contentType: String(value.contentType || ''),
stored: true,
...(note ? { note } : {}),
...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}),
...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}),
};
}
@@ -28,6 +30,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
filename: String(value.filename || ''),
contentType: String(value.contentType || ''),
...(note ? { note } : {}),
...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}),
...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}),
...(value.blob ? { blob:value.blob } : { data:String(value.data || '') }),
};
}
@@ -81,6 +85,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
function enqueue(message, mirror = true) {
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
if (message.kind === 'search-reply' && !['issue', 'pull'].includes(message.targetKind)) {
throw new Error('Choose an exact Search result before queueing a reply.');
}
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
const items = read();
@@ -128,8 +135,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
...(message.kind === 'search-reply' ? { targetKind:String(message.targetKind) } : {}),
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && attachments.length ?
...(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read'].includes(message.kind) && attachments.length ?
(attachments.length === 1 ? { attachment:attachmentMetadata(attachments[0]) } :
{ attachments:attachments.map(attachmentMetadata) }) : {}),
...(message.kind === 'pull-review' ? {
@@ -164,7 +172,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const previousItems = read();
const item = enqueue(message, false);
const attachments = messageAttachments(message);
if (attachments.length && ['update-reply', 'update-reply-read'].includes(message.kind) &&
if (attachments.length && ['search-reply', '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.');
@@ -233,6 +241,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
if (item.kind === 'pull-review') {
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
}
if (item.kind === 'search-reply') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/comments?kind=' + encodeURIComponent(item.targetKind);
}
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
}
+37 -3
View File
@@ -464,6 +464,13 @@ function createBackgroundIssueSync({
item,
);
}
if (item.kind === 'search-reply') {
return authoredRequest(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/comments?kind=' + encodeURIComponent(item.targetKind),
item,
);
}
return {
url: base + 'api/v1/repos/' + repository + '/issues',
options: {
@@ -520,17 +527,20 @@ function createBackgroundIssueSync({
async function uploadConversationAttachments(item, url) {
let current = item;
const attachments = conversationAttachments(current);
const confirmedMarkdowns = attachments.map(value => String(value?.confirmed?.markdown || ''));
const firstMissing = confirmedMarkdowns.findIndex(value => !value);
const leadingConfirmed = confirmedMarkdowns.slice(0, firstMissing < 0 ? confirmedMarkdowns.length : firstMissing);
const markdowns = Array.isArray(current.attachmentMarkdowns) ?
current.attachmentMarkdowns.slice(0, attachments.length) :
(current.attachmentMarkdown ? [current.attachmentMarkdown] : []);
(current.attachmentMarkdown ? [current.attachmentMarkdown] : leadingConfirmed);
for (let index = markdowns.length; index < attachments.length; index += 1) {
const uploaded = await requestStage(current, url, {
method:'POST',
headers:{
Accept:'application/json',
'Idempotency-Key':stageOperationId(
'Idempotency-Key':String(attachments[index]?.operationId || stageOperationId(
current.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
),
)).slice(0, 128),
},
body:attachmentMultipart(attachments[index]),
});
@@ -695,6 +705,29 @@ function createBackgroundIssueSync({
);
}
async function deliverSearchReply(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
let current = item;
let attachmentMarkdown = '';
if (conversationAttachments(current).length) {
const uploaded = await uploadConversationAttachments(current,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(current.number) +
'/preview/attachments?kind=' + encodeURIComponent(current.targetKind));
current = uploaded.current;
attachmentMarkdown = uploaded.markdown;
}
const text = String(current.body || '').trim();
return requestStage(current,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(current.number) +
'/preview/comments?kind=' + encodeURIComponent(current.targetKind), {
method:'POST',
headers:{Accept:'application/json','Content-Type':'application/json',
'Idempotency-Key':stageOperationId(current.operationId, 'comment')},
body:JSON.stringify({body:attachmentMarkdown ?
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text}),
});
}
async function deliverUpdateScreenshotReply(item) {
const uploaded = await uploadConversationAttachments(item,
base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/attachments');
@@ -714,6 +747,7 @@ function createBackgroundIssueSync({
const request = deliveryRequest(item);
try {
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
item.kind === 'search-reply' ? await deliverSearchReply(item) :
item.kind === 'update-reply' && conversationAttachments(item).length ? await deliverUpdateScreenshotReply(item) :
conversationAttachments(item).length && ['issue-comment', 'pull-comment'].includes(item.kind) ?
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
+11
View File
@@ -5134,6 +5134,17 @@
{ method:'PATCH', headers:{ Accept:'application/json' } }
),
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
queueReply:async (item,body,operationId) => {
searchReplyAttachmentTarget = item;
const serialized = await searchReplyAttachmentController.serialize();
const attachments = (Array.isArray(serialized) ? serialized : [serialized]).filter(Boolean);
const admitted = await authoredOutbox.enqueueDurably({
kind:'search-reply',targetKind:item.kind,repository:item.repository,number:item.number,
body,operationId,...(attachments.length ? {attachments} : {}),
});
renderOutbox();
return { id:admitted.item.id, queued:true };
},
prepareReply:(item,body) => {
searchReplyAttachmentTarget = item;
return searchReplyAttachmentController.prepareComment(item, body);
+20 -13
View File
@@ -84,14 +84,15 @@
buttons.forEach(button => {
button.disabled = replying || (!input.value.trim() && !preview.hasReplyAttachments?.());
});
if (replying) status.textContent = 'Sending reply…';
if (replying) status.textContent = 'Saving reply for delivery…';
else if (state.status === 'queued') status.textContent = 'Reply queued. It will send when connected.';
else if (state.status === 'replied') status.textContent = 'Reply posted.';
else if (state.status === 'reply-error') status.textContent =
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
if (Array.isArray(session)) {
getSession = session[0];
loadMore = () => session[1].loadMore();
@@ -289,7 +290,9 @@
},
reply({ advance = false } = {}) {
if (replyRequest) return replyRequest;
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.'));
if (!current || (typeof postReply !== 'function' && typeof queueReply !== 'function')) {
return Promise.reject(new Error('Replying is unavailable.'));
}
const body = api.replyDraft().trim();
if (!body && !hasAttachments?.()) return Promise.reject(new Error('Write a reply or add a photo first.'));
const item = { ...current };
@@ -300,20 +303,24 @@
save(operationKey, operationId);
}
publish({ status:'replying', item:current, detail:current, conversation });
replyRequest = Promise.resolve(
typeof prepareReply === 'function' ? prepareReply(item, body) : body
).then(preparedBody => {
if (!String(preparedBody || '').trim()) throw new Error('Write a reply or add a photo first.');
return postReply(item, preparedBody, operationId);
}).then(async comment => {
replyRequest = (typeof queueReply === 'function' ?
Promise.resolve(queueReply(item, body, operationId)) :
Promise.resolve(typeof prepareReply === 'function' ? prepareReply(item, body) : body)
.then(preparedBody => {
if (!String(preparedBody || '').trim()) throw new Error('Write a reply or add a photo first.');
return postReply(item, preparedBody, operationId);
})
).then(async comment => {
await afterReply?.(item);
const comments = [...(conversation?.comments || [])];
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
if (!comment?.queued) {
const comments = [...(conversation?.comments || [])];
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
}
save(replyKey(item, 'draft'), '');
save(operationKey, '');
clearAttachments?.();
publish({ status:'replied', item:current, detail:current, conversation, result:comment });
publish({ status:comment?.queued ? 'queued' : 'replied', item:current, detail:current, conversation, result:comment });
return advance ? api.next().then(() => comment) : comment;
}).catch(error => {
publish({ status:'reply-error', item:current, detail:current, conversation, error });