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 '
' + escape(author) + escape(timing) + '
' + render(comment.body || 'No message body provided.') + '
' + activity.actionHtml(comment) + '
'; }).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, 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 = () => { launcher.hidden = !currentTarget(); }; 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?.(); if (sheet.open) sheet.close(); openedTarget = null; }; launcher.addEventListener('click', async () => { const target = currentTarget(); if (!target) return; 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; }