feat: queue offline Search replies (Closes #959)
This commit is contained in:
1 parent
2e6c20d68d
commit
7fa43a9e50
9 files changed
+209
-22
No files matched your search
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 ?
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user