feat: preserve My Work photo drafts (Closes #963)
All checks were successful
CI / lint (pull_request) Successful in 2m1s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-16 12:34:01 +00:00
parent 50f1af83f6
commit 31f2c71575
13 changed files with 432 additions and 29 deletions

View File

@ -0,0 +1,81 @@
(function (root, factory) {
const createConversationPhotoDrafts = factory();
if (typeof module === 'object' && module.exports) module.exports = createConversationPhotoDrafts;
else root.createConversationPhotoDrafts = createConversationPhotoDrafts;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function createConversationPhotoDrafts({ store, lanes }) {
const states = {};
Object.entries(lanes || {}).forEach(([kind, lane]) => {
states[kind] = { ...lane, target:null, generation:0, restoring:false, pending:Promise.resolve() };
});
function state(kind) {
const value = states[kind];
if (!value) throw new Error('Unknown conversation photo draft lane.');
return value;
}
async function checkpoint(kind) {
const current = state(kind);
if (!current.target || current.restoring) return null;
const target = { ...current.target };
const attachments = await current.controller.serialize();
const save = current.pending.then(() => store.save(target, attachments));
current.pending = save.catch(() => null);
try { return await save; }
catch (error) { current.onError?.(error); throw error; }
}
async function open(kind, target) {
const current = state(kind);
const generation = ++current.generation;
current.target = { ...target };
await current.pending;
const attachments = await store.load(target);
if (generation !== current.generation) return false;
current.controller.clear();
if (attachments?.length) {
current.restoring = true;
try { current.controller.restore(attachments); }
finally { current.restoring = false; }
}
return Boolean(attachments?.length);
}
async function leave(kind) {
const current = state(kind);
const generation = current.generation;
if (!current.target) return true;
try { await checkpoint(kind); }
catch (_error) { return false; }
if (generation === current.generation) {
current.controller.clear();
current.target = null;
current.generation += 1;
}
return true;
}
async function complete(kind) {
const current = state(kind);
if (!current.target) return false;
const target = { ...current.target };
await current.pending;
await store.remove(target);
current.controller.clear();
current.target = null;
current.generation += 1;
return true;
}
async function switchTo(kind, target, isCurrent = () => true) {
if (!await leave(kind) || !isCurrent()) return false;
return open(kind, target);
}
function hasTarget(kind) { return Boolean(state(kind).target); }
return { checkpoint, open, switchTo, leave, complete, hasTarget };
};
});

View File

@ -0,0 +1,122 @@
(function (root, factory) {
const createConversationReplyDraftStore = factory();
if (typeof module === 'object' && module.exports) module.exports = createConversationReplyDraftStore;
else root.createConversationReplyDraftStore = createConversationReplyDraftStore;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const dbName = 'stackchain-conversation-reply-drafts-v1';
const storeName = 'drafts';
function requestResult(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => reject(request.error || new Error('Conversation photo draft storage failed.'));
});
}
function createTransaction(indexedDB) {
if (!indexedDB) return null;
let databasePromise;
function database() {
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(storeName)) {
request.result.createObjectStore(storeName, { keyPath:'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Conversation photo draft storage is unavailable.'));
});
return databasePromise;
}
return async (operation, key, value) => {
const db = await database();
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
const records = transaction.objectStore(storeName);
if (operation === 'put') return requestResult(records.put(value));
if (operation === 'delete') return requestResult(records.delete(key));
return requestResult(records.get(key));
};
}
function normalizedTarget(target) {
const kind = String(target?.kind || '');
if (kind === 'update') {
const notificationId = Number(target?.notificationId || target?.notification_id || 0);
if (!Number.isInteger(notificationId) || notificationId < 1) {
throw new Error('Open an unread update before saving photo evidence.');
}
return { kind, notificationId };
}
const repository = String(target?.repository || '');
const number = Number(target?.number || 0);
if (!['issue', 'pull'].includes(kind) || !repository || !Number.isInteger(number) || number < 1) {
throw new Error('Open a conversation before saving photo evidence.');
}
return { kind, repository, number };
}
function attachment(value) {
const blob = value?.blob;
const data = String(value?.data || '');
if (!blob && !data) throw new Error('A saved conversation photo is unavailable.');
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
const operationId = String(value?.operationId || '').slice(0, 128);
const markdown = String(value?.confirmed?.markdown || '');
return {
filename:String(value?.filename || ''), contentType:String(value?.contentType || ''),
...(blob ? { blob } : { data }), ...(note ? { note } : {}),
...(operationId ? { operationId } : {}), ...(markdown ? { confirmed:{ markdown } } : {}),
};
}
return function createConversationReplyDraftStore({
indexedDB = globalThis.indexedDB, transaction, getOwnerLogin = () => '',
} = {}) {
const transact = transaction || createTransaction(indexedDB);
function identity(target) {
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before saving conversation photos.');
const normalized = normalizedTarget(target);
const targetKey = normalized.kind === 'update' ? normalized.notificationId :
normalized.repository + ':' + normalized.number;
return {
ownerLogin, target:normalized,
id:[ownerLogin, normalized.kind, targetKey].map(value => encodeURIComponent(String(value))).join(':'),
};
}
async function save(target, values) {
if (!transact) throw new Error('Photo draft storage needs IndexedDB. Your current photos are still here.');
const { id, ownerLogin, target:normalized } = identity(target);
const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment);
if (!list.length) { await transact('delete', id); return null; }
await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list });
return list;
}
async function load(target) {
if (!transact) return null;
const { id, ownerLogin, target:normalized } = identity(target);
const record = await transact('get', id);
const same = record?.version === 1 && record.ownerLogin === ownerLogin &&
record.kind === normalized.kind && (normalized.kind === 'update' ?
Number(record.notificationId) === normalized.notificationId :
record.repository === normalized.repository && Number(record.number) === normalized.number);
if (!same || !Array.isArray(record.attachments) || !record.attachments.length) return null;
return record.attachments.slice(0, 5).map(attachment);
}
async function remove(target) {
if (!transact) return false;
const { id } = identity(target);
await transact('delete', id);
return true;
}
return { save, load, remove };
};
});

