227 lines
8.0 KiB
JavaScript
227 lines
8.0 KiB
JavaScript
(function (root, factory) {
|
|
if (typeof module === 'object' && module.exports) module.exports = factory;
|
|
else root.createMobileRecentWork = factory;
|
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function createMobileRecentWork(options) {
|
|
'use strict';
|
|
|
|
const storage = options.storage;
|
|
const getLogin = options.getLogin;
|
|
const fetchJson = options.fetchJson;
|
|
const limit = Math.max(1, Number(options.limit) || 5);
|
|
const prefix = 'stackchain.mobile-recent-work.v1.';
|
|
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
|
|
const setTimer = options.setTimeout || setTimeout;
|
|
const clearTimer = options.clearTimeout || clearTimeout;
|
|
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
|
|
let syncFlight = null;
|
|
let syncAccount = '';
|
|
let debounceTimer = null;
|
|
|
|
function login() {
|
|
return String(getLogin?.() || '').trim().toLowerCase();
|
|
}
|
|
|
|
function key() {
|
|
const owner = login();
|
|
return owner ? prefix + owner : '';
|
|
}
|
|
|
|
function normalize(item) {
|
|
const kind = String(item?.kind || '');
|
|
const number = Number(item?.number ?? item?.notification_id);
|
|
if (!kinds.has(kind) || !Number.isSafeInteger(number) || number < 1) return null;
|
|
let route = '';
|
|
let repository = '';
|
|
if (kind === 'update') {
|
|
route = '#/my-work/update/' + number;
|
|
} else {
|
|
repository = String(item?.repository || '');
|
|
if (!repositoryPattern.test(repository)) return null;
|
|
route = '#/my-work/' + kind + '/' + repository + '/' + number;
|
|
}
|
|
const title = String(item?.title || item?.subject?.title || '').trim().slice(0, 180);
|
|
if (!title) return null;
|
|
return {kind, ...(repository ? {repository} : {}), number, title, route};
|
|
}
|
|
|
|
function normalizeList(value) {
|
|
if (!Array.isArray(value)) return [];
|
|
const unique = [];
|
|
for (const candidate of value) {
|
|
const item = normalize(candidate);
|
|
if (item && !unique.some(existing => existing.route === item.route)) unique.push(item);
|
|
if (unique.length === limit) break;
|
|
}
|
|
return unique;
|
|
}
|
|
|
|
function read() {
|
|
const storageKey = key();
|
|
if (!storageKey) return {items:[], pending:[]};
|
|
try {
|
|
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
|
if (Array.isArray(parsed)) return {items:normalizeList(parsed), pending:[]};
|
|
return {
|
|
items:normalizeList(parsed?.items),
|
|
pending:normalizeList(parsed?.pending),
|
|
};
|
|
} catch (_) {
|
|
return {items:[], pending:[]};
|
|
}
|
|
}
|
|
|
|
function persist(value, accountKey = key()) {
|
|
if (!accountKey || accountKey !== key()) return false;
|
|
try {
|
|
storage.setItem(accountKey, JSON.stringify({
|
|
items:normalizeList(value.items), pending:normalizeList(value.pending),
|
|
}));
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function announce(value = read(), status = null) {
|
|
if (!options.status) return;
|
|
options.status.textContent = status || (value.pending.length ? 'Sync pending.' : '');
|
|
}
|
|
|
|
function items() {
|
|
return read().items;
|
|
}
|
|
|
|
function state() {
|
|
const value = read();
|
|
return {pending:value.pending.length > 0, pendingCount:value.pending.length};
|
|
}
|
|
|
|
function scheduleSync() {
|
|
if (!fetchJson || !key()) return false;
|
|
if (debounceTimer) clearTimer(debounceTimer);
|
|
debounceTimer = setTimer(() => {
|
|
debounceTimer = null;
|
|
void sync();
|
|
}, debounceMs);
|
|
return true;
|
|
}
|
|
|
|
function record(item) {
|
|
const accountKey = key();
|
|
const normalized = normalize(item);
|
|
if (!accountKey || !normalized) return false;
|
|
const current = read();
|
|
current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
|
current.pending = [normalized, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
|
if (!persist(current, accountKey)) return false;
|
|
announce(current);
|
|
render();
|
|
scheduleSync();
|
|
return true;
|
|
}
|
|
|
|
function adopt(snapshot, accountKey, pending = []) {
|
|
if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false;
|
|
const remote = normalizeList(snapshot.items);
|
|
const unsent = normalizeList(pending);
|
|
const value = {
|
|
items:normalizeList([...unsent, ...remote]),
|
|
pending:unsent,
|
|
};
|
|
persist(value, accountKey);
|
|
announce(value);
|
|
render();
|
|
return true;
|
|
}
|
|
|
|
async function drain(accountKey) {
|
|
while (key() === accountKey) {
|
|
const current = read();
|
|
if (!current.pending.length) return current;
|
|
const sending = current.pending[current.pending.length - 1];
|
|
announce(current, 'Syncing recent work…');
|
|
try {
|
|
const snapshot = await fetchJson('api/v1/recent-work', {
|
|
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(sending),
|
|
});
|
|
if (key() !== accountKey) return read();
|
|
const latest = read();
|
|
const pending = latest.pending.filter(item => item.route !== sending.route);
|
|
if (!adopt(snapshot, accountKey, pending)) throw new Error('Recent work response is invalid.');
|
|
} catch (_error) {
|
|
if (key() === accountKey) announce(read());
|
|
return read();
|
|
}
|
|
}
|
|
return read();
|
|
}
|
|
|
|
function sync() {
|
|
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
|
|
const accountKey = key();
|
|
if (!fetchJson || !accountKey || !read().pending.length) return Promise.resolve(read());
|
|
if (syncFlight && syncAccount === accountKey) return syncFlight;
|
|
syncAccount = accountKey;
|
|
syncFlight = drain(accountKey).finally(() => {
|
|
if (syncAccount === accountKey) { syncFlight = null; syncAccount = ''; }
|
|
});
|
|
return syncFlight;
|
|
}
|
|
|
|
async function load() {
|
|
const accountKey = key();
|
|
if (!fetchJson || !accountKey) return read();
|
|
try {
|
|
const snapshot = await fetchJson('api/v1/recent-work');
|
|
if (key() !== accountKey) return read();
|
|
const current = read();
|
|
adopt(snapshot, accountKey, current.pending);
|
|
return current.pending.length ? sync() : read();
|
|
} catch (_error) {
|
|
if (key() === accountKey) announce(read(), read().pending.length ? null : 'Recent work could not sync.');
|
|
return read();
|
|
}
|
|
}
|
|
|
|
function startLifecycle(lifecycle = {}) {
|
|
const reconcile = () => read().pending.length ? sync() : load();
|
|
lifecycle.window?.addEventListener?.('online', () => { void reconcile(); });
|
|
lifecycle.document?.addEventListener?.('visibilitychange', () => {
|
|
if (!lifecycle.document.hidden) void reconcile();
|
|
});
|
|
return reconcile;
|
|
}
|
|
|
|
function render() {
|
|
const recent = items();
|
|
const list = options.list;
|
|
const section = options.section;
|
|
if (!list || !section || !options.document) return recent.length;
|
|
const rows = recent.map(item => {
|
|
const detail = item.kind === 'update'
|
|
? 'Update · #' + item.number
|
|
: item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
|
|
const button = options.document.createElement('button');
|
|
const copy = options.document.createElement('span');
|
|
const primary = options.document.createElement('strong');
|
|
const secondary = options.document.createElement('small');
|
|
primary.textContent = item.title;
|
|
secondary.textContent = detail;
|
|
copy.appendChild(primary);
|
|
copy.appendChild(secondary);
|
|
button.appendChild(copy);
|
|
button.setAttribute('type', 'button');
|
|
button.setAttribute('data-recent-work-route', item.route);
|
|
button.setAttribute('aria-label', 'Open ' + item.title + ', ' + detail.toLowerCase().replace(' · ', ' '));
|
|
button.addEventListener('click', () => options.openRoute?.(item.route));
|
|
return button;
|
|
});
|
|
list.replaceChildren(...rows);
|
|
section.hidden = rows.length === 0;
|
|
return rows.length;
|
|
}
|
|
|
|
return {items, record, render, load, sync, startLifecycle, state};
|
|
});
|