(function (root, factory) { const exports = factory(); if (typeof module === 'object' && module.exports) { module.exports = exports.createFollowing; module.exports.attachFollowing = exports.attachFollowing; } else { root.createFollowing = exports.createFollowing; root.attachFollowing = exports.attachFollowing; } })(typeof self !== 'undefined' ? self : this, function () { function createFollowing(options) { let generation = 0; let snapshot = {revision:0, items:[]}; let review = null; function sameItem(left, right) { return (left?.kind || 'issue') === (right?.kind || 'issue') && left?.repository === right?.repository && Number(left?.number) === Number(right?.number); } function publish(status, error) { const state = {status, revision:snapshot.revision, items:[...snapshot.items]}; state.degraded = snapshot.degraded === true; state.refreshFailures = Number(snapshot.refreshFailures) || 0; if (review?.acknowledged.size) state.reviewSummary = { reviewed:review.acknowledged.size, remaining:snapshot.items.filter(item => item.has_unseen_change === true).length, }; if (error) state.error = error; options.render?.(state); if (status === 'ready') options.onCount?.( snapshot.items.filter(item => item.has_unseen_change === true).length); return state; } async function load() { const requestGeneration = ++generation; publish('loading'); try { const result = await options.fetchJson('api/v1/following', {headers:{Accept:'application/json'}}); if (requestGeneration !== generation) return snapshot; snapshot = { revision:Number(result?.revision) || 0, items:Array.isArray(result?.items) ? result.items.slice(0, 50) .map(item => ({...item, kind:item.kind === 'pull' ? 'pull' : 'issue'})) : [], degraded:result?.degraded === true, refreshFailures:Number(result?.refresh_failures) || 0, }; publish('ready'); return snapshot; } catch (error) { if (requestGeneration === generation) publish('error', error); throw error; } } async function acknowledge(item) { const current = snapshot.items.find(candidate => sameItem(candidate, item) && candidate.updated_at === item?.updated_at); if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false; await options.onAcknowledge(current); current.has_unseen_change = false; review?.acknowledged.add(current.kind + ':' + current.repository + '#' + current.number + '@' + current.updated_at); publish('ready'); return true; } async function keepForLater(item) { const exact = candidate => sameItem(candidate, item) && candidate.updated_at === item?.updated_at; const current = snapshot.items.find(exact); const index = review?.active ? review.items.findIndex(exact) : -1; if (!current || index < 0 || typeof options.onKeep !== 'function') return null; await options.onKeep(current); current.has_unseen_change = true; review.acknowledged.delete(current.kind + ':' + current.repository + '#' + current.number + '@' + current.updated_at); review.items.splice(index, 1); const next = review.items[index] || null; if (!next) review.active = false; publish('ready'); if (next) await open(snapshot.items.findIndex(candidate => sameItem(candidate, next))); return next; } async function open(index) { const item = snapshot.items[Number(index)]; if (!item) return false; await options.onOpen?.({...item, following:true}); await acknowledge(item); return true; } async function startReview() { const items = snapshot.items .filter(item => item.has_unseen_change === true) .map(item => ({...item, following:true})); if (!items.length) return false; review = {items, more:false, active:true, acknowledged:new Set()}; await open(snapshot.items.indexOf(snapshot.items.find(item => sameItem(item, items[0])))); return true; } function finishReview() { const completed = Boolean(review?.completed || (review?.active && !snapshot.items.some(item => item.has_unseen_change === true))); if (review) review.active = false; if (completed && !review.completed) { review.completed = true; options.onReviewComplete?.(); } publish('ready'); return completed; } function retire(item) { const same = candidate => sameItem(candidate, item); const index = review?.active ? review.items.findIndex(same) : -1; snapshot.items = snapshot.items.filter(candidate => !same(candidate)); if (index < 0) { publish('ready'); return null; } review.items.splice(index, 1); const next = review.items[index] || null; if (!next) { review.active = false; if (!review.completed) { review.completed = true; options.onReviewComplete?.(); } } publish('ready'); return next ? {...next} : null; } return { load, open, startReview, previewLoaded:acknowledge, keepForLater, finishReview, retire, session:() => review?.active ? {items:[...review.items], more:false} : null, items:() => snapshot.items.map(item => ({...item})), count:() => snapshot.items.length, }; } function attachFollowing(onOpen, hooks = {}) { const document = globalThis.document; const query = selector => document.querySelector(selector); const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[character]); const formatTime = value => new Date(value).toLocaleString(); const fetchJson = async (url, options) => { const response = await fetch(url, options); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.'); return payload; }; const changeRevision = (item, action) => { const [owner, repo] = item.repository.split('/'); return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) + '/issues/' + item.number + '/' + action + '?kind=' + encodeURIComponent(item.kind), { method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'}, body:JSON.stringify({updated_at:item.updated_at}), }); }; let feature; let activeReviewItem = null; const show = () => query('#following-sheet').open || query('#following-sheet').showModal(); function render(state) { hooks.onStatus?.(state.status); const list = query('#following-list'); const status = query('#following-status'); const reviewButton = query('#review-following'); query('#retry-following').hidden = state.status !== 'error'; if (state.status === 'loading') return void (status.textContent = 'Loading watched items…'); if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.'); const unseen = state.items.filter(item => item.has_unseen_change === true).length; reviewButton.hidden = unseen === 0; reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes'; status.textContent = (state.reviewSummary ? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. ' : '') + (state.degraded ? 'Some watched items could not be refreshed. Showing last known details. ' : '') + (state.items.length ? state.items.length + (state.items.length === 1 ? ' watched item.' : ' watched items.') : 'No watched items yet. Watch an issue or pull request from Search to keep it here.'); list.innerHTML = state.items.map((item, index) => '').join(''); list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => { query('#following-sheet').close(); feature.open(Number(button.dataset.followingIndex)).catch(() => {}); })); } feature = createFollowing({ fetchJson, render, onCount:count => { const value = query('[data-mobile-queue-count="following"]'); value.textContent = count; value.closest('button').setAttribute('aria-label', 'Following, ' + count + (count === 1 ? ' unseen change' : ' unseen changes')); hooks.onCount?.(count, feature.items()); }, onOpen:item => { activeReviewItem = item; return onOpen(item); }, onReviewComplete:hooks.onReviewComplete, onAcknowledge:item => changeRevision(item, 'seen'), onKeep:item => changeRevision(item, 'keep'), }); query('#close-following').addEventListener('click', () => query('#following-sheet').close()); query('#review-following').addEventListener('click', () => { query('#following-sheet').close(); feature.startReview().catch(() => { show(); }); }); query('#retry-following').addEventListener('click', () => feature.load().catch(() => {})); const keepButton = query('#keep-following-for-later'); keepButton.addEventListener('click', async () => { const status = query('#keep-following-status'); keepButton.disabled = true; status.textContent = 'Keeping this change for later…'; try { if (!await feature.keepForLater(activeReviewItem)) query('#close-search-preview').click(); status.textContent = 'Kept for later.'; } catch (_) { status.textContent = 'Could not keep this change for later. Retry when ready.'; keepButton.disabled = false; } }); return { load:feature.load, review:feature.startReview, async route() { await feature.load().then(feature.startReview).catch(() => false) || show(); }, open() { show(); feature.load().catch(() => {}); return 'opened-following'; }, session:feature.session, previewLoaded:feature.previewLoaded, keepForLater:feature.keepForLater, retire:feature.retire, preview(state) { keepButton.hidden = state?.item?.following !== true; keepButton.disabled = false; if (keepButton.hidden || state.status === 'loading') query('#keep-following-status').textContent = ''; }, returnToFollowing() { const completed = feature.finishReview(); if (completed) return 'completed-following'; show(); globalThis.requestAnimationFrame?.(() => { const target = query('#review-following:not([hidden])') || query('.following-card') || query('#close-following'); target?.focus(); }); return 'returned-following'; }, }; } return {createFollowing, attachFollowing}; });