226 lines
9.7 KiB
JavaScript
226 lines
9.7 KiB
JavaScript
(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 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, 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;
|
||
};
|
||
let feature;
|
||
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) =>
|
||
'<button class="following-card' + (item.has_unseen_change ? ' has-unseen-change' : '') +
|
||
'" type="button" data-following-index="' + index + '"><span>' +
|
||
(item.has_unseen_change ? '<em>New activity</em>' : '') + '<strong>' +
|
||
escapeHtml(item.title) + '</strong><small>' + escapeHtml(
|
||
(item.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + item.repository + ' #' + item.number +
|
||
' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
|
||
'</small></span><span aria-hidden="true">›</span></button>').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,
|
||
onReviewComplete:hooks.onReviewComplete,
|
||
onAcknowledge:item => {
|
||
const [owner, repo] = item.repository.split('/');
|
||
return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
|
||
encodeURIComponent(repo) + '/issues/' + item.number +
|
||
'/seen?kind=' + encodeURIComponent(item.kind), {
|
||
method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'},
|
||
body:JSON.stringify({updated_at:item.updated_at}),
|
||
});
|
||
},
|
||
});
|
||
query('#close-following').addEventListener('click', () => query('#following-sheet').close());
|
||
query('#review-following').addEventListener('click', () => {
|
||
query('#following-sheet').close();
|
||
feature.startReview().catch(() => {
|
||
if (!query('#following-sheet').open) query('#following-sheet').showModal();
|
||
});
|
||
});
|
||
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
|
||
return {
|
||
load:feature.load,
|
||
review:feature.startReview,
|
||
open() {
|
||
const sheet = query('#following-sheet');
|
||
if (!sheet.open) sheet.showModal();
|
||
feature.load().catch(() => {});
|
||
return 'opened-following';
|
||
},
|
||
session:feature.session,
|
||
previewLoaded:feature.previewLoaded,
|
||
retire:feature.retire,
|
||
returnToFollowing() {
|
||
const completed = feature.finishReview();
|
||
if (completed) return 'completed-following';
|
||
const sheet = query('#following-sheet');
|
||
if (!sheet.open) sheet.showModal();
|
||
globalThis.requestAnimationFrame?.(() => {
|
||
const target = query('#review-following:not([hidden])') || query('.following-card') || query('#close-following');
|
||
target?.focus();
|
||
});
|
||
return 'returned-following';
|
||
},
|
||
};
|
||
}
|
||
|
||
return {createFollowing, attachFollowing};
|
||
});
|