feat: preserve My Work photo drafts (Closes #963)
This commit is contained in:
parent
50f1af83f6
commit
31f2c71575
81
frontend/conversation-photo-drafts.js
Normal file
81
frontend/conversation-photo-drafts.js
Normal 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 };
|
||||||
|
};
|
||||||
|
});
|
||||||
122
frontend/conversation-reply-draft-store.js
Normal file
122
frontend/conversation-reply-draft-store.js
Normal 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 };
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -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.';
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
164
tests/test_conversation_photo_drafts.py
Normal file
164
tests/test_conversation_photo_drafts.py
Normal 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:''}}}},
|
||||||
|
];
|
||||||
|
(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": ""},
|
||||||
|
"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
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"}]
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user