279 lines
12 KiB
JavaScript
279 lines
12 KiB
JavaScript
function createProgressiveMyWork({
|
|
document, fetchSnapshot, liveSnapshot: snapshotBroker = null,
|
|
pollerOptions = {}, lifecycleTarget = globalThis,
|
|
}) {
|
|
const list = document.querySelector('#my-work-list');
|
|
const status = document.querySelector('#my-work-status');
|
|
const detail = document.querySelector('#progressive-work-detail');
|
|
const detailTitle = document.querySelector('#progressive-work-detail-title');
|
|
const detailMeta = document.querySelector('#progressive-work-detail-meta');
|
|
const detailReason = document.querySelector('#progressive-work-detail-reason');
|
|
const closeDetail = document.querySelector('#close-progressive-work-detail');
|
|
const openGitea = document.querySelector('#open-progressive-work-gitea');
|
|
const filters = Array.from(document.querySelectorAll('[data-work-filter]'));
|
|
const listeners = [];
|
|
const lifecycleListeners = [];
|
|
const subscribers = new Set();
|
|
let items = [];
|
|
let active = 'all';
|
|
let stopped = false;
|
|
let selectedByUser = false;
|
|
let liveSnapshot = null;
|
|
let liveSnapshotPromise = null;
|
|
let confirmedLogin = '';
|
|
let poller = null;
|
|
let detailTrigger = null;
|
|
let openWork = null;
|
|
let contextUnavailable = false;
|
|
const deferredQueues = {
|
|
today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts',
|
|
};
|
|
const fetchProgressiveSnapshot = (revisions = {}, options = {}) =>
|
|
snapshotBroker && Object.keys(revisions || {}).length === 0 ?
|
|
snapshotBroker.acquire() : fetchSnapshot(revisions, options);
|
|
|
|
const escapeHtml = value => String(value || '').replace(/[&<>"']/g, character => ({
|
|
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
|
})[character]);
|
|
const safeUrl = value => {
|
|
try {
|
|
const url = new URL(String(value || ''), globalThis.location?.href || 'https://invalid.example/');
|
|
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
|
} catch (_error) { return ''; }
|
|
};
|
|
const matches = (item, filter) =>
|
|
filter === 'review' ? item.is_review :
|
|
filter === 'update' ? item.has_update :
|
|
filter === 'attention' ? item.needs_attention :
|
|
filter === 'filed' ? item.is_filed :
|
|
filter === 'authored' ? item.is_authored :
|
|
filter === 'pull' ? item.kind === 'pull' && !item.is_review : item.kind === filter;
|
|
const visibleItems = () => active === 'all' ? items : items.filter(item => matches(item, active));
|
|
const notify = () => subscribers.forEach(subscriber => subscriber());
|
|
const queueCounts = () => ({
|
|
all:items.length,
|
|
filed:items.filter(item => matches(item, 'filed')).length,
|
|
authored:items.filter(item => matches(item, 'authored')).length,
|
|
attention:items.filter(item => matches(item, 'attention')).length,
|
|
update:items.filter(item => matches(item, 'update')).length,
|
|
review:items.filter(item => matches(item, 'review')).length,
|
|
});
|
|
const closeProgressiveDetail = ({ restoreFocus = true } = {}) => {
|
|
if (!detail || detail.hidden) return;
|
|
detail.hidden = true;
|
|
if (restoreFocus) detailTrigger?.focus?.();
|
|
detailTrigger = null;
|
|
openWork = null;
|
|
};
|
|
const openProgressiveDetail = (item, trigger) => {
|
|
if (!detail || !item) return;
|
|
detailTrigger = trigger;
|
|
openWork = item;
|
|
detailTitle.textContent = item.title || item.key || 'Untitled work';
|
|
detailMeta.textContent = (item.key || 'Unknown work item') + ' · ' +
|
|
(item.kind === 'pull' ? 'Pull request' : 'Issue');
|
|
detailReason.textContent = item.reason || 'Assigned to you';
|
|
const href = safeUrl(item.url);
|
|
openGitea.hidden = !href;
|
|
if (href) openGitea.href = href;
|
|
else openGitea.removeAttribute?.('href');
|
|
detail.hidden = false;
|
|
closeDetail?.focus?.();
|
|
};
|
|
const updateCounts = () => {
|
|
['all','attention','filed','authored','issue','pull','review','update'].forEach(filter => {
|
|
const count = filter === 'all' ? items.length : items.filter(item => matches(item, filter)).length;
|
|
const element = document.querySelector('[data-work-count="' + filter + '"]');
|
|
if (element) element.textContent = String(count);
|
|
});
|
|
};
|
|
const render = () => {
|
|
if (stopped || !list) return;
|
|
const visible = visibleItems();
|
|
list.innerHTML = contextUnavailable && !items.length ?
|
|
'<div class="muted">Assigned work is reconnecting…</div>' : deferredQueues[active] ?
|
|
'<div class="muted">' + deferredQueues[active] + ' is still loading…</div>' : visible.length ? visible.map((item, index) => {
|
|
const title = escapeHtml(item.title || item.key || 'Untitled work');
|
|
const context = escapeHtml(item.key || '');
|
|
const reason = escapeHtml(item.reason || 'Assigned to you');
|
|
return '<article class="my-work-card progressive-my-work-card">' +
|
|
'<button class="my-work-card-main" type="button" data-progressive-work-index="' + index + '">' +
|
|
'<strong>' + title + '</strong><span class="small">' + context + ' · ' + reason + '</span>' +
|
|
'</button></article>';
|
|
}).join('') : '<div class="muted">No work in this queue.</div>';
|
|
filters.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.workFilter === active)));
|
|
};
|
|
filters.forEach(button => {
|
|
const listener = () => { active = button.dataset.workFilter || 'all'; selectedByUser = true; render(); };
|
|
button.addEventListener('click', listener);
|
|
listeners.push([button, listener]);
|
|
});
|
|
const openListener = event => {
|
|
const trigger = event.target?.closest?.('[data-progressive-work-index]');
|
|
if (!trigger) return;
|
|
const item = visibleItems()[Number(trigger.dataset.progressiveWorkIndex)];
|
|
if (!item) return;
|
|
event.preventDefault?.();
|
|
openProgressiveDetail(item, trigger);
|
|
};
|
|
const closeListener = () => closeProgressiveDetail();
|
|
const keyListener = event => {
|
|
if (event.key !== 'Escape' || detail?.hidden) return;
|
|
event.preventDefault?.();
|
|
closeProgressiveDetail();
|
|
};
|
|
list?.addEventListener?.('click', openListener);
|
|
closeDetail?.addEventListener?.('click', closeListener);
|
|
lifecycleTarget.addEventListener?.('keydown', keyListener);
|
|
|
|
const applySnapshot = snapshot => {
|
|
if (stopped) return false;
|
|
const transferable = snapshot && typeof snapshot === 'object' &&
|
|
Object.prototype.hasOwnProperty.call(snapshot, 'context');
|
|
const contextDegraded = transferable && (
|
|
!snapshot.context || snapshot.freshness?.sections?.context?.degraded
|
|
);
|
|
if (contextDegraded) {
|
|
contextUnavailable = true;
|
|
render();
|
|
notify();
|
|
if (status) status.textContent = 'Assigned work is reconnecting…';
|
|
return false;
|
|
}
|
|
contextUnavailable = false;
|
|
if (transferable) liveSnapshot = snapshot;
|
|
const context = snapshot?.context || snapshot || {};
|
|
confirmedLogin = String(context.user?.login || '').trim();
|
|
items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] });
|
|
updateCounts();
|
|
render();
|
|
notify();
|
|
const assigned = items.filter(item => item.is_assigned).length;
|
|
if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.';
|
|
return true;
|
|
};
|
|
|
|
if (typeof createContextPoller === 'function') {
|
|
poller = createContextPoller({
|
|
...pollerOptions,
|
|
fetchContext: fetchProgressiveSnapshot,
|
|
onSnapshot: applySnapshot,
|
|
onError: () => {
|
|
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
|
|
},
|
|
isHidden: pollerOptions.isHidden || (() => Boolean(document.hidden)),
|
|
});
|
|
const recover = () => {
|
|
if (!document.hidden) void poller.refresh();
|
|
};
|
|
['online', 'visibilitychange'].forEach(eventName => {
|
|
lifecycleTarget.addEventListener?.(eventName, recover);
|
|
lifecycleListeners.push([eventName, recover]);
|
|
});
|
|
}
|
|
|
|
return {
|
|
login() { return confirmedLogin; },
|
|
counts() { return queueCounts(); },
|
|
subscribe(subscriber) {
|
|
subscribers.add(subscriber);
|
|
return () => subscribers.delete(subscriber);
|
|
},
|
|
openFirst() {
|
|
const item = visibleItems()[0];
|
|
if (!item) return deferredQueues[active] ? 'loading' : 'empty';
|
|
const trigger = list?.querySelector?.('[data-progressive-work-index="0"]') || null;
|
|
openProgressiveDetail(item, trigger);
|
|
return 'opened';
|
|
},
|
|
selectQueue(name, {openFirst = false} = {}) {
|
|
const allowed = new Set(['all','attention','filed','authored','issue','pull','review','update','today','agenda','later','draft']);
|
|
if (!allowed.has(name)) return 'unsupported';
|
|
active = name;
|
|
selectedByUser = true;
|
|
render();
|
|
notify();
|
|
return openFirst ? this.openFirst() : 'selected';
|
|
},
|
|
handoff() {
|
|
const state = { selectedFilter:selectedByUser ? active : null };
|
|
if (openWork) {
|
|
state.openWork = {
|
|
kind:openWork.kind, key:openWork.key, number:openWork.number,
|
|
repository:openWork.repository,
|
|
};
|
|
openWork = null;
|
|
}
|
|
if (liveSnapshot) {
|
|
state.liveSnapshot = liveSnapshot;
|
|
liveSnapshot = null;
|
|
liveSnapshotPromise = null;
|
|
} else if (liveSnapshotPromise) {
|
|
state.liveSnapshotPromise = liveSnapshotPromise;
|
|
liveSnapshotPromise = null;
|
|
}
|
|
return state;
|
|
},
|
|
async start() {
|
|
if (status) status.textContent = 'Loading assigned work…';
|
|
if (poller) {
|
|
const request = poller.start();
|
|
liveSnapshotPromise = request.then(snapshot => (
|
|
snapshot && typeof snapshot === 'object' &&
|
|
Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null
|
|
));
|
|
return Boolean(await request);
|
|
}
|
|
const request = Promise.resolve().then(() => fetchProgressiveSnapshot());
|
|
liveSnapshotPromise = request.then(snapshot => (
|
|
snapshot && typeof snapshot === 'object' &&
|
|
Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null
|
|
), () => null);
|
|
try {
|
|
const snapshot = await request;
|
|
if (stopped) return false;
|
|
const applied = applySnapshot(snapshot);
|
|
if (!liveSnapshot) liveSnapshotPromise = null;
|
|
return applied;
|
|
} catch (_error) {
|
|
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
|
|
return false;
|
|
}
|
|
},
|
|
stop() {
|
|
stopped = true;
|
|
poller?.stop();
|
|
listeners.forEach(([button, listener]) => button.removeEventListener?.('click', listener));
|
|
list?.removeEventListener?.('click', openListener);
|
|
closeDetail?.removeEventListener?.('click', closeListener);
|
|
lifecycleTarget.removeEventListener?.('keydown', keyListener);
|
|
lifecycleListeners.forEach(([eventName, listener]) =>
|
|
lifecycleTarget.removeEventListener?.(eventName, listener));
|
|
subscribers.clear();
|
|
},
|
|
};
|
|
}
|
|
|
|
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
window.stackchainProgressiveMyWork = createProgressiveMyWork({
|
|
document,
|
|
lifecycleTarget: window,
|
|
liveSnapshot:window.stackchainProgressiveLiveSnapshot,
|
|
fetchSnapshot: async (revisions = {}, { signal } = {}) => {
|
|
const query = createContextPoller.buildRevisionQuery(revisions);
|
|
const response = await fetch('api/v1/live' + (query ? '?' + query : ''), {
|
|
headers:{Accept:'application/json'}, signal,
|
|
});
|
|
if (!response.ok) {
|
|
const error = new Error('HTTP ' + response.status);
|
|
error.retryAfterMs = createContextPoller.retryAfterMs(response.headers.get('Retry-After'));
|
|
throw error;
|
|
}
|
|
return response.json();
|
|
},
|
|
});
|
|
void window.stackchainProgressiveMyWork.start();
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveMyWork;
|