diff --git a/frontend/conversation-photo-drafts.js b/frontend/conversation-photo-drafts.js new file mode 100644 index 0000000..d1a41aa --- /dev/null +++ b/frontend/conversation-photo-drafts.js @@ -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 }; + }; +}); diff --git a/frontend/conversation-reply-draft-store.js b/frontend/conversation-reply-draft-store.js new file mode 100644 index 0000000..0904a69 --- /dev/null +++ b/frontend/conversation-reply-draft-store.js @@ -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 }; + }; +}); diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 54f72a3..0eb92ff 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -620,6 +620,8 @@ note: qs('#issue-attachment-note'), noteLabel: qs('#issue-attachment-note-label'), status: qs('#issue-comment-status'), + onChange:() => conversationPhotoDrafts.checkpoint('issue').catch(() => {}), + onCheckpoint:() => conversationPhotoDrafts.checkpoint('issue'), editor: { document, edit: qs('#edit-issue-attachment'), @@ -670,6 +672,8 @@ note: qs('#pull-attachment-note'), noteLabel: qs('#pull-attachment-note-label'), status: qs('#pull-comment-status'), + onChange:() => conversationPhotoDrafts.checkpoint('pull').catch(() => {}), + onCheckpoint:() => conversationPhotoDrafts.checkpoint('pull'), editor: { document, edit: qs('#edit-pull-attachment'), @@ -711,6 +715,8 @@ note: qs('#update-reply-attachment-note'), noteLabel: qs('#update-reply-attachment-note-label'), status: qs('#update-reply-status'), + onChange:() => conversationPhotoDrafts.checkpoint('update').catch(() => {}), + onCheckpoint:() => conversationPhotoDrafts.checkpoint('update'), readyMessage: 'Screenshot ready to send with this reply.', removedMessage: 'Screenshot removed. Your reply is unchanged.', editor: { @@ -734,6 +740,20 @@ 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(); function mountConversationVoice(kind, draftSelector) { return createVoiceConversationCapture({ @@ -1351,10 +1371,9 @@ queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId), loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item), onOpen: item => { - if (selectedUpdate && selectedUpdate.notification_id !== item.notification_id) { - updateReplyAttachmentController.clear(); - } selectedUpdate = item; + void conversationPhotoDrafts.switchTo('update', + { kind:'update', notificationId:item.notification_id }, () => selectedUpdate === item); void updateVoiceReply.open('update:' + item.notification_id); selectedUpdateDetail = null; updateReadPosition.open(String(item.notification_id)); @@ -3715,6 +3734,8 @@ qs('#issue-planning').inert = false; qs('#issue-handoff').inert = false; selectedIssue = item; + void conversationPhotoDrafts.switchTo('issue', + { kind:'issue', repository:item.repository, number:item.number }, () => selectedIssue === item); void issueVoiceReply.open(conversationVoiceTarget('issue', item)); dismissedChecklistBody = null; issueMentions.dismiss(); @@ -3882,7 +3903,7 @@ } mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel')); issueVoiceReply.cancel(); - issueAttachmentController.clear(); + void conversationPhotoDrafts.leave('issue'); qs('#issue-sheet').classList.remove('open'); selectedIssue = null; selectedIssueOffline = false; @@ -4034,10 +4055,11 @@ if (!item) return; if (!await ensurePullWorkflow(trigger)) return; pullDetailPosition.open(workDetailIdentity('pull', item)); - if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear(); qs('#pull-review').inert = false; qs('#pull-ownership').inert = false; selectedPull = item; + void conversationPhotoDrafts.switchTo('pull', + { kind:'pull', repository:item.repository, number:item.number }, () => selectedPull === item); void pullVoiceReply.open(conversationVoiceTarget('pull', item)); pullMentions.dismiss(); pullTrigger = trigger; @@ -4104,7 +4126,7 @@ } mobileComposerViewport.close(qs('#pull-sheet .pull-sheet-panel')); pullVoiceReply.cancel(); - pullAttachmentController.clear(); + void conversationPhotoDrafts.leave('pull'); qs('#pull-sheet').classList.remove('open'); selectedPull = null; selectedPullDetail = null; @@ -4834,7 +4856,7 @@ } mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel')); updateVoiceReply.cancel(); - updateReplyAttachmentController.clear(); + void conversationPhotoDrafts.leave('update'); qs('#update-sheet').classList.remove('open'); selectedUpdate = null; selectedUpdateDetail = null; @@ -6323,7 +6345,7 @@ } const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item; if (!stillOpen) return; - attachmentController.clear(); + await conversationPhotoDrafts.complete(kind); if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.'; else if (result.delivery === 'posted') status.textContent = 'Comment posted.'; 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 admission = await queueIssueScreenshotComment(selectedIssue, body, operationId); refreshMyWorkView(); - issueAttachmentController.clear(); + await conversationPhotoDrafts.complete('issue'); qs('#issue-comment-status').textContent = admission.background ? 'Queued with screenshot for sync when the connection returns.' : '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 admission = await queueIssueScreenshotComment(selectedIssue, body, operationId); refreshMyWorkView(); - issueAttachmentController.clear(); + await conversationPhotoDrafts.complete('issue'); qs('#issue-comment-status').textContent = admission.background ? 'Queued with screenshot for sync when the connection returns.' : 'Saved with screenshot for next launch; background delivery unavailable.'; @@ -6394,7 +6416,7 @@ else renderIssueConversation(conversation); } qs('#issue-comment').value = ''; - issueAttachmentController.clear(); + await conversationPhotoDrafts.complete('issue'); qs('#issue-comment-status').textContent = 'Comment posted.'; } catch (error) { if (canQueueMessage(error)) { @@ -6403,9 +6425,9 @@ const admission = await authoredOutbox.enqueueDurably({ kind:'issue-comment', repository:selectedIssue.repository, number:selectedIssue.number, body:preparedBody, operationId }); refreshMyWorkView(); + await conversationPhotoDrafts.complete('issue'); if (admission.background) { qs('#issue-comment').value = ''; - issueAttachmentController.clear(); qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.'; } else { qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.'; @@ -6664,7 +6686,7 @@ renderPullConversation(pullConversation.append(admission.delivered)); } qs('#pull-comment').value = ''; - pullAttachmentController.clear(); + await conversationPhotoDrafts.complete('pull'); qs('#pull-comment-status').textContent = admission.delivered ? 'Comment posted.' : (admission.background ? 'Queued with screenshot for sync when the connection returns.' : 'Saved with screenshot for next launch; background delivery unavailable.'); @@ -6676,7 +6698,7 @@ if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment)); pullController.saveDraft(item, ''); if (selectedPull === item) qs('#pull-comment').value = ''; - pullAttachmentController.clear(); + await conversationPhotoDrafts.complete('pull'); qs('#pull-comment-status').textContent = 'Comment posted.'; } catch (error) { if (canQueueMessage(error) && !pullAttachmentController.state()) { @@ -6781,13 +6803,13 @@ updateReplyAttachmentController.setBusy(false); if (result?.queued) { qs('#update-reply').value = ''; - updateReplyAttachmentController.clear(); + await conversationPhotoDrafts.complete('update'); refreshMyWorkView(); qs('#my-work-action-status').textContent = 'Reply queued for sync.'; } else if (result) { notificationReader.appendReply(result); qs('#update-reply').value = ''; - updateReplyAttachmentController.clear(); + await conversationPhotoDrafts.complete('update'); qs('#mark-update-read-next').focus(); } else { qs('#update-reply').focus(); @@ -6812,7 +6834,7 @@ const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(); try { 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 = result.delivery === 'posted' ? 'Reply posted and update marked read.' : 'Reply and read acknowledgement queued for sync.'; diff --git a/frontend/index.html b/frontend/index.html index bc4461b..2e8eac5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1448,6 +1448,8 @@ + + diff --git a/frontend/private-data-registry.js b/frontend/private-data-registry.js index 5b9ae8e..542407e 100644 --- a/frontend/private-data-registry.js +++ b/frontend/private-data-registry.js @@ -5,6 +5,7 @@ 'stackchain-unfiled-captures-v1', 'stackchain-voice-transcripts-v1', 'stackchain-search-reply-drafts-v1', + 'stackchain-conversation-reply-drafts-v1', ]); if (typeof module !== 'undefined' && module.exports) module.exports = databases; else root.stackchainPrivateDatabases = databases; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 8a6fea7..9889541 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -23,6 +23,8 @@ const SHELL = [ BASE + 'static/saved-searches.js', BASE + 'static/search-preview.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/widgets.js', BASE + 'static/drafts.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 2a5f88f..a9ac6b8 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -31,12 +31,12 @@ FEATURE_SOURCES = { "security-center": ("static/security-center.js",), "today-timer": ( "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/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/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", ), } diff --git a/tests/test_conversation_photo_drafts.py b/tests/test_conversation_photo_drafts.py new file mode 100644 index 0000000..cef7ac1 --- /dev/null +++ b/tests/test_conversation_photo_drafts.py @@ -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 '' in html + assert '' 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 diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index aa7e220..dd01cf6 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -350,7 +350,7 @@ process.stdout.write(JSON.stringify(state)); assert result["deletedDatabases"] == [ "stackchain-background-outbox-v1", "stackchain-offline-work-v2", "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["replaced"] == [ @@ -377,7 +377,7 @@ process.stdout.write(JSON.stringify(state)); assert result["deletedDatabases"] == [ "stackchain-background-outbox-v1", "stackchain-offline-work-v2", "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["replaced"] == ["/dashboard/login?reason=session-expired"] @@ -509,7 +509,7 @@ process.stdout.write(JSON.stringify(state)); assert result["deletedDatabases"] == [ "stackchain-background-outbox-v1", "stackchain-offline-work-v2", "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["workerMessages"] == [{"type": "stackchain-purge-outbox"}] @@ -533,7 +533,7 @@ process.stdout.write(JSON.stringify(state)); assert result["deletedDatabases"] == [ "stackchain-background-outbox-v1", "stackchain-offline-work-v2", "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["workerMessages"] == [{"type": "stackchain-purge-outbox"}] @@ -630,7 +630,7 @@ process.stdout.write(JSON.stringify(state)); assert result["deletedDatabases"] == [ "stackchain-background-outbox-v1", "stackchain-offline-work-v2", "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["assigned"] == "/dashboard/login" diff --git a/tests/test_issue_attachment_ui.py b/tests/test_issue_attachment_ui.py index eef47f4..4d8120b 100644 --- a/tests/test_issue_attachment_ui.py +++ b/tests/test_issue_attachment_ui.py @@ -979,7 +979,8 @@ def test_issue_comment_actions_upload_binary_multipart_before_posting_and_clear_ assert "'/attachments'" in source assert "issueAttachmentController.prepareComment(item, 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 "body: issueAttachment.multipart(payload)" in source @@ -991,7 +992,7 @@ def test_closing_issue_sheet_cannot_carry_a_screenshot_to_another_issue(): source, re.DOTALL, ).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(): @@ -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="remove-pull-attachment"' in html 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() open_body = source.split("async function openPullSheet(item, trigger, offlineDetail = null) {", 1)[1].split( "\n function closePullSheet", 1 )[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(): diff --git a/tests/test_private_data_registry.py b/tests/test_private_data_registry.py index cdb30c0..b927758 100644 --- a/tests/test_private_data_registry.py +++ b/tests/test_private_data_registry.py @@ -32,6 +32,7 @@ process.stdout.write(JSON.stringify(databases)); "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", + "stackchain-conversation-reply-drafts-v1", } diff --git a/tests/test_private_device_data.py b/tests/test_private_device_data.py index 325a906..cef89cf 100644 --- a/tests/test_private_device_data.py +++ b/tests/test_private_device_data.py @@ -49,6 +49,7 @@ const clear = createPrivateDeviceDataPurger({{ "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", + "stackchain-conversation-reply-drafts-v1", ] assert state["caches"] == ["stackchain-dashboard-shell-v37"] assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}] diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 2685e0a..142e3f1 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -431,6 +431,7 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", + "stackchain-conversation-reply-drafts-v1", ] assert result["state"]["deleted"] == ["stackchain-dashboard-old"] assert result["state"]["clientMessages"] == [ @@ -525,6 +526,7 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion(): "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", + "stackchain-conversation-reply-drafts-v1", ] 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/search-preview.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/widgets.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-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", + "stackchain-conversation-reply-drafts-v1", ] assert result["state"]["deleted"] == ["stackchain-dashboard-old"] assert "private cached dashboard" not in result["body"]