405 lines
18 KiB
JavaScript
405 lines
18 KiB
JavaScript
function createTodayProgress({ storage, getLogin = () => '', admit, makeId = () =>
|
|
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
|
maxLength = 2000, maxItems = 20 }) {
|
|
const prefix = 'stackchain.today-progress.v1.';
|
|
const login = () => String(getLogin() || '').trim().toLowerCase();
|
|
const storageKey = () => login() ? prefix + encodeURIComponent(login()) : '';
|
|
const validIdentity = identity => typeof identity === 'string' && identity.length > 0 && identity.length <= 500;
|
|
const attachmentFingerprint = attachments => JSON.stringify((Array.isArray(attachments) ? attachments : []).filter(Boolean).slice(0, 5).map(value => ({
|
|
filename:String(value.filename || ''), contentType:String(value.contentType || ''),
|
|
note:String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240),
|
|
operationId:String(value.operationId || '').slice(0, 128),
|
|
markdown:String(value.confirmed?.markdown || ''),
|
|
})));
|
|
const payloadFingerprint = (body, attachments) => JSON.stringify({body, attachments:attachmentFingerprint(attachments)});
|
|
const validRecord = record => record && typeof record.body === 'string' && (record.body.length > 0 || record.has_attachments === true) &&
|
|
record.body.length <= maxLength && typeof record.operation_id === 'string' && record.operation_id.length > 0 &&
|
|
record.operation_id.length <= 128;
|
|
|
|
function read() {
|
|
const key = storageKey();
|
|
if (!storage || !key) return {};
|
|
try {
|
|
const saved = JSON.parse(storage.getItem(key) || 'null');
|
|
if (!saved || saved.version !== 1 || !saved.drafts || typeof saved.drafts !== 'object' ||
|
|
Array.isArray(saved.drafts)) return {};
|
|
const entries = Object.entries(saved.drafts).filter(([identity, record]) =>
|
|
validIdentity(identity) && validRecord(record)
|
|
).slice(0, maxItems);
|
|
return Object.fromEntries(entries);
|
|
} catch (_error) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function write(drafts) {
|
|
const key = storageKey();
|
|
if (!storage || !key) return false;
|
|
try {
|
|
if (Object.keys(drafts).length) storage.setItem(key, JSON.stringify({ version:1, drafts }));
|
|
else storage.removeItem(key);
|
|
return true;
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function load(identity) {
|
|
if (!validIdentity(identity)) return '';
|
|
return read()[identity]?.body || '';
|
|
}
|
|
|
|
function save(identity, value, attachments = []) {
|
|
if (!validIdentity(identity) || !storageKey()) return false;
|
|
const body = String(value || '').trim();
|
|
const hasAttachments = attachments === true || (Array.isArray(attachments) && attachments.filter(Boolean).length > 0);
|
|
if (body.length > maxLength) return false;
|
|
const drafts = read();
|
|
if (!body && !hasAttachments) {
|
|
delete drafts[identity];
|
|
return write(drafts);
|
|
}
|
|
if (!drafts[identity] && Object.keys(drafts).length >= maxItems) return false;
|
|
const previous = drafts[identity];
|
|
const fingerprint = payloadFingerprint(body, Array.isArray(attachments) ? attachments : []);
|
|
const previousFingerprint = previous?.payload_fingerprint ||
|
|
(previous && !previous.has_attachments ? payloadFingerprint(previous.body, []) : '');
|
|
drafts[identity] = {
|
|
body,
|
|
operation_id: previousFingerprint === fingerprint ? previous.operation_id : String(makeId()).slice(0, 128),
|
|
payload_fingerprint:fingerprint,
|
|
...(hasAttachments ? {has_attachments:true} : {}),
|
|
};
|
|
return write(drafts);
|
|
}
|
|
|
|
function validTarget(target) {
|
|
return target && ['issue', 'pull'].includes(target.kind) && validIdentity(target.identity) &&
|
|
typeof target.repository === 'string' && target.repository.length > 0 && target.repository.length <= 200 &&
|
|
Number.isInteger(target.number) && target.number > 0;
|
|
}
|
|
|
|
async function post(target, value, attachments = [], completeEvidence) {
|
|
if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.');
|
|
const pendingRecord = read()[target.identity];
|
|
if (pendingRecord?.cleanup_pending === true) {
|
|
try {
|
|
if (typeof completeEvidence === 'function') await completeEvidence();
|
|
} catch (_error) {
|
|
const error = new Error('Progress update is already queued; photo cleanup is pending.');
|
|
error.deliveryAdmitted = true;
|
|
throw error;
|
|
}
|
|
const pendingDrafts = read();
|
|
if (pendingDrafts[target.identity]?.operation_id === pendingRecord.operation_id) {
|
|
delete pendingDrafts[target.identity];
|
|
write(pendingDrafts);
|
|
}
|
|
return { background:true, alreadyAdmitted:true, cleanupRecovered:true };
|
|
}
|
|
const evidence = (Array.isArray(attachments) ? attachments : [attachments]).filter(Boolean).slice(0, 5);
|
|
if ((value !== undefined || evidence.length) && !save(target.identity, value ?? load(target.identity), evidence)) {
|
|
throw new Error('Progress update could not be saved on this device.');
|
|
}
|
|
const record = read()[target.identity];
|
|
if (!record) throw new Error('Write a progress update before posting.');
|
|
if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.');
|
|
const admission = await admit({
|
|
kind:target.kind + '-comment', repository:target.repository, number:target.number,
|
|
body:record.body, operationId:record.operation_id,
|
|
...(evidence.length ? {attachments:evidence} : {}),
|
|
});
|
|
const admittedDrafts = read();
|
|
if (admittedDrafts[target.identity]?.operation_id === record.operation_id) {
|
|
admittedDrafts[target.identity] = { ...admittedDrafts[target.identity], cleanup_pending:true };
|
|
write(admittedDrafts);
|
|
}
|
|
try {
|
|
if (typeof completeEvidence === 'function') await completeEvidence();
|
|
} catch (_error) {
|
|
const error = new Error('Progress update is already queued; photo cleanup is pending.');
|
|
error.deliveryAdmitted = true;
|
|
throw error;
|
|
}
|
|
const drafts = read();
|
|
if (drafts[target.identity]?.operation_id === record.operation_id) {
|
|
delete drafts[target.identity];
|
|
write(drafts);
|
|
}
|
|
return admission;
|
|
}
|
|
|
|
return { load, save, discard:identity => save(identity, ''), post };
|
|
}
|
|
|
|
function createTodayProgressActivity({ fetchJson, createPager, getActions, onActions, surfaceStatus, paint = () => {}, setStatus = () => {} }) {
|
|
let requestToken = 0;
|
|
let pager = null;
|
|
let target = null;
|
|
let actions = null;
|
|
|
|
const pathFor = (value, page) => {
|
|
const repository = String(value.repository || '').split('/').map(encodeURIComponent).join('/');
|
|
const resource = value.kind === 'pull' ? 'pulls' : 'issues';
|
|
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(value.number) +
|
|
'/comments?' + (Number.isInteger(page) ? 'page=' + encodeURIComponent(page) + '&' : '') + 'limit=20';
|
|
};
|
|
const loadPage = page => fetchJson(pathFor(target, page));
|
|
|
|
async function open(value) {
|
|
const token = ++requestToken;
|
|
target = { ...value };
|
|
setStatus('Loading recent activity…');
|
|
try {
|
|
const page = await loadPage();
|
|
if (token !== requestToken || target.identity !== value.identity) return false;
|
|
pager = createPager({ loadPage });
|
|
actions = typeof getActions === 'function' ? await getActions() : null;
|
|
if (token !== requestToken || target.identity !== value.identity) return false;
|
|
onActions?.(actions);
|
|
paint(pager.reset(page));
|
|
setStatus('');
|
|
return true;
|
|
} catch (error) {
|
|
if (token === requestToken) setStatus('Recent activity unavailable. Retry.');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function loadOlder() {
|
|
if (!pager || !target) return false;
|
|
const token = requestToken;
|
|
setStatus('Loading older activity…');
|
|
try {
|
|
const page = await pager.loadOlder();
|
|
if (token !== requestToken) return false;
|
|
paint(page);
|
|
setStatus('');
|
|
return true;
|
|
} catch (_error) {
|
|
if (token === requestToken) setStatus('Older activity unavailable. Retry.');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return {
|
|
open,
|
|
retry:() => target ? open(target) : Promise.resolve(false),
|
|
loadOlder,
|
|
actionHtml:comment => actions?.actionHtml?.(comment) || '',
|
|
surface:() => ({
|
|
context:{ kind:target?.kind, item:target }, pager,
|
|
status:surfaceStatus, render:paint,
|
|
}),
|
|
close:() => { requestToken++; target = null; pager = null; actions = null; setStatus(''); },
|
|
};
|
|
}
|
|
|
|
function mountTodayProgressActivity(qs, fetchJson, actionSource = {}, render) {
|
|
const options = actionSource.get && !actionSource.getActions ?
|
|
{ getActions:actionSource.get, isOffline:() => !navigator.onLine } : actionSource;
|
|
const escape = options.escape || (value => String(value).replace(/[&<>"']/g,
|
|
character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[character]));
|
|
render ||= options.render;
|
|
const list = qs('#today-progress-activity-list');
|
|
const optionsStatus = qs('#today-progress-activity-status');
|
|
let actionsWired = false;
|
|
const activity = createTodayProgressActivity({
|
|
fetchJson,
|
|
createPager:options.createPager || createConversationPager,
|
|
getActions:options.getActions,
|
|
surfaceStatus:optionsStatus,
|
|
onActions:actions => {
|
|
if (actionsWired || !actions?.wire) return;
|
|
actions.wire({ root:list, getSurface:() => activity.surface(),
|
|
isOffline:options.isOffline || (() => false), escapeHtml:escape });
|
|
actionsWired = true;
|
|
},
|
|
paint:state => {
|
|
list.innerHTML = (state.comments || []).map(comment => {
|
|
const author = comment.author || comment.user?.login || 'Unknown author';
|
|
const timing = comment.created_at ? ' · ' + new Date(comment.created_at).toLocaleString() : '';
|
|
return '<article class="today-progress-activity-item issue-comment" data-comment-id="' +
|
|
escape(String(comment.id)) + '"><div class="small muted">' +
|
|
escape(author) + escape(timing) + '</div><div class="markdown-content">' +
|
|
render(comment.body || 'No message body provided.') + '</div>' +
|
|
activity.actionHtml(comment) + '</article>';
|
|
}).join('');
|
|
qs('#today-progress-activity-count').textContent = state.total ?
|
|
String(state.comments.length) + ' of ' + String(state.total) + ' messages' : '';
|
|
qs('#load-older-today-progress-activity').hidden = !Number.isInteger(state.older_page);
|
|
},
|
|
setStatus:message => {
|
|
optionsStatus.textContent = message;
|
|
qs('#retry-today-progress-activity').hidden = !message.includes('unavailable');
|
|
},
|
|
});
|
|
qs('#retry-today-progress-activity').addEventListener('click', () => activity.retry());
|
|
qs('#load-older-today-progress-activity').addEventListener('click', async () => {
|
|
const list = qs('#today-progress-activity-list');
|
|
const previousHeight = list.scrollHeight;
|
|
const button = qs('#load-older-today-progress-activity');
|
|
button.disabled = true;
|
|
await activity.loadOlder();
|
|
list.scrollTop += list.scrollHeight - previousHeight;
|
|
button.disabled = false;
|
|
});
|
|
return activity;
|
|
}
|
|
|
|
function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, mentions, announce = () => {}, onAdmitted = () => {} }) {
|
|
const sheet = qs('#today-progress-sheet');
|
|
const body = qs('#today-progress-body');
|
|
const status = qs('#today-progress-status');
|
|
const launcher = qs('[data-mobile-today-update]');
|
|
let openedTarget = null;
|
|
|
|
const update = () => {
|
|
const target = currentTarget();
|
|
launcher.hidden = !target;
|
|
if (openedTarget && target?.identity !== openedTarget.identity) mentions?.dismiss?.();
|
|
};
|
|
const checkpoint = async () => {
|
|
if (!openedTarget) return false;
|
|
try {
|
|
await photos?.checkpoint?.();
|
|
const checkpointAttachments = await photos?.serialize?.() || [];
|
|
if (progress.save(openedTarget.identity, body.value, checkpointAttachments)) return true;
|
|
status.textContent = 'Update must be 2,000 characters or fewer and device storage must be available.';
|
|
return false;
|
|
} catch (error) {
|
|
status.textContent = error.message + ' Your photos remain here; retry.';
|
|
return false;
|
|
}
|
|
};
|
|
const close = () => {
|
|
voice?.cancel?.();
|
|
activity?.close?.();
|
|
mentions?.dismiss?.();
|
|
if (sheet.open) sheet.close();
|
|
openedTarget = null;
|
|
};
|
|
|
|
launcher.addEventListener('click', async () => {
|
|
const target = currentTarget();
|
|
if (!target) return;
|
|
mentions?.dismiss?.();
|
|
openedTarget = target;
|
|
qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : '');
|
|
body.value = progress.load(target.identity);
|
|
status.textContent = 'Restoring saved photo evidence…';
|
|
sheet.showModal();
|
|
try {
|
|
await voice?.open?.(target.identity);
|
|
await photos?.open?.(target);
|
|
await activity?.open?.(target);
|
|
status.textContent = '';
|
|
}
|
|
catch (error) { status.textContent = error.message + ' You can retry by reopening this update.'; }
|
|
body.focus();
|
|
});
|
|
qs('#cancel-today-progress').addEventListener('click', async () => {
|
|
if (await checkpoint()) close();
|
|
});
|
|
sheet.addEventListener('cancel', async event => {
|
|
event.preventDefault();
|
|
if (await checkpoint()) close();
|
|
});
|
|
qs('#save-today-progress').addEventListener('click', async () => {
|
|
if (!await checkpoint()) return;
|
|
announce('Progress update saved privately to this Today item.');
|
|
close();
|
|
});
|
|
qs('#post-today-progress').addEventListener('click', async () => {
|
|
const target = currentTarget();
|
|
if (!target || target.identity !== openedTarget?.identity) {
|
|
status.textContent = 'The active Today item changed. Close and open its update again.';
|
|
return;
|
|
}
|
|
const button = qs('#post-today-progress');
|
|
button.disabled = true;
|
|
status.textContent = 'Saving for delivery…';
|
|
try {
|
|
await photos?.checkpoint?.();
|
|
const attachments = await photos?.serialize?.() || [];
|
|
const admission = await progress.post(target, body.value, attachments, () => photos?.complete?.());
|
|
onAdmitted(admission);
|
|
announce(admission.background ?
|
|
'Progress update queued for delivery. Today is still on the same item.' :
|
|
'Progress update saved for next launch. Today is still on the same item.');
|
|
close();
|
|
} catch (error) {
|
|
status.textContent = error.deliveryAdmitted ?
|
|
error.message + ' Retry to finish local cleanup; delivery will not be queued again.' :
|
|
error.message + ' Your update remains on this item; retry.';
|
|
body.focus();
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
update();
|
|
return { update };
|
|
}
|
|
|
|
function createTodayProgressPhotos({ qs, document, issueAttachment, fetchJson, createStore, createDrafts, getLogin }) {
|
|
let target = null;
|
|
let drafts = null;
|
|
const controller = issueAttachment.mount({
|
|
maxFiles:5, input:qs('#today-progress-attachment'),
|
|
inputs:[qs('#take-today-progress-photo'), qs('#today-progress-attachment')],
|
|
preview:qs('#today-progress-attachment-preview'), image:qs('#today-progress-attachment-image'),
|
|
meta:qs('#today-progress-attachment-meta'), remove:qs('#remove-today-progress-attachment'),
|
|
tray:qs('#today-progress-attachment-tray'), earlier:qs('#move-today-progress-attachment-earlier'),
|
|
later:qs('#move-today-progress-attachment-later'), note:qs('#today-progress-attachment-note'),
|
|
noteLabel:qs('#today-progress-attachment-note-label'), status:qs('#today-progress-status'),
|
|
onChange:() => drafts?.checkpoint('today').catch(() => {}),
|
|
onCheckpoint:() => drafts.checkpoint('today'),
|
|
readyMessage:'Photo ready to post with this Today update.',
|
|
removedMessage:'Photo removed. Your progress text is unchanged.',
|
|
editor:{
|
|
document, edit:qs('#edit-today-progress-attachment'), dialog:qs('#issue-evidence-editor'),
|
|
canvas:qs('#issue-evidence-editor-canvas'), exportCanvas:qs('#issue-evidence-editor-export'),
|
|
crop:qs('#crop-issue-evidence'), redact:qs('#redact-issue-evidence'),
|
|
highlight:qs('#highlight-issue-evidence'), arrow:qs('#arrow-issue-evidence'),
|
|
undo:qs('#undo-issue-evidence-edit'), reset:qs('#reset-issue-evidence-edit'),
|
|
cancel:qs('#cancel-issue-evidence-edit'), apply:qs('#apply-issue-evidence-edit'),
|
|
status:qs('#issue-evidence-editor-status'), appliedMessage:'Edited photo ready for this Today update.',
|
|
},
|
|
createObjectURL:file => URL.createObjectURL(file), revokeObjectURL:url => URL.revokeObjectURL(url),
|
|
upload:payload => {
|
|
if (!target || target.repository !== payload.repository || Number(target.number) !== Number(payload.number)) {
|
|
return Promise.reject(new Error('The active Today item changed. Reopen its update before posting.'));
|
|
}
|
|
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
|
|
const resource = target.kind === 'pull' ? 'pulls' : 'issues';
|
|
return fetchJson('api/v1/repos/' + repository + '/' + resource + '/' +
|
|
encodeURIComponent(payload.number) + '/attachments', {
|
|
method:'POST', headers:{Accept:'application/json','Idempotency-Key':payload.operation_id},
|
|
body:issueAttachment.multipart(payload),
|
|
});
|
|
},
|
|
});
|
|
const store = createStore({ indexedDB:globalThis.indexedDB, getOwnerLogin:getLogin, scope:'today-progress' });
|
|
drafts = createDrafts({ store, lanes:{ today:{ controller, onError:error => {
|
|
qs('#today-progress-status').textContent = error.message + ' Your photos remain here; retry.';
|
|
} } } });
|
|
return {
|
|
has:() => Boolean(controller.state()), serialize:() => controller.serialize(),
|
|
checkpoint:() => drafts.checkpoint('today'),
|
|
open:async value => {
|
|
if (drafts.hasTarget('today')) await drafts.switchTo('today', value);
|
|
else await drafts.open('today', value);
|
|
target = { ...value };
|
|
},
|
|
complete:async () => { await drafts.complete('today'); target = null; },
|
|
};
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = createTodayProgress;
|
|
module.exports.createView = createTodayProgressView;
|
|
module.exports.createPhotos = createTodayProgressPhotos;
|
|
module.exports.createActivity = createTodayProgressActivity;
|
|
module.exports.mountActivity = mountTodayProgressActivity;
|
|
}
|