Merge pull request 'Preserve screenshots in capture-first mobile Drafts' (#616) from timmy/615-capture-first-screenshots into main
This commit is contained in:
commit
f43f776479
17
README.md
17
README.md
|
|
@ -24,13 +24,16 @@ For online delivery, the screenshot uploads before the comment is posted—to th
|
|||
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
|
||||
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
|
||||
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
|
||||
either stage. The mobile **New issue**
|
||||
sheet accepts the same image formats and stores the screenshot with its account-bound
|
||||
outbox capture. Durable admission writes the complete screenshot capture as a binary Blob to IndexedDB
|
||||
before confirmation; localStorage keeps only bounded attachment metadata, avoiding base64
|
||||
quota pressure and synchronous multi-megabyte writes. Online and background delivery send the
|
||||
original bytes as multipart form data, avoiding the roughly 33% base64 wire expansion. Existing
|
||||
queued base64 screenshot payloads remain readable and are converted only at delivery time.
|
||||
either stage. The mobile **New issue** capture-first stage accepts the same image formats before a
|
||||
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before
|
||||
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact
|
||||
preview when the operator later chooses a repository. The source Draft remains available until its
|
||||
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob.
|
||||
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox
|
||||
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
|
||||
delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire
|
||||
expansion. Existing queued base64 screenshot payloads remain readable and are converted only at
|
||||
delivery time.
|
||||
Delivery creates the issue exactly once, then uploads and comments with
|
||||
the image; after a partial failure, retry resumes with the confirmed issue instead of
|
||||
creating a duplicate.
|
||||
|
|
|
|||
|
|
@ -55,6 +55,20 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
|||
return transact;
|
||||
}
|
||||
|
||||
function createUnfiledAttachmentStore(indexedDB = globalThis.indexedDB) {
|
||||
const transact = createIndexedDbTransaction(indexedDB, 'stackchain-unfiled-captures-v1');
|
||||
return {
|
||||
put: (id, value) => transact(records => records.put({id, ...value})),
|
||||
get: id => transact(async records => {
|
||||
const value = await records.get(id);
|
||||
if (!value) return null;
|
||||
const {id: _id, ...attachment} = value;
|
||||
return attachment;
|
||||
}),
|
||||
delete: id => transact(records => records.delete(id)),
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueSyncStore({
|
||||
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
|
||||
createToken = () => globalThis.crypto?.randomUUID?.() ||
|
||||
|
|
@ -798,4 +812,5 @@ if (typeof module !== 'undefined' && module.exports) module.exports = createBack
|
|||
if (typeof globalThis !== 'undefined') {
|
||||
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
|
||||
globalThis.createIssueSyncStore = createIssueSyncStore;
|
||||
globalThis.createUnfiledAttachmentStore = createUnfiledAttachmentStore;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -400,11 +400,14 @@
|
|||
loadMilestones: item => issueController.loadMilestones(item),
|
||||
});
|
||||
let issueCapture = null;
|
||||
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
|
||||
const unfiledCaptures = createUnfiledCaptures({
|
||||
storage: localStorage,
|
||||
attachmentStore: unfiledAttachmentStore,
|
||||
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
|
||||
getCurrentLogin: () => activeFlushLogin,
|
||||
});
|
||||
let resumedUnfiledCaptureId = '';
|
||||
let backgroundIssueSync = null;
|
||||
if ('indexedDB' in window) {
|
||||
const backgroundIssueStore = createIssueSyncStore();
|
||||
|
|
@ -1940,7 +1943,8 @@
|
|||
function listDrafts() {
|
||||
const unfiled = unfiledCaptures.list().map(item => ({
|
||||
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing',
|
||||
title:item.title, preview:item.body, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
|
||||
title:item.title, preview:item.body + (item.hasAttachment ? ' · Screenshot attached' : ''),
|
||||
hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
|
||||
updated_at:item.savedAt, quarantined:item.quarantined,
|
||||
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
|
||||
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
|
||||
|
|
@ -2107,15 +2111,16 @@
|
|||
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
|
||||
});
|
||||
list.querySelectorAll('.draft-resume').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item) return;
|
||||
if (item.kind === 'unfiled-issue') {
|
||||
try {
|
||||
const resumed = unfiledCaptures.resume(item.capture_id, activeFlushLogin);
|
||||
const resumed = await unfiledCaptures.resume(item.capture_id, activeFlushLogin);
|
||||
issueCapture.saveDraft(resumed);
|
||||
refreshMyWorkView();
|
||||
openCreateIssueSheet();
|
||||
resumedUnfiledCaptureId = item.capture_id;
|
||||
await openCreateIssueSheet();
|
||||
if (resumed.attachment) createIssueAttachmentController.restore(resumed.attachment);
|
||||
qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.';
|
||||
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
||||
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||
|
|
@ -2191,10 +2196,10 @@
|
|||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-discard').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
button.addEventListener('click', async () => {
|
||||
if (!window.confirm('Discard this unfinished draft?')) return;
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (item?.kind === 'unfiled-issue') unfiledCaptures.discard(item.capture_id);
|
||||
if (item?.kind === 'unfiled-issue') await unfiledCaptures.discard(item.capture_id);
|
||||
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
||||
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
|
||||
else if (item) draftInbox.discard(item.id);
|
||||
|
|
@ -4057,16 +4062,22 @@
|
|||
qs('#create-issue-capture-status').textContent = '';
|
||||
qs('#create-issue-repository-search').focus();
|
||||
});
|
||||
qs('#save-unfiled-issue').addEventListener('click', () => {
|
||||
const captureDraft = {
|
||||
title: qs('#create-issue-title').value.trim(),
|
||||
body: qs('#create-issue-body').value.trim(),
|
||||
};
|
||||
qs('#save-unfiled-issue').addEventListener('click', async () => {
|
||||
try {
|
||||
unfiledCaptures.save(captureDraft);
|
||||
const captureDraft = {
|
||||
title: qs('#create-issue-title').value.trim(),
|
||||
body: qs('#create-issue-body').value.trim(),
|
||||
attachment: await createIssueAttachmentController.serialize(),
|
||||
};
|
||||
await unfiledCaptures.save(captureDraft);
|
||||
if (resumedUnfiledCaptureId) {
|
||||
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
|
||||
resumedUnfiledCaptureId = '';
|
||||
}
|
||||
issueCapture.clearDraft();
|
||||
qs('#create-issue-title').value = '';
|
||||
qs('#create-issue-body').value = '';
|
||||
createIssueAttachmentController.clear();
|
||||
closeCreateIssueSheet(true, false);
|
||||
refreshMyWorkView();
|
||||
qs('#my-work-action-status').textContent = 'Saved in Drafts · choose a repository after reconnecting.';
|
||||
|
|
@ -4200,6 +4211,10 @@
|
|||
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||||
await issueOutbox.enqueueDurably(durableDraft);
|
||||
const queued = admission.item;
|
||||
if (resumedUnfiledCaptureId && (!durableDraft.attachment || admission.background)) {
|
||||
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
|
||||
resumedUnfiledCaptureId = '';
|
||||
}
|
||||
if (!admission.background) {
|
||||
editingOutboxId = queued.id;
|
||||
refreshMyWorkView();
|
||||
|
|
|
|||
|
|
@ -536,18 +536,6 @@
|
|||
<button id="save-unfiled-issue" type="button">Save to Drafts</button>
|
||||
<button id="file-new-issue" type="button">File now</button>
|
||||
</div>
|
||||
<section class="create-issue-filing" id="create-issue-filing" hidden>
|
||||
<div class="create-issue-repository-picker">
|
||||
<label for="create-issue-repository-search">Repository search
|
||||
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
|
||||
</label>
|
||||
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
|
||||
<label for="create-issue-repository">Selected repository
|
||||
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
|
||||
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
|
||||
<div class="issue-attachment-controls">
|
||||
|
|
@ -561,6 +549,18 @@
|
|||
</div>
|
||||
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
|
||||
</section>
|
||||
<section class="create-issue-filing" id="create-issue-filing" hidden>
|
||||
<div class="create-issue-repository-picker">
|
||||
<label for="create-issue-repository-search">Repository search
|
||||
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
|
||||
</label>
|
||||
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
|
||||
<label for="create-issue-repository">Selected repository
|
||||
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
|
||||
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
|
||||
<option value="">No milestone</option>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
function createUnfiledCaptures({
|
||||
storage,
|
||||
attachmentStore = null,
|
||||
getCaptureLogin = () => '',
|
||||
getCurrentLogin = () => '',
|
||||
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
|
|
@ -35,10 +36,39 @@ function createUnfiledCaptures({
|
|||
if (!title) throw new Error('Add a title before saving.');
|
||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||
const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
|
||||
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
|
||||
write(items);
|
||||
return item;
|
||||
const attachment = note?.attachment;
|
||||
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
|
||||
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
|
||||
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
|
||||
if (hasAttachment && !attachmentStore) {
|
||||
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
|
||||
}
|
||||
const item = {
|
||||
id:String(createId()), ownerLogin, title, body, savedAt:Number(now()),
|
||||
...(hasAttachment ? {hasAttachment:true} : {}),
|
||||
};
|
||||
const existing = read().filter(candidate => candidate.id !== item.id);
|
||||
const items = [item, ...existing].slice(0, maxItems);
|
||||
const pruned = existing.filter(candidate => !items.some(retained => retained.id === candidate.id));
|
||||
if (!hasAttachment) {
|
||||
write(items);
|
||||
pruned.filter(candidate => candidate.hasAttachment).forEach(candidate =>
|
||||
Promise.resolve(attachmentStore?.delete(candidate.id)).catch(() => {}));
|
||||
return item;
|
||||
}
|
||||
return Promise.resolve(attachmentStore.put(item.id, {
|
||||
filename:String(attachment.filename).slice(0, 255),
|
||||
contentType:String(attachment.contentType), blob:attachment.blob,
|
||||
})).then(() => {
|
||||
try { write(items); }
|
||||
catch (error) {
|
||||
Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
pruned.filter(candidate => candidate.hasAttachment).forEach(candidate =>
|
||||
Promise.resolve(attachmentStore.delete(candidate.id)).catch(() => {}));
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
|
|
@ -46,7 +76,9 @@ function createUnfiledCaptures({
|
|||
const remaining = items.filter(item => item.id !== id);
|
||||
if (remaining.length === items.length) return false;
|
||||
write(remaining);
|
||||
return true;
|
||||
const removed = items.find(item => item.id === id);
|
||||
if (!removed?.hasAttachment) return true;
|
||||
return Promise.resolve(attachmentStore?.delete(id)).then(() => true);
|
||||
}
|
||||
|
||||
function resume(id, confirmedLogin) {
|
||||
|
|
@ -55,11 +87,20 @@ function createUnfiledCaptures({
|
|||
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
||||
throw new Error('Reconnect with the account that saved this capture.');
|
||||
}
|
||||
discard(id);
|
||||
return {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
if (!item.hasAttachment) return draft;
|
||||
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
|
||||
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
|
||||
if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
|
||||
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
|
||||
}
|
||||
return {...draft, attachment};
|
||||
});
|
||||
}
|
||||
|
||||
return {list, save, discard, resume};
|
||||
function completeResume(id) { return discard(id); }
|
||||
|
||||
return {list, save, discard, resume, completeResume};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ FEATURE_SOURCES = {
|
|||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js",
|
||||
"static/today-rollover.js", "static/unfiled-captures.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ const second = captures.save({{title:'Second',body:'Two'}});
|
|||
let mismatch = '';
|
||||
try {{ captures.resume(first.id, 'alexander'); }} catch (error) {{ mismatch = error.message; }}
|
||||
const resumed = captures.resume(first.id, 'timmy');
|
||||
captures.completeResume(first.id);
|
||||
process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list(),second}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
|
@ -74,6 +75,52 @@ process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list()
|
|||
assert [item["id"] for item in output["remaining"]] == [output["second"]["id"]]
|
||||
|
||||
|
||||
def test_unfiled_capture_durably_restores_screenshot_before_explicit_handoff_completion():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const blobs = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const attachmentStore = {{
|
||||
put: async (id, attachment) => blobs.set(id, attachment),
|
||||
get: async id => blobs.get(id) || null,
|
||||
delete: async id => blobs.delete(id),
|
||||
}};
|
||||
(async () => {{
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
|
||||
createId:()=>'capture-1', now:()=>42,
|
||||
}});
|
||||
const screenshot = {{filename:'phone.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}};
|
||||
const saved = await captures.save({{title:'Broken mobile layout',body:'At 320px',attachment:screenshot}});
|
||||
const listed = captures.list();
|
||||
const resumed = await captures.resume(saved.id, 'timmy');
|
||||
const beforeComplete = captures.list().length;
|
||||
await captures.completeResume(saved.id);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
listed, beforeComplete, afterComplete:captures.list().length,
|
||||
resumed:{{title:resumed.title,body:resumed.body,filename:resumed.attachment.filename,
|
||||
contentType:resumed.attachment.contentType,size:resumed.attachment.blob.size}},
|
||||
blobRemoved:!blobs.has(saved.id),
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["listed"][0]["hasAttachment"] is True
|
||||
assert "attachment" not in output["listed"][0]
|
||||
assert output["beforeComplete"] == 1
|
||||
assert output["afterComplete"] == 0
|
||||
assert output["blobRemoved"] is True
|
||||
assert output["resumed"] == {
|
||||
"title": "Broken mobile layout",
|
||||
"body": "At 320px",
|
||||
"filename": "phone.png",
|
||||
"contentType": "image/png",
|
||||
"size": 5,
|
||||
}
|
||||
|
||||
|
||||
def test_unfiled_capture_rejects_empty_or_identityless_records_without_writing():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
|
|
@ -104,7 +151,12 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
|
|||
assert "createUnfiledCaptures({" in html
|
||||
assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
|
||||
assert "unfiledCaptures.save(captureDraft)" in html
|
||||
assert "unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
|
||||
assert "await unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
|
||||
assert "await createIssueAttachmentController.serialize()" in html
|
||||
assert "createUnfiledAttachmentStore()" in html
|
||||
assert "createIssueAttachmentController.restore(resumed.attachment)" in html
|
||||
assert "await unfiledCaptures.completeResume(resumedUnfiledCaptureId)" in html
|
||||
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
|
||||
assert "issueCapture.saveDraft(resumed)" in html
|
||||
assert "item.kind === 'unfiled-issue'" in html
|
||||
assert '.create-issue-actions button { min-height:44px;' in html
|
||||
|
|
@ -125,3 +177,6 @@ async def test_mobile_new_opens_capture_first_and_progressively_reveals_filing_f
|
|||
assert "qs('#file-new-issue').addEventListener('click'" in html
|
||||
assert '.create-issue-capture-actions button { min-height:44px;' in html
|
||||
assert '.create-issue-capture-actions[hidden] { display:none;' in html
|
||||
attachment = html.index('class="create-issue-attachment"')
|
||||
filing = html.index('class="create-issue-filing"')
|
||||
assert attachment < filing
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user