stackchain-dashboard/frontend/following.js
timmy 32e2a228b9
All checks were successful
CI / lint (pull_request) Successful in 4m12s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m3s
CI / release-candidate (pull_request) Has been skipped
feat: make Following a changed-first inbox (Closes #1297)
2026-08-23 07:38:53 +00:00

128 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(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:[]};
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 (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) : [],
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 open(index) {
const item = snapshot.items[Number(index)];
if (!item) return false;
await options.onOpen?.({...item, kind:'issue'});
if (item.has_unseen_change === true && typeof options.onAcknowledge === 'function') {
await options.onAcknowledge(item);
item.has_unseen_change = false;
publish('ready');
}
return true;
}
return {load, open, count:() => snapshot.items.length};
}
function attachFollowing(onOpen) {
const document = globalThis.document;
const query = selector => document.querySelector(selector);
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[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) {
const list = query('#following-list');
const status = query('#following-status');
query('#retry-following').hidden = state.status !== 'error';
if (state.status === 'loading') return void (status.textContent = 'Loading watched issues…');
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
status.textContent = (state.degraded ? 'Some watched issues could not be refreshed. Showing last known details. ' : '') + (state.items.length
? state.items.length + (state.items.length === 1 ? ' watched issue.' : ' watched issues.')
: 'No watched issues yet. Watch one 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.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'));
},
onOpen,
onAcknowledge:item => {
const [owner, repo] = item.repository.split('/');
return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
encodeURIComponent(repo) + '/issues/' + item.number + '/seen', {
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('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
return {
load:feature.load,
open() {
const sheet = query('#following-sheet');
if (!sheet.open) sheet.showModal();
feature.load().catch(() => {});
return 'opened-following';
},
};
}
return {createFollowing, attachFollowing};
});