feat: bind offline outboxes to Gitea identity (#238)
CI / lint (pull_request) Successful in 19s
CI / build-frontend (pull_request) Successful in 4s

This commit is contained in:
timmy committed 2026-08-07 22:57:49 +00:00
1 parent d155f0081f
commit a73b3bc773
9 files changed
+237 -63

No files matched your search

+21 -10
View File
@@ -1,4 +1,4 @@
function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 50 }) {
function createAuthoredOutbox({ storage, fetchJson, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
const storageKey = 'stackchain.authored-outbox.v1';
const makeId = createOperationId || (() =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
@@ -9,17 +9,19 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && supportedKinds.has(item.kind));
} catch (_error) { return []; }
}
function write(items) {
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
}
function enqueue(message) {
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
const items = read();
const requestedOperationId = String(message.operationId || '').slice(0, 128);
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
@@ -34,6 +36,7 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
number: Number(message.number || 0),
notificationId: Number(message.notificationId || 0),
body: String(message.body || ''),
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
};
@@ -75,7 +78,8 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
}
async function sendItem(item) {
async function sendItem(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (pending.has(item.id)) return pending.get(item.id);
const request = (async () => {
try {
@@ -108,23 +112,30 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
}
async function flush() {
async function flush(currentLogin) {
const confirmed = [];
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
for (const item of read()) {
if (item.status === 'attention') continue;
const outcome = await sendItem(item);
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
const outcome = await sendItem(item, currentLogin);
if (outcome.result) confirmed.push(outcome.result);
if (outcome.transient) break;
}
return { confirmed, remaining: read() };
return { confirmed, remaining: read(), blocked };
}
async function retry(id) {
async function retry(id, currentLogin) {
const item = read().find(candidate => candidate.id === id);
if (!item) return { confirmed: [], remaining: read() };
currentLogin = String(currentLogin || '').trim();
if (!currentLogin || item.ownerLogin !== currentLogin) {
return { confirmed: [], remaining: read(), blocked: 1 };
}
const queued = item.status === 'attention' ? update(id, item) : item;
const outcome = await sendItem(queued);
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read() };
const outcome = await sendItem(queued, currentLogin);
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 };
}
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
+21 -5
View File
@@ -1,4 +1,4 @@
function createDraftInbox({ storage, now = () => Date.now() }) {
function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Date.now() }) {
const indexKey = 'stackchain.draft-index.v1';
function readIndex() {
@@ -101,10 +101,14 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
function parseOutbox(raw) {
try {
const record = JSON.parse(raw);
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
const currentLogin = String(getCurrentLogin() || '').trim();
return record.items.filter(item =>
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
).map(item => ({
).map(item => {
const ownerLogin = String(item.ownerLogin || '').trim();
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
return {
id: 'stackchain.issue-outbox.v1:' + item.id,
outbox_id: item.id,
kind: 'issue-outbox',
@@ -113,20 +117,28 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
repository: item.repository,
title: textPreview(item.title) || 'Untitled queued issue',
preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
copy_text: [item.title, item.body].filter(Boolean).join('\n\n'),
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
updated_at: Number(item.queuedAt || 0),
}));
};
});
} catch (_error) { return []; }
}
function parseAuthoredOutbox(raw) {
try {
const record = JSON.parse(raw);
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
const currentLogin = String(getCurrentLogin() || '').trim();
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
.map(item => {
const isUpdate = item.kind === 'update-reply';
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
const ownerLogin = String(item.ownerLogin || '').trim();
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
return {
id: 'stackchain.authored-outbox.v1:' + item.id,
outbox_id: item.id,
@@ -136,6 +148,10 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
repository: isUpdate ? '' : item.repository,
title: target,
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
copy_text: item.body,
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
updated_at: Number(item.queuedAt || 0),
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
{ kind:routeKind, repository:item.repository, number:item.number },
+39 -16
View File
@@ -847,6 +847,8 @@ textarea { resize: vertical; min-height: 120px; }
let bulkMarkPending = false;
let reviewHandoffPending = false;
let editingOutboxId = null;
let confirmedOwnerLogin = '';
let activeFlushLogin = '';
async function fetchReviewJson(url, options) {
const response = await fetch(url, options);
@@ -876,8 +878,12 @@ textarea { resize: vertical; min-height: 120px; }
loadMilestones: item => issueController.loadMilestones(item),
});
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
const authoredOutbox = createAuthoredOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
const issueOutbox = createIssueOutbox({
storage: localStorage, fetchJson: fetchReviewJson, getOwnerLogin: () => confirmedOwnerLogin,
});
const authoredOutbox = createAuthoredOutbox({
storage: localStorage, fetchJson: fetchReviewJson, getOwnerLogin: () => confirmedOwnerLogin,
});
const shareParams = new URLSearchParams(location.search);
const sharedLaunch = {
title: shareParams.get('title') || '',
@@ -887,7 +893,7 @@ textarea { resize: vertical; min-height: 120px; }
let sharedLaunchState = Object.values(sharedLaunch).some(Boolean) ?
issueCapture.stageSharedContent(sharedLaunch) : null;
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const draftInbox = createDraftInbox({ storage: localStorage });
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
const findWorkController = createFindWork({
fetchJson: fetchReviewJson,
@@ -1242,6 +1248,7 @@ textarea { resize: vertical; min-height: 120px; }
function handleContextError(e) {
console.error('context failed', e);
liveMode = false;
activeFlushLogin = '';
if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;
setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable');
if (!hasContextSnapshot) {
@@ -1301,7 +1308,10 @@ textarea { resize: vertical; min-height: 120px; }
const list = qs('#my-work-list');
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
const outboxActions = item.kind === 'issue-outbox' ?
const outboxActions = item.quarantined ?
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
item.kind === 'issue-outbox' ?
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
'<button class="draft-send" data-draft-index="' + index + '" type="button">Send now</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
@@ -1312,7 +1322,9 @@ textarea { resize: vertical; min-height: 120px; }
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Resume draft</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
const state = isOutbox ?
'<span class="pill">' + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync') + '</span>' : '';
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
(item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
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>' +
@@ -1344,8 +1356,16 @@ textarea { resize: vertical; min-height: 120px; }
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.outbox_id) return;
button.disabled = true;
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id));
else applyOutboxResult(await issueOutbox.retry(item.outbox_id));
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id, activeFlushLogin));
else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin));
});
});
list.querySelectorAll('.draft-copy').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.copy_text) return;
await navigator.clipboard.writeText(item.copy_text);
qs('#my-work-action-status').textContent = 'Queued content copied without sending it.';
});
});
list.querySelectorAll('.draft-discard').forEach(button => {
@@ -2022,8 +2042,8 @@ textarea { resize: vertical; min-height: 120px; }
}
async function flushIssueOutbox() {
if (!navigator.onLine || !issueOutbox.list().length) return;
applyOutboxResult(await issueOutbox.flush());
if (!navigator.onLine || !activeFlushLogin || !issueOutbox.list().length) return;
applyOutboxResult(await issueOutbox.flush(activeFlushLogin));
}
function applyAuthoredOutboxResult(result) {
@@ -2036,8 +2056,8 @@ textarea { resize: vertical; min-height: 120px; }
}
async function flushAuthoredOutbox() {
if (!navigator.onLine || !authoredOutbox.list().length) return;
applyAuthoredOutboxResult(await authoredOutbox.flush());
if (!navigator.onLine || !activeFlushLogin || !authoredOutbox.list().length) return;
applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin));
}
function canQueueMessage(error) {
@@ -2259,6 +2279,10 @@ textarea { resize: vertical; min-height: 120px; }
}
if (snapshot.context) {
setOfflineWorkMode(false);
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
!contextFreshness?.degraded && !contextFreshness?.revalidating;
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (activeFlushLogin) confirmedOwnerLogin = activeFlushLogin;
snapshot.context.notifications = lastNotifications;
renderContextSnapshot(snapshot.context);
if (contextFreshness?.stale) markMyWorkStale();
@@ -2271,6 +2295,8 @@ textarea { resize: vertical; min-height: 120px; }
});
updateOfflineWorkControls();
}
flushIssueOutbox();
flushAuthoredOutbox();
} else handleContextError(new Error('Context section unavailable'));
if (Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
if (eventsFreshness?.revalidating) {
@@ -2669,7 +2695,7 @@ textarea { resize: vertical; min-height: 120px; }
closeCreateIssueSheet();
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Queued for sync.';
if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id), true);
if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id, activeFlushLogin), true);
} catch (error) {
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
button.disabled = false;
@@ -3222,6 +3248,7 @@ textarea { resize: vertical; min-height: 120px; }
return true;
}
function showOfflineStatus() {
activeFlushLogin = '';
offlineStatus.hidden = false;
setStatus('Offline');
if (!hasContextSnapshot) hydrateOfflineWork();
@@ -3248,8 +3275,6 @@ textarea { resize: vertical; min-height: 120px; }
if (!navigator.onLine) showOfflineStatus();
window.addEventListener('offline', showOfflineStatus);
window.addEventListener('online', reconnectLiveData);
window.addEventListener('online', flushIssueOutbox);
window.addEventListener('online', flushAuthoredOutbox);
qs('#refresh').addEventListener('click', load);
qs('#start-work-session').addEventListener('click', () => {
@@ -3334,8 +3359,6 @@ textarea { resize: vertical; min-height: 120px; }
);
}
contextPoller.start();
flushIssueOutbox();
flushAuthoredOutbox();
document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden);
});
+21 -10
View File
@@ -1,4 +1,4 @@
function createIssueOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 20 }) {
function createIssueOutbox({ storage, fetchJson, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) {
const storageKey = 'stackchain.issue-outbox.v1';
const operationId = createOperationId || (() =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
@@ -8,16 +8,18 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && typeof item === 'object');
} catch (_error) { return []; }
}
function write(items) {
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
}
function enqueue(draft) {
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
const items = read();
if (items.length >= maxItems) throw new Error('Issue outbox is full. Send or discard a queued issue first.');
const item = {
@@ -27,6 +29,7 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
title: String(draft?.title || ''),
body: String(draft?.body || ''),
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
};
@@ -64,7 +67,8 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
return true;
}
async function sendItem(item) {
async function sendItem(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (pending.has(item.id)) return pending.get(item.id);
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
const request = (async () => {
@@ -98,23 +102,30 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
}
async function flush() {
async function flush(currentLogin) {
const confirmed = [];
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
for (const item of read()) {
if (item.status === 'attention') continue;
const result = await sendItem(item);
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
const result = await sendItem(item, currentLogin);
if (result.issue) confirmed.push(result.issue);
if (result.transient) break;
}
return { confirmed, remaining: read() };
return { confirmed, remaining: read(), blocked };
}
async function retry(id) {
async function retry(id, currentLogin) {
const item = read().find(candidate => candidate.id === id);
if (!item) return { confirmed: [], remaining: read() };
currentLogin = String(currentLogin || '').trim();
if (!currentLogin || item.ownerLogin !== currentLogin) {
return { confirmed: [], remaining: read(), blocked: 1 };
}
update(id, item);
const result = await sendItem({ ...item, status: 'queued' });
return { confirmed: result.issue ? [result.issue] : [], remaining: read() };
const result = await sendItem({ ...item, status: 'queued' }, currentLogin);
return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 };
}
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'stackchain-dashboard-shell-v7';
const CACHE = 'stackchain-dashboard-shell-v8';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const BASE = new URL('./', self.location.href).pathname;
const SHELL = [