feat: add offline delivery center (Closes #481)
This commit is contained in:
1 parent
b667963968
commit
9dede4bf2a
8 files changed
+177
-7
No files matched your search
@@ -166,6 +166,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const attemptAt = Number(now());
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'sending', lastAttemptAt:attemptAt,
|
||||
} : candidate), false);
|
||||
const request = (async () => {
|
||||
try {
|
||||
let result;
|
||||
@@ -209,13 +213,20 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
const permanent = status >= 400 && status < 500;
|
||||
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
|
||||
if (permanent) {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate,
|
||||
status: 'attention',
|
||||
error: String(error.message || 'Message needs attention').slice(0, 240),
|
||||
lastAttemptAt: attemptAt,
|
||||
lastAttemptError: attemptError,
|
||||
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
||||
} : candidate));
|
||||
} else {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
|
||||
} : candidate));
|
||||
}
|
||||
return { error, transient: !permanent };
|
||||
}
|
||||
|
||||
@@ -126,6 +126,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.draft-actions button { min-height:44px; width:100%; }
|
||||
.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; grid-column:1/-1; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.delivery-center h3, .delivery-center p { margin:0; }
|
||||
.delivery-center button { min-height:44px; flex:0 0 auto; }
|
||||
.delivery-attempt { color:#fbbf24; overflow-wrap:anywhere; }
|
||||
.draft-section { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; grid-column:1/-1; }
|
||||
.draft-section > h3 { grid-column:1/-1; margin:8px 0 0; }
|
||||
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
|
||||
.create-issue-actions #create-issue-status { grid-column:1/-1; }
|
||||
|
||||
+32
-4
@@ -1640,7 +1640,9 @@
|
||||
|
||||
function renderDrafts() {
|
||||
const list = qs('#my-work-list');
|
||||
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
|
||||
const deliveryCenter = draftInbox.partition(lastDrafts);
|
||||
const renderDraftCard = item => {
|
||||
const index = lastDrafts.indexOf(item);
|
||||
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
||||
const isUnfiled = item.kind === 'unfiled-issue';
|
||||
const reviewOutbox = item.outbox_kind === 'pull-review';
|
||||
@@ -1674,15 +1676,41 @@
|
||||
const state = (isOutbox || isUnfiled) ?
|
||||
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
||||
(isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' :
|
||||
item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
|
||||
item.status === 'attention' ? 'Needs attention' : item.status === 'sending' ? 'Sending' : 'Queued for sync')) + '</span>' +
|
||||
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
||||
const attempt = item.last_attempt_error ? '<span class="delivery-attempt small">Last attempt ' +
|
||||
escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '</span>' : '';
|
||||
return '<article class="my-work-card draft-card">' +
|
||||
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state +
|
||||
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt +
|
||||
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
|
||||
'<div class="draft-actions">' + outboxActions + '</div></article>';
|
||||
}).join('') : '<div class="muted">No unfinished drafts.</div>';
|
||||
};
|
||||
const deliverySummary = '<section class="delivery-center" aria-labelledby="delivery-center-title">' +
|
||||
'<div><h3 id="delivery-center-title">Delivery center</h3>' +
|
||||
'<p class="small">Waiting <strong data-delivery-count="waiting">' + deliveryCenter.counts.waiting + '</strong> · ' +
|
||||
'Sending <strong data-delivery-count="sending">' + deliveryCenter.counts.sending + '</strong> · ' +
|
||||
'Needs attention <strong data-delivery-count="attention">' + deliveryCenter.counts.attention + '</strong></p></div>' +
|
||||
'<button id="retry-waiting-deliveries" type="button"' +
|
||||
(deliveryCenter.retryable.length && activeFlushLogin ? '' : ' disabled') + '>Retry waiting</button></section>';
|
||||
const deliveryCards = deliveryCenter.deliveries.length ? deliveryCenter.deliveries.map(renderDraftCard).join('') :
|
||||
'<div class="muted">No queued deliveries.</div>';
|
||||
const draftCards = deliveryCenter.drafts.length ? deliveryCenter.drafts.map(renderDraftCard).join('') :
|
||||
'<div class="muted">No unfinished drafts.</div>';
|
||||
list.innerHTML = deliverySummary + '<section class="draft-section" aria-label="Queued deliveries">' +
|
||||
'<h3>Queued deliveries</h3>' + deliveryCards + '</section>' +
|
||||
'<section class="draft-section" aria-label="Unfinished drafts"><h3>Unfinished drafts</h3>' + draftCards + '</section>';
|
||||
qs('#retry-waiting-deliveries').addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
if (!activeFlushLogin || !deliveryCenter.retryable.length) return;
|
||||
button.disabled = true;
|
||||
qs('#my-work-action-status').textContent = 'Retrying safe waiting deliveries…';
|
||||
const [issueResult, authoredResult] = await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)]);
|
||||
applyOutboxResult(issueResult);
|
||||
applyAuthoredOutboxResult(authoredResult);
|
||||
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
|
||||
});
|
||||
list.querySelectorAll('.draft-resume').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
|
||||
+22
-3
@@ -112,7 +112,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
id: 'stackchain.issue-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
kind: 'issue-outbox',
|
||||
status: item.status === 'completion' ? 'completion' : (item.status === 'attention' ? 'attention' : 'queued'),
|
||||
status: item.status === 'completion' ? 'completion' :
|
||||
(item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : 'queued')),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'completion' ? 'Created · ready to start' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued issue')),
|
||||
@@ -125,6 +126,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
last_attempt_at: Number(item.lastAttemptAt || 0),
|
||||
last_attempt_error: textPreview(item.lastAttemptError),
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
};
|
||||
});
|
||||
@@ -157,7 +160,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
outbox_id: item.id,
|
||||
outbox_kind: item.kind,
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : 'queued'),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
||||
(isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))),
|
||||
@@ -170,6 +173,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
last_attempt_at: Number(item.lastAttemptAt || 0),
|
||||
last_attempt_error: textPreview(item.lastAttemptError),
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
|
||||
{ kind:routeKind, repository:item.repository, number:item.number },
|
||||
@@ -228,7 +233,21 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
return { list, discard };
|
||||
function partition(items = list()) {
|
||||
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
|
||||
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
|
||||
const counts = deliveries.reduce((summary, item) => {
|
||||
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' : 'waiting');
|
||||
summary[state] += 1;
|
||||
return summary;
|
||||
}, { waiting:0, sending:0, attention:0 });
|
||||
const retryable = deliveries.filter(item =>
|
||||
item.status === 'queued' && !item.quarantined && item.delivery_state !== 'uncertain'
|
||||
);
|
||||
return { drafts, deliveries, counts, retryable };
|
||||
}
|
||||
|
||||
return { list, discard, partition };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
|
||||
@@ -256,6 +256,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const attemptAt = Number(now());
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'sending', lastAttemptAt:attemptAt,
|
||||
} : candidate), false);
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
const request = (async () => {
|
||||
try {
|
||||
@@ -276,11 +280,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
return { issue, item };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
|
||||
if (status >= 400 && status < 500) {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
|
||||
lastAttemptAt: attemptAt, lastAttemptError: attemptError,
|
||||
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
||||
} : candidate));
|
||||
} else {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
|
||||
} : candidate));
|
||||
}
|
||||
return { error, transient: !(status >= 400 && status < 500) };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user