feat: add offline delivery center (Closes #481)
CI / lint (pull_request) Successful in 54s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 11:57:30 +00:00
1 parent b667963968
commit 9dede4bf2a
8 files changed
+177 -7

No files matched your search

+11
View File
@@ -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 };
}
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+10
View File
@@ -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) };
}