feat: send mobile conversation photo bundles (Closes #943)
This commit is contained in:
1 parent
843906208c
commit
c5d31c1972
19 files changed
+269
-105
No files matched your search
+31
-15
@@ -6,6 +6,28 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
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']);
|
||||
|
||||
function messageAttachments(message) {
|
||||
const values = Array.isArray(message?.attachments) ? message.attachments :
|
||||
(Array.isArray(message?.attachment) ? message.attachment : (message?.attachment ? [message.attachment] : []));
|
||||
return values.filter(Boolean).slice(0, 5);
|
||||
}
|
||||
|
||||
function attachmentMetadata(value) {
|
||||
return {
|
||||
filename: String(value.filename || ''),
|
||||
contentType: String(value.contentType || ''),
|
||||
stored: true,
|
||||
};
|
||||
}
|
||||
|
||||
function durableAttachment(value) {
|
||||
return {
|
||||
filename: String(value.filename || ''),
|
||||
contentType: String(value.contentType || ''),
|
||||
...(value.blob ? { blob:value.blob } : { data:String(value.data || '') }),
|
||||
};
|
||||
}
|
||||
|
||||
function checklistOperation(value) {
|
||||
const action = String(value?.action || '');
|
||||
if (!['rename', 'remove', 'move-earlier', 'move-later'].includes(action)) return null;
|
||||
@@ -90,6 +112,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
}
|
||||
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
|
||||
const id = String(requestedOperationId || makeId()).slice(0, 128);
|
||||
const attachments = messageAttachments(message);
|
||||
const item = {
|
||||
id,
|
||||
operationId: requestedOperationId || id,
|
||||
@@ -102,13 +125,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
|
||||
...(['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 || ''),
|
||||
stored: true,
|
||||
},
|
||||
} : {}),
|
||||
...(['issue-comment', 'pull-comment', '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' ? {
|
||||
decision: String(message.decision || 'comment'),
|
||||
expectedHeadSha: String(message.expectedHeadSha || ''),
|
||||
@@ -140,7 +159,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
async function enqueueDurably(message) {
|
||||
const previousItems = read();
|
||||
const item = enqueue(message, false);
|
||||
if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
|
||||
const attachments = messageAttachments(message);
|
||||
if (attachments.length && ['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.');
|
||||
@@ -148,14 +168,10 @@ 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 ? {
|
||||
const durableItems = read().map(candidate => candidate.id === item.id && attachments.length ? {
|
||||
...candidate,
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
contentType: String(message.attachment.contentType || ''),
|
||||
...(message.attachment.blob ? { blob: message.attachment.blob } :
|
||||
{ data: String(message.attachment.data || '') }),
|
||||
},
|
||||
...(attachments.length === 1 ? { attachment:durableAttachment(attachments[0]) } :
|
||||
{ attachments:attachments.map(durableAttachment) }),
|
||||
} : candidate);
|
||||
try {
|
||||
await backgroundSync.reconcile(durableItems, 'authored');
|
||||
|
||||
@@ -186,10 +186,15 @@ function createIssueSyncStore({
|
||||
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
|
||||
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
|
||||
? { attachment: current.attachment } : {};
|
||||
const preservedAttachments = current?.operationId === item?.operationId &&
|
||||
current?.attachments?.every(value => value?.data || value?.blob) &&
|
||||
item?.attachments?.every(value => value?.stored && !value.data && !value.blob)
|
||||
? { attachments:current.attachments } : {};
|
||||
const next = current && current.operationId === item.operationId ? {
|
||||
...item, ...preservedAttachment,
|
||||
...item, ...preservedAttachment, ...preservedAttachments,
|
||||
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
|
||||
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
|
||||
...(current.attachmentMarkdowns ? { attachmentMarkdowns:current.attachmentMarkdowns } : {}),
|
||||
} : { ...item };
|
||||
await records.put(next);
|
||||
return next;
|
||||
@@ -508,31 +513,65 @@ function createBackgroundIssueSync({
|
||||
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
|
||||
}
|
||||
|
||||
function conversationAttachments(item) {
|
||||
return (Array.isArray(item?.attachments) ? item.attachments : [item?.attachment]).filter(Boolean);
|
||||
}
|
||||
|
||||
async function uploadConversationAttachments(item, url) {
|
||||
let current = item;
|
||||
const attachments = conversationAttachments(current);
|
||||
const markdowns = Array.isArray(current.attachmentMarkdowns) ?
|
||||
current.attachmentMarkdowns.slice(0, attachments.length) :
|
||||
(current.attachmentMarkdown ? [current.attachmentMarkdown] : []);
|
||||
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(
|
||||
current.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
|
||||
),
|
||||
},
|
||||
body:attachmentMultipart(attachments[index]),
|
||||
});
|
||||
const markdown = String(uploaded?.markdown || '');
|
||||
if (!markdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
markdowns.push(markdown);
|
||||
await checkpointClaim(current, stored => ({
|
||||
...stored, attachmentMarkdowns:markdowns.slice(),
|
||||
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
||||
}));
|
||||
current = {
|
||||
...current, attachmentMarkdowns:markdowns.slice(),
|
||||
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
current,
|
||||
markdown:markdowns.map((value, index) =>
|
||||
evidenceMarkdown(attachments[index], value, index)).join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
let attachmentMarkdown = '';
|
||||
if (conversationAttachments(current).length) {
|
||||
const uploaded = await uploadConversationAttachments(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments');
|
||||
current = uploaded.current;
|
||||
attachmentMarkdown = uploaded.markdown;
|
||||
}
|
||||
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');
|
||||
if (conversationAttachments(current).length) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
|
||||
await requestStage(
|
||||
current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
|
||||
@@ -638,28 +677,9 @@ function createBackgroundIssueSync({
|
||||
async function deliverScreenshotComment(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: attachmentMultipart(item.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(item, current => ({ ...current, attachmentMarkdown }));
|
||||
}
|
||||
const uploaded = await uploadConversationAttachments(item,
|
||||
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments');
|
||||
const attachmentMarkdown = uploaded.markdown;
|
||||
const text = String(item.body || '').trim();
|
||||
return requestStage(
|
||||
item,
|
||||
@@ -676,24 +696,10 @@ 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 uploaded = await uploadConversationAttachments(item,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/attachments');
|
||||
const current = uploaded.current;
|
||||
const attachmentMarkdown = uploaded.markdown;
|
||||
const text = String(current.body || '').trim();
|
||||
return requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
|
||||
@@ -708,8 +714,8 @@ function createBackgroundIssueSync({
|
||||
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) ?
|
||||
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 ?
|
||||
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
|
||||
await deliverIssueCapture(item) : item.blockers?.length && !item.kind ?
|
||||
|
||||
@@ -495,6 +495,7 @@
|
||||
qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.';
|
||||
});
|
||||
const issueAttachmentController = issueAttachment.mount({
|
||||
maxFiles: 5,
|
||||
input: qs('#issue-attachment'),
|
||||
inputs: [qs('#take-issue-comment-photo'), qs('#issue-attachment')],
|
||||
preview: qs('#issue-attachment-preview'),
|
||||
@@ -526,6 +527,7 @@
|
||||
},
|
||||
});
|
||||
const pullAttachmentController = issueAttachment.mount({
|
||||
maxFiles: 5,
|
||||
input: qs('#pull-attachment'),
|
||||
inputs: [qs('#take-pull-comment-photo'), qs('#pull-attachment')],
|
||||
preview: qs('#pull-attachment-preview'),
|
||||
@@ -548,6 +550,7 @@
|
||||
},
|
||||
});
|
||||
const updateReplyAttachmentController = issueAttachment.mount({
|
||||
maxFiles: 5,
|
||||
input: qs('#update-reply-attachment'),
|
||||
inputs: [qs('#take-update-reply-photo'), qs('#update-reply-attachment')],
|
||||
preview: qs('#update-reply-attachment-preview'),
|
||||
|
||||
+3
-3
@@ -665,7 +665,7 @@
|
||||
<label class="issue-attachment-trigger" for="take-issue-comment-photo">Take photo</label>
|
||||
<input class="visually-hidden" id="take-issue-comment-photo" type="file" accept="image/*" capture="environment" />
|
||||
<label class="issue-attachment-trigger" for="issue-attachment">Choose existing</label>
|
||||
<input class="visually-hidden" id="issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<input class="visually-hidden" id="issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="issue-attachment-preview" hidden>
|
||||
<img id="issue-attachment-image" alt="Selected screenshot preview" />
|
||||
@@ -1056,7 +1056,7 @@
|
||||
<label class="issue-attachment-trigger" for="take-update-reply-photo">Take photo</label>
|
||||
<input class="visually-hidden" id="take-update-reply-photo" type="file" accept="image/*" capture="environment" />
|
||||
<label class="issue-attachment-trigger" for="update-reply-attachment">Choose existing</label>
|
||||
<input class="visually-hidden" id="update-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<input class="visually-hidden" id="update-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="update-reply-attachment-preview" hidden>
|
||||
<img id="update-reply-attachment-image" alt="Selected screenshot preview" />
|
||||
@@ -1128,7 +1128,7 @@
|
||||
<label class="issue-attachment-trigger" for="take-pull-comment-photo">Take photo</label>
|
||||
<input class="visually-hidden" id="take-pull-comment-photo" type="file" accept="image/*" capture="environment" />
|
||||
<label class="issue-attachment-trigger" for="pull-attachment">Choose existing</label>
|
||||
<input class="visually-hidden" id="pull-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<input class="visually-hidden" id="pull-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="pull-attachment-preview" hidden>
|
||||
<img id="pull-attachment-image" alt="Selected screenshot preview" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v110';
|
||||
const CACHE = 'stackchain-dashboard-shell-v111';
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user