feat: attach screenshots during issue capture (#469)
This commit is contained in:
1 parent
11341ec5d9
commit
5bf491c725
19 files changed
+488
-51
No files matched your search
@@ -141,8 +141,13 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(current.status === 'attention' && item.status === 'attention') ||
|
||||
current.status === 'sent')) return current;
|
||||
await records.put({ ...item });
|
||||
return item;
|
||||
const next = current && current.operationId === item.operationId ? {
|
||||
...item,
|
||||
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
|
||||
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
|
||||
} : { ...item };
|
||||
await records.put(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -177,6 +182,7 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
return {
|
||||
reconcile,
|
||||
upsert,
|
||||
update,
|
||||
claim,
|
||||
claimNext,
|
||||
claimBatch,
|
||||
@@ -343,10 +349,63 @@ function createBackgroundIssueSync({
|
||||
};
|
||||
}
|
||||
|
||||
function stageOperationId(operationId, stage) {
|
||||
const suffix = ':' + stage;
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
async function deliverIssueCapture(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
let deliveredIssue = item.deliveredIssue;
|
||||
if (!deliveredIssue) {
|
||||
const request = deliveryRequest(item);
|
||||
deliveredIssue = await requestJson(request.url, request.options);
|
||||
await store.update?.(item.id, current => ({ ...current, deliveredIssue }));
|
||||
}
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestJson(
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.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, deliveredIssue, attachmentMarkdown }));
|
||||
}
|
||||
await requestJson(
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
|
||||
},
|
||||
body: JSON.stringify({ body: attachmentMarkdown }),
|
||||
},
|
||||
);
|
||||
return deliveredIssue;
|
||||
}
|
||||
|
||||
async function deliver(item) {
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = await requestJson(request.url, request.options);
|
||||
const delivered = 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;
|
||||
|
||||
@@ -337,10 +337,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
|
||||
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.create-issue-sheet.open { display:flex; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.create-issue-header button, .create-issue-actions button { min-height:44px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
|
||||
.create-issue-repository-more { min-height:44px; width:100%; }
|
||||
.create-issue-form label { display:grid; gap:6px; }
|
||||
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
|
||||
+27
-1
@@ -303,6 +303,25 @@
|
||||
);
|
||||
},
|
||||
});
|
||||
const createIssueAttachmentController = issueAttachment.mount({
|
||||
input: qs('#create-issue-attachment'),
|
||||
preview: qs('#create-issue-attachment-preview'),
|
||||
image: qs('#create-issue-attachment-image'),
|
||||
meta: qs('#create-issue-attachment-meta'),
|
||||
remove: qs('#remove-create-issue-attachment'),
|
||||
status: qs('#create-issue-attachment-status'),
|
||||
readyMessage: 'Screenshot ready to file with this issue.',
|
||||
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',
|
||||
createObjectURL: file => URL.createObjectURL(file),
|
||||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||||
readDataUrl: file => new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = () => reject(new Error('The screenshot could not be read. Choose it again.'));
|
||||
reader.readAsDataURL(file);
|
||||
}),
|
||||
upload: async () => { throw new Error('Create the issue before uploading its screenshot.'); },
|
||||
});
|
||||
const planningLoader = createIssueSheet.createPlanningLoader({
|
||||
loadLabels: item => issueController.loadLabels(item),
|
||||
loadMilestones: item => issueController.loadMilestones(item),
|
||||
@@ -1694,6 +1713,8 @@
|
||||
editingOutboxId = queued.id;
|
||||
issueCapture.saveDraft(queued);
|
||||
openCreateIssueSheet();
|
||||
if (queued.attachment) createIssueAttachmentController.restore(queued.attachment);
|
||||
else createIssueAttachmentController.clear();
|
||||
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
|
||||
});
|
||||
});
|
||||
@@ -3542,7 +3563,11 @@
|
||||
qs('#create-issue-status').textContent = createAndStartRequested ?
|
||||
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
|
||||
try {
|
||||
const durableDraft = createAndStartRequested ? { ...captureDraft, completionIntent: 'create-and-start' } : captureDraft;
|
||||
const durableDraft = {
|
||||
...captureDraft,
|
||||
attachment: await createIssueAttachmentController.serialize(),
|
||||
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
|
||||
};
|
||||
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||||
await issueOutbox.enqueueDurably(durableDraft);
|
||||
const queued = admission.item;
|
||||
@@ -3556,6 +3581,7 @@
|
||||
}
|
||||
editingOutboxId = null;
|
||||
issueCapture.clearDraft();
|
||||
createIssueAttachmentController.clear();
|
||||
suppressCreateDraftOnHistoryClose = true;
|
||||
taskOverlayHistory.leave();
|
||||
refreshMyWorkView();
|
||||
|
||||
@@ -418,6 +418,19 @@
|
||||
<label for="create-issue-body">Description <span class="small">Optional</span>
|
||||
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
||||
</label>
|
||||
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
|
||||
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
|
||||
<div class="issue-attachment-controls">
|
||||
<label class="issue-attachment-trigger" for="create-issue-attachment">Attach screenshot</label>
|
||||
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
|
||||
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="create-issue-attachment-meta"></span>
|
||||
<button id="remove-create-issue-attachment" type="button">Remove</button>
|
||||
</div>
|
||||
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
|
||||
</section>
|
||||
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
|
||||
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
|
||||
<option value="">No milestone</option>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
const upload = options.upload;
|
||||
let selected = null;
|
||||
let confirmed = null;
|
||||
let serialized = null;
|
||||
|
||||
function select(file) {
|
||||
if (!file || !IMAGE_TYPES.has(file.type)) {
|
||||
@@ -23,12 +24,25 @@
|
||||
}
|
||||
selected = file;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
return state();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selected = null;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
}
|
||||
|
||||
function restore(value) {
|
||||
const contentType = String(value?.contentType || '');
|
||||
const filename = String(value?.filename || '');
|
||||
const data = String(value?.data || '');
|
||||
const padding = (data.match(/=*$/) || [''])[0].length;
|
||||
const size = Math.max(1, Math.floor(data.length * 3 / 4) - padding);
|
||||
select({ name: filename, type: contentType, size });
|
||||
serialized = { filename, contentType, data };
|
||||
return state();
|
||||
}
|
||||
|
||||
function state() {
|
||||
@@ -39,20 +53,33 @@
|
||||
} : null;
|
||||
}
|
||||
|
||||
async function prepareComment(item, body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!selected) return text;
|
||||
if (!confirmed) {
|
||||
async function serialize() {
|
||||
if (!selected) return null;
|
||||
if (!serialized) {
|
||||
const dataUrl = await readDataUrl(selected);
|
||||
const marker = ';base64,';
|
||||
const markerAt = String(dataUrl).indexOf(marker);
|
||||
if (markerAt < 0) throw new Error('The screenshot could not be read. Choose it again.');
|
||||
serialized = {
|
||||
filename: selected.name,
|
||||
contentType: selected.type,
|
||||
data: String(dataUrl).slice(markerAt + marker.length),
|
||||
};
|
||||
}
|
||||
return { ...serialized };
|
||||
}
|
||||
|
||||
async function prepareComment(item, body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!selected) return text;
|
||||
if (!confirmed) {
|
||||
const attachment = await serialize();
|
||||
confirmed = await upload({
|
||||
repository: item.repository,
|
||||
number: item.number,
|
||||
filename: selected.name,
|
||||
content_type: selected.type,
|
||||
data: String(dataUrl).slice(markerAt + marker.length),
|
||||
filename: attachment.filename,
|
||||
content_type: attachment.contentType,
|
||||
data: attachment.data,
|
||||
});
|
||||
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
|
||||
confirmed = null;
|
||||
@@ -62,7 +89,7 @@
|
||||
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
|
||||
}
|
||||
|
||||
return { select, clear, state, prepareComment };
|
||||
return { select, restore, clear, state, serialize, prepareComment };
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
@@ -93,14 +120,25 @@
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
options.status.textContent = 'Screenshot ready to upload with this comment.';
|
||||
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
|
||||
});
|
||||
options.remove.addEventListener('click', () => {
|
||||
clearPreview();
|
||||
options.status.textContent = 'Screenshot removed. Your comment is unchanged.';
|
||||
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
|
||||
});
|
||||
|
||||
return Object.assign(controller, { clear: clearPreview });
|
||||
function restorePreview(value) {
|
||||
clearPreview();
|
||||
const restored = controller.restore(value);
|
||||
previewUrl = 'data:' + value.contentType + ';base64,' + value.data;
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
|
||||
return restored;
|
||||
}
|
||||
|
||||
return Object.assign(controller, { clear: clearPreview, restore: restorePreview });
|
||||
}
|
||||
|
||||
return { create, mount, MAX_BYTES };
|
||||
|
||||
+90
-16
@@ -5,6 +5,14 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
);
|
||||
const pending = new Map();
|
||||
|
||||
function captureAttachment(value) {
|
||||
const contentType = String(value?.contentType || '');
|
||||
const filename = String(value?.filename || '').slice(0, 255);
|
||||
const data = String(value?.data || '');
|
||||
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType) || !data) return undefined;
|
||||
return { filename, contentType, data };
|
||||
}
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
@@ -40,6 +48,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
||||
const attachment = captureAttachment(draft?.attachment);
|
||||
if (attachment) item.attachment = attachment;
|
||||
item.operationId = item.id;
|
||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||
item.milestoneId = Number(draft.milestoneId);
|
||||
@@ -76,14 +86,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
? Number(draft.milestoneId) : undefined;
|
||||
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
||||
? String(draft.dueDate) : undefined;
|
||||
const nextAttachment = captureAttachment(draft?.attachment);
|
||||
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
|
||||
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate;
|
||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|
||||
|| attachmentChanged;
|
||||
updated = {
|
||||
...item,
|
||||
repository: nextRepository, title: nextTitle,
|
||||
body: nextBody, labelIds: nextLabelIds,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
|
||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||
status: 'queued',
|
||||
};
|
||||
@@ -91,6 +104,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
else delete updated.completionIntent;
|
||||
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||
if (nextDueDate === undefined) delete updated.dueDate;
|
||||
if (nextAttachment === undefined) delete updated.attachment;
|
||||
if (attachmentChanged) delete updated.attachmentMarkdown;
|
||||
delete updated.error;
|
||||
delete updated.deliveryState;
|
||||
return updated;
|
||||
@@ -119,6 +134,72 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
return true;
|
||||
}
|
||||
|
||||
function persistDeliveryStage(id, stage) {
|
||||
write(read().map(item => item.id === id ? { ...item, ...stage } : item));
|
||||
}
|
||||
|
||||
function stageOperationId(operationId, stage) {
|
||||
const suffix = ':' + stage;
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
async function sendDirect(item, repository) {
|
||||
let issue = item.deliveredIssue;
|
||||
if (!issue) {
|
||||
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title, body: item.body, label_ids: item.labelIds,
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
});
|
||||
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
|
||||
}
|
||||
if (!item.attachment) return issue;
|
||||
let markdown = item.attachmentMarkdown;
|
||||
if (!markdown) {
|
||||
const uploaded = await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.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,
|
||||
}),
|
||||
},
|
||||
);
|
||||
markdown = String(uploaded?.markdown || '');
|
||||
if (!markdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
|
||||
}
|
||||
await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
|
||||
},
|
||||
body: JSON.stringify({ body: markdown }),
|
||||
},
|
||||
);
|
||||
return issue;
|
||||
}
|
||||
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
@@ -135,18 +216,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
issue = delivery.issue;
|
||||
} else {
|
||||
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title, body: item.body, label_ids: item.labelIds,
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
});
|
||||
issue = await sendDirect(item, repository);
|
||||
}
|
||||
if (!issue) return { blocked: true };
|
||||
discard(item.id);
|
||||
@@ -237,6 +307,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
const background = statuses.get(item.id);
|
||||
const deliveryStage = {
|
||||
...(background?.deliveredIssue ? { deliveredIssue: background.deliveredIssue } : {}),
|
||||
...(background?.attachmentMarkdown ? { attachmentMarkdown: background.attachmentMarkdown } : {}),
|
||||
};
|
||||
if (background?.status === 'sent') {
|
||||
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
|
||||
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
|
||||
@@ -244,10 +318,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
return [];
|
||||
}
|
||||
if (background?.status === 'attention') return [{
|
||||
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||
...item, ...deliveryStage, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
return [item];
|
||||
return [{ ...item, ...deliveryStage }];
|
||||
});
|
||||
write(items);
|
||||
return items;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v80';
|
||||
const CACHE = 'stackchain-dashboard-shell-v81';
|
||||
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