feat: preserve My Work photo drafts (Closes #963)
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 committed 2026-08-16 12:34:01 +00:00
1 parent 50f1af83f6
commit 31f2c71575
13 files changed
+432 -29

No files matched your search

+81
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 };
};
});
+122
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 };
};
});
+39 -17
View File
@@ -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.';
+2
View File
@@ -1448,6 +1448,8 @@
<script src="static/saved-searches.js"></script>
<script src="static/search-preview.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/widgets.js"></script>
<script src="static/drafts.js"></script>
+1
View File
@@ -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;
+2
View File
@@ -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',