feat: surface photo-only reply drafts in My Work (Closes #1396)
This commit is contained in:
1 parent
306d6e3056
commit
fa876e1e38
13 files changed
+474
-12
No files matched your search
@@ -5,7 +5,7 @@
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
return function createConversationPhotoDrafts({ store, lanes }) {
|
||||
return function createConversationPhotoDrafts({ store, lanes, onChange = () => {} }) {
|
||||
const states = {};
|
||||
Object.entries(lanes || {}).forEach(([kind, lane]) => {
|
||||
states[kind] = { ...lane, target:null, generation:0, restoring:false, pending:Promise.resolve() };
|
||||
@@ -24,7 +24,11 @@
|
||||
const attachments = await current.controller.serialize();
|
||||
const save = current.pending.then(() => store.save(target, attachments));
|
||||
current.pending = save.catch(() => null);
|
||||
try { return await save; }
|
||||
try {
|
||||
const saved = await save;
|
||||
onChange();
|
||||
return saved;
|
||||
}
|
||||
catch (error) { current.onError?.(error); throw error; }
|
||||
}
|
||||
|
||||
@@ -67,6 +71,7 @@
|
||||
const target = { ...current.target };
|
||||
await current.pending;
|
||||
await store.remove(target);
|
||||
onChange();
|
||||
current.restoring = true;
|
||||
try { current.controller.clear(); }
|
||||
finally {
|
||||
|
||||
@@ -33,10 +33,11 @@
|
||||
}
|
||||
return async (operation, key, value) => {
|
||||
const db = await database();
|
||||
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
|
||||
const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite');
|
||||
const records = transaction.objectStore(storeName);
|
||||
if (operation === 'put') return requestResult(records.put(value));
|
||||
if (operation === 'delete') return requestResult(records.delete(key));
|
||||
if (operation === 'list') return requestResult(records.getAll());
|
||||
return requestResult(records.get(key));
|
||||
};
|
||||
}
|
||||
@@ -97,7 +98,10 @@
|
||||
const { id, ownerLogin, scope:recordScope, 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, scope:recordScope, ...normalized, attachments:list });
|
||||
await transact('put', id, {
|
||||
id, version:1, ownerLogin, scope:recordScope, ...normalized,
|
||||
updatedAt:Date.now(), attachments:list,
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -122,6 +126,38 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
return { save, load, remove };
|
||||
async function list() {
|
||||
if (!transact || draftScope !== 'conversation') return [];
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) return [];
|
||||
const records = await transact('list');
|
||||
return (Array.isArray(records) ? records : []).flatMap(record => {
|
||||
if (record?.version !== 1 || record.ownerLogin !== ownerLogin ||
|
||||
(record.scope && record.scope !== 'conversation') ||
|
||||
!Array.isArray(record.attachments) || !record.attachments.length) return [];
|
||||
const updatedAt = Number(record.updatedAt || 0);
|
||||
if (record.kind === 'update') {
|
||||
const notificationId = Number(record.notificationId || 0);
|
||||
if (!Number.isInteger(notificationId) || notificationId < 1) return [];
|
||||
return [{
|
||||
id:record.id, kind:'photo-reply', label:'Update photos', title:'Update #' + notificationId,
|
||||
photo_count:record.attachments.length, updated_at:updatedAt,
|
||||
route:{ kind:'update', notification_id:notificationId }, photo_store:'conversation',
|
||||
}];
|
||||
}
|
||||
const repository = String(record.repository || '');
|
||||
const number = Number(record.number || 0);
|
||||
if (!['issue', 'pull'].includes(record.kind) || !repository ||
|
||||
!Number.isInteger(number) || number < 1) return [];
|
||||
const label = record.kind === 'issue' ? 'Issue photos' : 'PR photos';
|
||||
return [{
|
||||
id:record.id, kind:'photo-reply', label, repository, number,
|
||||
title:repository + '#' + number, photo_count:record.attachments.length, updated_at:updatedAt,
|
||||
route:{ kind:record.kind, repository, number }, photo_store:'conversation',
|
||||
}];
|
||||
}).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
return { save, load, remove, list };
|
||||
};
|
||||
});
|
||||
+18
-2
@@ -777,6 +777,7 @@
|
||||
try {
|
||||
const attachments = await searchReplyAttachmentController.serialize();
|
||||
await searchReplyDraftStore.save(target, attachments);
|
||||
void refreshPhotoDraftInbox();
|
||||
} catch (error) {
|
||||
qs('#search-preview-reply-status').textContent = error?.message ||
|
||||
'Photos could not be saved. They remain in this preview; retry before leaving.';
|
||||
@@ -1002,6 +1003,7 @@
|
||||
} });
|
||||
const conversationPhotoDrafts = createConversationPhotoDrafts({
|
||||
store:conversationPhotoDraftStore,
|
||||
onChange:() => { void refreshPhotoDraftInbox(); },
|
||||
lanes:{
|
||||
issue:photoDraftLane(issueAttachmentController, '#issue-comment-status'),
|
||||
pull:photoDraftLane(pullAttachmentController, '#pull-comment-status'),
|
||||
@@ -1310,6 +1312,10 @@
|
||||
ensurePullWorkflow().then(() => rR.restore()).catch(() => {});
|
||||
}
|
||||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||
const photoDraftInbox = createPhotoDraftInbox({
|
||||
conversation:conversationPhotoDraftStore,
|
||||
search:searchReplyDraftStore,
|
||||
});
|
||||
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
||||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
|
||||
let offlineStorageReady = await offlineWorkStore.ready();
|
||||
@@ -3401,11 +3407,16 @@
|
||||
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
|
||||
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
|
||||
}));
|
||||
return draftInbox.list().concat(unfiled).sort((left, right) =>
|
||||
return draftInbox.list().concat(photoDraftInbox.list(), unfiled).sort((left, right) =>
|
||||
Number(right.updated_at || 0) - Number(left.updated_at || 0)
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshPhotoDraftInbox() {
|
||||
try { await photoDraftInbox.refresh(draftInbox.list()); refreshMyWorkView({ reconcileSession:false }); }
|
||||
catch (_error) { /* Local photo inventory must not break My Work. */ }
|
||||
}
|
||||
|
||||
function refreshMyWorkView({ reconcileSession = true } = {}) {
|
||||
lastDrafts = listDrafts();
|
||||
const actionableMyWork = filedHistoryTabs.prepare(lastMyWork);
|
||||
@@ -3579,7 +3590,7 @@
|
||||
return '<article class="my-work-card draft-card"' + captureTarget + '>' +
|
||||
'<span class="small">' + escapeHtml([item.label, item.repository, item.details].filter(Boolean).join(' · ')) + '</span>' +
|
||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt +
|
||||
'<span class="draft-preview">' + escapeHtml(photoDraftInbox.description(item)) + '</span>' + state + attempt +
|
||||
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
|
||||
'<div class="draft-actions">' + outboxActions + '</div></article>';
|
||||
};
|
||||
@@ -3618,6 +3629,9 @@
|
||||
await dFS.open(item.capture_id, item.ready ? button : null);
|
||||
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
||||
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||
else if (item.kind === 'photo-reply' && item.route?.kind === 'search') {
|
||||
taskOverlayHistory.open('search-preview', { preview:photoDraftInbox.searchTarget(item) });
|
||||
}
|
||||
else if (item.route) workRoute.open(item.route);
|
||||
};
|
||||
});
|
||||
@@ -3736,6 +3750,7 @@
|
||||
}
|
||||
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?.kind === 'photo-reply') await photoDraftInbox.discard(item);
|
||||
else if (item) draftInbox.discard(item.id);
|
||||
lastDrafts = listDrafts();
|
||||
const count = qs('[data-work-count="draft"]');
|
||||
@@ -5493,6 +5508,7 @@
|
||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (activeFlushLogin) {
|
||||
confirmedOwnerLogin = activeFlushLogin;
|
||||
void refreshPhotoDraftInbox();
|
||||
timerView.restore(todaySync.flush());
|
||||
restoreReleaseReceipt();
|
||||
updateDeliveryReceiptControls();
|
||||
|
||||
@@ -2264,6 +2264,7 @@
|
||||
<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/photo-draft-inbox.js"></script>
|
||||
<script src="static/search-defer.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
(function (root, factory) {
|
||||
const createPhotoDraftInbox = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createPhotoDraftInbox;
|
||||
else root.createPhotoDraftInbox = createPhotoDraftInbox;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
function targetKey(route) {
|
||||
if (route?.kind === 'update') {
|
||||
const notificationId = Number(route.notification_id || 0);
|
||||
return notificationId > 0 ? 'update:' + notificationId : '';
|
||||
}
|
||||
const kind = route?.kind === 'search' ? route.target_kind : route?.kind;
|
||||
const repository = String(route?.repository || '');
|
||||
const number = Number(route?.number || 0);
|
||||
return ['issue', 'pull'].includes(kind) && repository && number > 0 ?
|
||||
kind + ':' + repository.toLowerCase() + '#' + number : '';
|
||||
}
|
||||
|
||||
function removeTarget(item) {
|
||||
const route = item?.route || {};
|
||||
if (route.kind === 'update') return { kind:'update', notificationId:Number(route.notification_id) };
|
||||
return {
|
||||
kind:route.kind === 'search' ? route.target_kind : route.kind,
|
||||
repository:route.repository,
|
||||
number:Number(route.number),
|
||||
};
|
||||
}
|
||||
|
||||
return function createPhotoDraftInbox({ conversation, search }) {
|
||||
let items = [];
|
||||
|
||||
async function refresh(existing = []) {
|
||||
const occupied = new Set(existing.map(item => targetKey(item?.route)).filter(Boolean));
|
||||
const results = await Promise.allSettled([
|
||||
conversation?.list?.() || [],
|
||||
search?.list?.() || [],
|
||||
]);
|
||||
items = results.flatMap(result => result.status === 'fulfilled' && Array.isArray(result.value) ? result.value : [])
|
||||
.filter(item => {
|
||||
const key = targetKey(item?.route);
|
||||
if (!key || occupied.has(key)) return false;
|
||||
occupied.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort((left, right) => Number(right.updated_at || 0) - Number(left.updated_at || 0) ||
|
||||
String(left.id || '').localeCompare(String(right.id || '')));
|
||||
return items.slice();
|
||||
}
|
||||
|
||||
function list() { return items.slice(); }
|
||||
|
||||
async function discard(item) {
|
||||
if (!item || !items.some(candidate => candidate.id === item.id && candidate.photo_store === item.photo_store)) {
|
||||
return false;
|
||||
}
|
||||
const store = item.photo_store === 'search' ? search : conversation;
|
||||
if (!store?.remove) return false;
|
||||
await store.remove(removeTarget(item));
|
||||
items = items.filter(candidate => !(candidate.id === item.id && candidate.photo_store === item.photo_store));
|
||||
return true;
|
||||
}
|
||||
|
||||
function description(item) {
|
||||
return item?.kind === 'photo-reply' ?
|
||||
Number(item.photo_count || 0) + (Number(item.photo_count) === 1 ? ' saved photo' : ' saved photos') :
|
||||
(item?.preview || 'Unfinished draft');
|
||||
}
|
||||
|
||||
function searchTarget(item) {
|
||||
const route = item?.route || {};
|
||||
return {
|
||||
kind:route.target_kind, repository:route.repository,
|
||||
number:route.number, title:item?.title,
|
||||
};
|
||||
}
|
||||
|
||||
return { refresh, list, discard, description, searchTarget };
|
||||
};
|
||||
});
|
||||
@@ -33,10 +33,11 @@
|
||||
}
|
||||
return async (operation, key, value) => {
|
||||
const db = await database();
|
||||
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
|
||||
const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite');
|
||||
const records = transaction.objectStore(storeName);
|
||||
if (operation === 'put') return requestResult(records.put(value));
|
||||
if (operation === 'delete') return requestResult(records.delete(key));
|
||||
if (operation === 'list') return requestResult(records.getAll());
|
||||
return requestResult(records.get(key));
|
||||
};
|
||||
}
|
||||
@@ -95,7 +96,9 @@
|
||||
await transact('delete', id);
|
||||
return null;
|
||||
}
|
||||
await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list });
|
||||
await transact('put', id, {
|
||||
id, version:1, ownerLogin, ...normalized, updatedAt:Date.now(), attachments:list,
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -116,6 +119,28 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
return { save, load, remove };
|
||||
async function list() {
|
||||
if (!transact) return [];
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) return [];
|
||||
const records = await transact('list');
|
||||
return (Array.isArray(records) ? records : []).flatMap(record => {
|
||||
const repository = String(record?.repository || '');
|
||||
const number = Number(record?.number || 0);
|
||||
if (record?.version !== 1 || record.ownerLogin !== ownerLogin ||
|
||||
!['issue', 'pull'].includes(record.kind) || !repository ||
|
||||
!Number.isInteger(number) || number < 1 ||
|
||||
!Array.isArray(record.attachments) || !record.attachments.length) return [];
|
||||
return [{
|
||||
id:record.id, kind:'photo-reply',
|
||||
label:'Search ' + (record.kind === 'pull' ? 'PR' : 'issue') + ' photos',
|
||||
repository, number, title:repository + '#' + number,
|
||||
photo_count:record.attachments.length, updated_at:Number(record.updatedAt || 0),
|
||||
route:{ kind:'search', target_kind:record.kind, repository, number }, photo_store:'search',
|
||||
}];
|
||||
}).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
return { save, load, remove, list };
|
||||
};
|
||||
});
|
||||
@@ -167,6 +167,7 @@ const SHELL = [
|
||||
BASE + 'static/search-reply-draft-store.js',
|
||||
BASE + 'static/conversation-reply-draft-store.js',
|
||||
BASE + 'static/conversation-photo-drafts.js',
|
||||
BASE + 'static/photo-draft-inbox.js',
|
||||
BASE + 'static/search-defer.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
|
||||
Reference in new issue
Block a user