View File

@ -620,6 +620,8 @@
note: qs('#issue-attachment-note'), note: qs('#issue-attachment-note'),
noteLabel: qs('#issue-attachment-note-label'), noteLabel: qs('#issue-attachment-note-label'),
status: qs('#issue-comment-status'), status: qs('#issue-comment-status'),
onChange:() => conversationPhotoDrafts.checkpoint('issue').catch(() => {}),
onCheckpoint:() => conversationPhotoDrafts.checkpoint('issue'),
editor: { editor: {
document, document,
edit: qs('#edit-issue-attachment'), edit: qs('#edit-issue-attachment'),
@ -670,6 +672,8 @@
note: qs('#pull-attachment-note'), note: qs('#pull-attachment-note'),
noteLabel: qs('#pull-attachment-note-label'), noteLabel: qs('#pull-attachment-note-label'),
status: qs('#pull-comment-status'), status: qs('#pull-comment-status'),
onChange:() => conversationPhotoDrafts.checkpoint('pull').catch(() => {}),
onCheckpoint:() => conversationPhotoDrafts.checkpoint('pull'),
editor: { editor: {
document, document,
edit: qs('#edit-pull-attachment'), edit: qs('#edit-pull-attachment'),
@ -711,6 +715,8 @@
note: qs('#update-reply-attachment-note'), note: qs('#update-reply-attachment-note'),
noteLabel: qs('#update-reply-attachment-note-label'), noteLabel: qs('#update-reply-attachment-note-label'),
status: qs('#update-reply-status'), status: qs('#update-reply-status'),
onChange:() => conversationPhotoDrafts.checkpoint('update').catch(() => {}),
onCheckpoint:() => conversationPhotoDrafts.checkpoint('update'),
readyMessage: 'Screenshot ready to send with this reply.', readyMessage: 'Screenshot ready to send with this reply.',
removedMessage: 'Screenshot removed. Your reply is unchanged.', removedMessage: 'Screenshot removed. Your reply is unchanged.',
editor: { editor: {
@ -734,6 +740,20 @@
body:issueAttachment.multipart(payload) }, body:issueAttachment.multipart(payload) },
), ),
}); });
const conversationPhotoDraftStore = createConversationReplyDraftStore({
indexedDB:window.indexedDB, getOwnerLogin:() => confirmedOwnerLogin,
});
const photoDraftLane = (controller, status) => ({ controller, onError:error => {
qs(status).textContent = error.message + ' Your photos remain here; retry before leaving.';
} });
const conversationPhotoDrafts = createConversationPhotoDrafts({
store:conversationPhotoDraftStore,
lanes:{
issue:photoDraftLane(issueAttachmentController, '#issue-comment-status'),
pull:photoDraftLane(pullAttachmentController, '#pull-comment-status'),
update:photoDraftLane(updateReplyAttachmentController, '#update-reply-status'),
},
});
const voiceTranscriptStore = createVoiceTranscriptStore(); const voiceTranscriptStore = createVoiceTranscriptStore();
function mountConversationVoice(kind, draftSelector) { function mountConversationVoice(kind, draftSelector) {
return createVoiceConversationCapture({ return createVoiceConversationCapture({
@ -1351,10 +1371,9 @@
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId), queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item), loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
onOpen: item => { onOpen: item => {
if (selectedUpdate && selectedUpdate.notification_id !== item.notification_id) {
updateReplyAttachmentController.clear();
}
selectedUpdate = item; selectedUpdate = item;
void conversationPhotoDrafts.switchTo('update',
{ kind:'update', notificationId:item.notification_id }, () => selectedUpdate === item);
void updateVoiceReply.open('update:' + item.notification_id); void updateVoiceReply.open('update:' + item.notification_id);
selectedUpdateDetail = null; selectedUpdateDetail = null;
updateReadPosition.open(String(item.notification_id)); updateReadPosition.open(String(item.notification_id));
@ -3715,6 +3734,8 @@
qs('#issue-planning').inert = false; qs('#issue-planning').inert = false;
qs('#issue-handoff').inert = false; qs('#issue-handoff').inert = false;
selectedIssue = item; selectedIssue = item;
void conversationPhotoDrafts.switchTo('issue',
{ kind:'issue', repository:item.repository, number:item.number }, () => selectedIssue === item);
void issueVoiceReply.open(conversationVoiceTarget('issue', item)); void issueVoiceReply.open(conversationVoiceTarget('issue', item));
dismissedChecklistBody = null; dismissedChecklistBody = null;
issueMentions.dismiss(); issueMentions.dismiss();
@ -3882,7 +3903,7 @@
} }
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel')); mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
issueVoiceReply.cancel(); issueVoiceReply.cancel();
issueAttachmentController.clear(); void conversationPhotoDrafts.leave('issue');
qs('#issue-sheet').classList.remove('open'); qs('#issue-sheet').classList.remove('open');
selectedIssue = null; selectedIssue = null;
selectedIssueOffline = false; selectedIssueOffline = false;
@ -4034,10 +4055,11 @@
if (!item) return; if (!item) return;
if (!await ensurePullWorkflow(trigger)) return; if (!await ensurePullWorkflow(trigger)) return;
pullDetailPosition.open(workDetailIdentity('pull', item)); pullDetailPosition.open(workDetailIdentity('pull', item));
if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear();
qs('#pull-review').inert = false; qs('#pull-review').inert = false;
qs('#pull-ownership').inert = false; qs('#pull-ownership').inert = false;
selectedPull = item; selectedPull = item;
void conversationPhotoDrafts.switchTo('pull',
{ kind:'pull', repository:item.repository, number:item.number }, () => selectedPull === item);
void pullVoiceReply.open(conversationVoiceTarget('pull', item)); void pullVoiceReply.open(conversationVoiceTarget('pull', item));
pullMentions.dismiss(); pullMentions.dismiss();
pullTrigger = trigger; pullTrigger = trigger;
@ -4104,7 +4126,7 @@
} }
mobileComposerViewport.close(qs('#pull-sheet .pull-sheet-panel')); mobileComposerViewport.close(qs('#pull-sheet .pull-sheet-panel'));
pullVoiceReply.cancel(); pullVoiceReply.cancel();
pullAttachmentController.clear(); void conversationPhotoDrafts.leave('pull');
qs('#pull-sheet').classList.remove('open'); qs('#pull-sheet').classList.remove('open');
selectedPull = null; selectedPull = null;
selectedPullDetail = null; selectedPullDetail = null;
@ -4834,7 +4856,7 @@
} }
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel')); mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
updateVoiceReply.cancel(); updateVoiceReply.cancel();
updateReplyAttachmentController.clear(); void conversationPhotoDrafts.leave('update');
qs('#update-sheet').classList.remove('open'); qs('#update-sheet').classList.remove('open');
selectedUpdate = null; selectedUpdate = null;
selectedUpdateDetail = null; selectedUpdateDetail = null;
@ -6323,7 +6345,7 @@
} }
const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item; const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item;
if (!stillOpen) return; if (!stillOpen) return;
attachmentController.clear(); await conversationPhotoDrafts.complete(kind);
if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.'; if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.';
else if (result.delivery === 'posted') status.textContent = 'Comment posted.'; else if (result.delivery === 'posted') status.textContent = 'Comment posted.';
else if (result.background) status.textContent = 'Queued for sync when the connection returns.'; else if (result.background) status.textContent = 'Queued for sync when the connection returns.';
@ -6357,7 +6379,7 @@
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation'); const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId); const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId);
refreshMyWorkView(); refreshMyWorkView();
issueAttachmentController.clear(); await conversationPhotoDrafts.complete('issue');
qs('#issue-comment-status').textContent = admission.background ? qs('#issue-comment-status').textContent = admission.background ?
'Queued with screenshot for sync when the connection returns.' : 'Queued with screenshot for sync when the connection returns.' :
'Saved with screenshot for next launch; background delivery unavailable.'; 'Saved with screenshot for next launch; background delivery unavailable.';
@ -6371,7 +6393,7 @@
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation'); const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId); const admission = await queueIssueScreenshotComment(selectedIssue, body, operationId);
refreshMyWorkView(); refreshMyWorkView();
issueAttachmentController.clear(); await conversationPhotoDrafts.complete('issue');
qs('#issue-comment-status').textContent = admission.background ? qs('#issue-comment-status').textContent = admission.background ?
'Queued with screenshot for sync when the connection returns.' : 'Queued with screenshot for sync when the connection returns.' :
'Saved with screenshot for next launch; background delivery unavailable.'; 'Saved with screenshot for next launch; background delivery unavailable.';
@ -6394,7 +6416,7 @@
else renderIssueConversation(conversation); else renderIssueConversation(conversation);
} }
qs('#issue-comment').value = ''; qs('#issue-comment').value = '';
issueAttachmentController.clear(); await conversationPhotoDrafts.complete('issue');
qs('#issue-comment-status').textContent = 'Comment posted.'; qs('#issue-comment-status').textContent = 'Comment posted.';
} catch (error) { } catch (error) {
if (canQueueMessage(error)) { if (canQueueMessage(error)) {
@ -6403,9 +6425,9 @@
const admission = await authoredOutbox.enqueueDurably({ kind:'issue-comment', repository:selectedIssue.repository, const admission = await authoredOutbox.enqueueDurably({ kind:'issue-comment', repository:selectedIssue.repository,
number:selectedIssue.number, body:preparedBody, operationId }); number:selectedIssue.number, body:preparedBody, operationId });
refreshMyWorkView(); refreshMyWorkView();
await conversationPhotoDrafts.complete('issue');
if (admission.background) { if (admission.background) {
qs('#issue-comment').value = ''; qs('#issue-comment').value = '';
issueAttachmentController.clear();
qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.'; qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.';
} else { } else {
qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.'; qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.';
@ -6664,7 +6686,7 @@
renderPullConversation(pullConversation.append(admission.delivered)); renderPullConversation(pullConversation.append(admission.delivered));
} }
qs('#pull-comment').value = ''; qs('#pull-comment').value = '';
pullAttachmentController.clear(); await conversationPhotoDrafts.complete('pull');
qs('#pull-comment-status').textContent = admission.delivered ? 'Comment posted.' : qs('#pull-comment-status').textContent = admission.delivered ? 'Comment posted.' :
(admission.background ? 'Queued with screenshot for sync when the connection returns.' : (admission.background ? 'Queued with screenshot for sync when the connection returns.' :
'Saved with screenshot for next launch; background delivery unavailable.'); 'Saved with screenshot for next launch; background delivery unavailable.');
@ -6676,7 +6698,7 @@
if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment)); if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment));
pullController.saveDraft(item, ''); pullController.saveDraft(item, '');
if (selectedPull === item) qs('#pull-comment').value = ''; if (selectedPull === item) qs('#pull-comment').value = '';
pullAttachmentController.clear(); await conversationPhotoDrafts.complete('pull');
qs('#pull-comment-status').textContent = 'Comment posted.'; qs('#pull-comment-status').textContent = 'Comment posted.';
} catch (error) { } catch (error) {
if (canQueueMessage(error) && !pullAttachmentController.state()) { if (canQueueMessage(error) && !pullAttachmentController.state()) {
@ -6781,13 +6803,13 @@
updateReplyAttachmentController.setBusy(false); updateReplyAttachmentController.setBusy(false);
if (result?.queued) { if (result?.queued) {
qs('#update-reply').value = ''; qs('#update-reply').value = '';
updateReplyAttachmentController.clear(); await conversationPhotoDrafts.complete('update');
refreshMyWorkView(); refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Reply queued for sync.'; qs('#my-work-action-status').textContent = 'Reply queued for sync.';
} else if (result) { } else if (result) {
notificationReader.appendReply(result); notificationReader.appendReply(result);
qs('#update-reply').value = ''; qs('#update-reply').value = '';
updateReplyAttachmentController.clear(); await conversationPhotoDrafts.complete('update');
qs('#mark-update-read-next').focus(); qs('#mark-update-read-next').focus();
} else { } else {
qs('#update-reply').focus(); qs('#update-reply').focus();
@ -6812,7 +6834,7 @@
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(); const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
try { try {
const result = await updateReplyReadNext.submit(item, body, operationId, attachment); const result = await updateReplyReadNext.submit(item, body, operationId, attachment);
if (result?.accepted) updateReplyAttachmentController.clear(); if (result?.accepted) await conversationPhotoDrafts.complete('update');
if (result?.accepted) qs('#my-work-action-status').textContent = if (result?.accepted) qs('#my-work-action-status').textContent =
result.delivery === 'posted' ? 'Reply posted and update marked read.' : result.delivery === 'posted' ? 'Reply posted and update marked read.' :
'Reply and read acknowledgement queued for sync.'; 'Reply and read acknowledgement queued for sync.';

View File

@ -1448,6 +1448,8 @@
<script src="static/saved-searches.js"></script> <script src="static/saved-searches.js"></script>
<script src="static/search-preview.js"></script> <script src="static/search-preview.js"></script>
<script src="static/search-reply-draft-store.js"></script> <script src="static/search-reply-draft-store.js"></script>
<script src="static/conversation-reply-draft-store.js"></script>
<script src="static/conversation-photo-drafts.js"></script>
<script src="static/search-defer.js"></script> <script src="static/search-defer.js"></script>
<script src="static/widgets.js"></script> <script src="static/widgets.js"></script>
<script src="static/drafts.js"></script> <script src="static/drafts.js"></script>

View File

@ -5,6 +5,7 @@
'stackchain-unfiled-captures-v1', 'stackchain-unfiled-captures-v1',
'stackchain-voice-transcripts-v1', 'stackchain-voice-transcripts-v1',
'stackchain-search-reply-drafts-v1', 'stackchain-search-reply-drafts-v1',
'stackchain-conversation-reply-drafts-v1',
]); ]);
if (typeof module !== 'undefined' && module.exports) module.exports = databases; if (typeof module !== 'undefined' && module.exports) module.exports = databases;
else root.stackchainPrivateDatabases = databases; else root.stackchainPrivateDatabases = databases;

View File

@ -23,6 +23,8 @@ const SHELL = [
BASE + 'static/saved-searches.js', BASE + 'static/saved-searches.js',
BASE + 'static/search-preview.js', BASE + 'static/search-preview.js',
BASE + 'static/search-reply-draft-store.js', BASE + 'static/search-reply-draft-store.js',
BASE + 'static/conversation-reply-draft-store.js',
BASE + 'static/conversation-photo-drafts.js',
BASE + 'static/search-defer.js', BASE + 'static/search-defer.js',
BASE + 'static/widgets.js', BASE + 'static/widgets.js',
BASE + 'static/drafts.js', BASE + 'static/drafts.js',

View File

@ -31,12 +31,12 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",), "security-center": ("static/security-center.js",),
"today-timer": ( "today-timer": (
"static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js",
"static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-reply-draft-store.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", "static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js", "static/search-batch-plan.js", "static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js", "static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
), ),
} }

View File

@ -0,0 +1,164 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STORE = ROOT / "frontend" / "conversation-reply-draft-store.js"
COORDINATOR = ROOT / "frontend" / "conversation-photo-drafts.js"
DASHBOARD = ROOT / "frontend" / "dashboard.js"
INDEX = ROOT / "frontend" / "index.html"
REGISTRY = ROOT / "frontend" / "private-data-registry.js"
WORKER = ROOT / "frontend" / "service-worker.js"
BUNDLE = ROOT / "src" / "frontend_bundle.py"
def run_node(script: str) -> str:
return subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
def test_store_isolates_complete_photo_bundles_by_account_and_conversation():
script = f"""
const createStore = require({json.dumps(str(STORE))});
const records = new Map();
let login = 'timmy';
const transaction = async (operation, key, value) => {{
if (operation === 'put') records.set(key, structuredClone(value));
if (operation === 'get') return records.has(key) ? structuredClone(records.get(key)) : null;
if (operation === 'delete') records.delete(key);
}};
const store = createStore({{transaction, getOwnerLogin:()=>login}});
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:963}};
const pull = {{kind:'pull', repository:'stackchain/dashboard', number:963}};
const update = {{kind:'update', notificationId:4815}};
const photos = [
{{filename:'before.webp', contentType:'image/webp', blob:new Blob(['before']), note:'before', operationId:'op-1'}},
{{filename:'after.webp', contentType:'image/webp', blob:new Blob(['after']), note:'after', operationId:'op-2', confirmed:{{markdown:'![after](saved)'}}}},
];
(async()=>{{
await store.save(issue, photos);
await store.save(pull, [photos[1]]);
await store.save(update, [photos[0]]);
const restored = await store.load(issue);
const pullDraft = await store.load(pull);
const updateDraft = await store.load(update);
login = 'alexander';
const wrongAccount = await store.load(issue);
process.stdout.write(JSON.stringify({{
restored:await Promise.all(restored.map(async photo=>({{...photo,blobText:await photo.blob.text(),blob:undefined}}))),
pullCount:pullDraft.length, updateCount:updateDraft.length, wrongAccount, recordCount:records.size,
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output == {
"restored": [
{
"filename": "before.webp",
"contentType": "image/webp",
"note": "before",
"operationId": "op-1",
"blobText": "before",
},
{
"filename": "after.webp",
"contentType": "image/webp",
"note": "after",
"operationId": "op-2",
"confirmed": {"markdown": "![after](saved)"},
"blobText": "after",
},
],
"pullCount": 1,
"updateCount": 1,
"wrongAccount": None,
"recordCount": 3,
}
def test_coordinator_ignores_stale_restores_and_clears_only_after_durable_completion():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const waits = new Map();
const removed = [];
const restored = [];
let current = [{{filename:'current.webp',contentType:'image/webp',blob:new Blob(['current'])}}];
const store = {{
load:target=>new Promise(resolve=>waits.set(target.number, resolve)),
save:async()=>null,
remove:async target=>removed.push(target.number || target.notificationId),
}};
const controller = {{
serialize:async()=>current,
restore:value=>restored.push(value[0].filename),
clear:()=>{{current=[];}},
}};
const drafts = createCoordinator({{store, lanes:{{issue:{{controller,onError:()=>{{}}}}}}}});
(async()=>{{
const first = drafts.open('issue', {{kind:'issue',repository:'stackchain/dashboard',number:1}});
const second = drafts.open('issue', {{kind:'issue',repository:'stackchain/dashboard',number:2}});
await Promise.resolve();
waits.get(1)([{{filename:'stale.webp',contentType:'image/webp',blob:new Blob(['stale'])}}]);
waits.get(2)([{{filename:'fresh.webp',contentType:'image/webp',blob:new Blob(['fresh'])}}]);
await Promise.all([first, second]);
await drafts.complete('issue');
process.stdout.write(JSON.stringify({{restored,removed,hasTarget:drafts.hasTarget('issue')}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output == {"restored": ["fresh.webp"], "removed": [2], "hasTarget": False}
def test_coordinator_checkpoints_previous_target_before_switching_conversations():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const events = [];
let current = [{{filename:'first.webp',contentType:'image/webp',blob:new Blob(['first'])}}];
const store = {{
load:async target=>target.number === 2 ? [{{filename:'second.webp',contentType:'image/webp',blob:new Blob(['second'])}}] : null,
save:async (target,attachments)=>events.push('save:' + target.number + ':' + attachments[0].filename),
remove:async()=>null,
}};
const controller = {{
serialize:async()=>current,
restore:value=>{{current=value;events.push('restore:' + value[0].filename);}},
clear:()=>{{current=[];}},
}};
const drafts = createCoordinator({{store, lanes:{{issue:{{controller}}}}}});
(async()=>{{
await drafts.open('issue', {{kind:'issue',repository:'stackchain/dashboard',number:1}});
current=[{{filename:'first.webp',contentType:'image/webp',blob:new Blob(['first'])}}];
await drafts.switchTo('issue', {{kind:'issue',repository:'stackchain/dashboard',number:2}});
process.stdout.write(JSON.stringify(events));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert json.loads(run_node(script)) == ["save:1:first.webp", "restore:second.webp"]
def test_my_work_wires_durable_photo_drafts_into_all_conversation_boundaries():
dashboard = DASHBOARD.read_text()
html = INDEX.read_text()
registry = REGISTRY.read_text()
worker = WORKER.read_text()
bundle = BUNDLE.read_text()
assert '<script src="static/conversation-reply-draft-store.js"></script>' in html
assert '<script src="static/conversation-photo-drafts.js"></script>' in html
assert "'stackchain-conversation-reply-drafts-v1'" in registry
assert "BASE + 'static/conversation-reply-draft-store.js'" in worker
assert "BASE + 'static/conversation-photo-drafts.js'" in worker
assert '"static/conversation-reply-draft-store.js"' in bundle
assert '"static/conversation-photo-drafts.js"' in bundle
assert "conversationPhotoDrafts.switchTo('issue'," in dashboard
assert "conversationPhotoDrafts.switchTo('pull'," in dashboard
assert "conversationPhotoDrafts.switchTo('update'," in dashboard
assert "conversationPhotoDrafts.leave('issue')" in dashboard
assert "conversationPhotoDrafts.leave('pull')" in dashboard
assert "conversationPhotoDrafts.leave('update')" in dashboard
assert "await conversationPhotoDrafts.complete(kind)" in dashboard
assert "await conversationPhotoDrafts.complete('issue')" in dashboard
assert "await conversationPhotoDrafts.complete('pull')" in dashboard
assert "await conversationPhotoDrafts.complete('update')" in dashboard

View File

@ -350,7 +350,7 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedDatabases"] == [ assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2", "stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
] ]
assert result["remaining"] == ["gitea.preference"] assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == [ assert result["replaced"] == [
@ -377,7 +377,7 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedDatabases"] == [ assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2", "stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
] ]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"] assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
@ -509,7 +509,7 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedDatabases"] == [ assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2", "stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
] ]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}] assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@ -533,7 +533,7 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedDatabases"] == [ assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2", "stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
] ]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}] assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@ -630,7 +630,7 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedDatabases"] == [ assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2", "stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
] ]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login" assert result["assigned"] == "/dashboard/login"

View File

@ -979,7 +979,8 @@ def test_issue_comment_actions_upload_binary_multipart_before_posting_and_clear_
assert "'/attachments'" in source assert "'/attachments'" in source
assert "issueAttachmentController.prepareComment(item, body)" in source assert "issueAttachmentController.prepareComment(item, body)" in source
assert "issueAttachmentController.prepareComment(selectedIssue, body)" in source assert "issueAttachmentController.prepareComment(selectedIssue, body)" in source
assert source.count("issueAttachmentController.clear();") >= 2 assert "await conversationPhotoDrafts.complete('issue')" in source
assert "await conversationPhotoDrafts.complete(kind)" in source
assert "'Idempotency-Key': payload.operation_id" in source assert "'Idempotency-Key': payload.operation_id" in source
assert "body: issueAttachment.multipart(payload)" in source assert "body: issueAttachment.multipart(payload)" in source
@ -991,7 +992,7 @@ def test_closing_issue_sheet_cannot_carry_a_screenshot_to_another_issue():
source, source,
re.DOTALL, re.DOTALL,
).group("body") ).group("body")
assert "issueAttachmentController.clear();" in close_body assert "conversationPhotoDrafts.leave('issue')" in close_body
def test_attachment_runtime_is_available_in_the_offline_app_shell(): def test_attachment_runtime_is_available_in_the_offline_app_shell():
@ -1034,16 +1035,17 @@ def test_assigned_pull_composer_offers_screenshot_preview_remove_and_reuses_opti
assert 'id="pull-attachment-image"' in html assert 'id="pull-attachment-image"' in html
assert 'id="remove-pull-attachment"' in html assert 'id="remove-pull-attachment"' in html
assert "const pullAttachmentController = issueAttachment.mount({" in source assert "const pullAttachmentController = issueAttachment.mount({" in source
assert "pullAttachmentController.clear();" in source assert "await conversationPhotoDrafts.complete('pull')" in source
def test_retrying_same_pull_load_preserves_screenshot_but_target_change_clears_it(): def test_retrying_same_pull_load_restores_only_the_exact_target_draft():
source = DASHBOARD.read_text() source = DASHBOARD.read_text()
open_body = source.split("async function openPullSheet(item, trigger, offlineDetail = null) {", 1)[1].split( open_body = source.split("async function openPullSheet(item, trigger, offlineDetail = null) {", 1)[1].split(
"\n function closePullSheet", 1 "\n function closePullSheet", 1
)[0] )[0]
assert "if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear();" in open_body assert "conversationPhotoDrafts.switchTo('pull'," in open_body
assert "() => selectedPull === item" in open_body
def test_pull_screenshot_comment_uploads_or_durably_admits_before_clearing_draft(): def test_pull_screenshot_comment_uploads_or_durably_admits_before_clearing_draft():

View File

@ -32,6 +32,7 @@ process.stdout.write(JSON.stringify(databases));
"stackchain-unfiled-captures-v1", "stackchain-unfiled-captures-v1",
"stackchain-voice-transcripts-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
} }

View File

@ -49,6 +49,7 @@ const clear = createPrivateDeviceDataPurger({{
"stackchain-unfiled-captures-v1", "stackchain-unfiled-captures-v1",
"stackchain-voice-transcripts-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
] ]
assert state["caches"] == ["stackchain-dashboard-shell-v37"] assert state["caches"] == ["stackchain-dashboard-shell-v37"]
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}] assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]

View File

@ -431,6 +431,7 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
"stackchain-unfiled-captures-v1", "stackchain-unfiled-captures-v1",
"stackchain-voice-transcripts-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
] ]
assert result["state"]["deleted"] == ["stackchain-dashboard-old"] assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert result["state"]["clientMessages"] == [ assert result["state"]["clientMessages"] == [
@ -525,6 +526,7 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
"stackchain-unfiled-captures-v1", "stackchain-unfiled-captures-v1",
"stackchain-voice-transcripts-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
] ]
assert result["replies"] == [{"ok": True}] assert result["replies"] == [{"ok": True}]
@ -942,6 +944,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/saved-searches.js", "/dashboard/static/saved-searches.js",
"/dashboard/static/search-preview.js", "/dashboard/static/search-preview.js",
"/dashboard/static/search-reply-draft-store.js", "/dashboard/static/search-reply-draft-store.js",
"/dashboard/static/conversation-reply-draft-store.js",
"/dashboard/static/conversation-photo-drafts.js",
"/dashboard/static/search-defer.js", "/dashboard/static/search-defer.js",
"/dashboard/static/widgets.js", "/dashboard/static/widgets.js",
"/dashboard/static/drafts.js", "/dashboard/static/drafts.js",
@ -1131,6 +1135,7 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
"stackchain-unfiled-captures-v1", "stackchain-unfiled-captures-v1",
"stackchain-voice-transcripts-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
] ]
assert result["state"]["deleted"] == ["stackchain-dashboard-old"] assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert "private cached dashboard" not in result["body"] assert "private cached dashboard" not in result["body"]