428 lines
16 KiB
JavaScript
428 lines
16 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 pinnedLimit = Math.max(1, Number(options.pinnedLimit) || 20);
|
|
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;
|
|
const retryMs = Number.isFinite(options.retryMs) ? Math.max(1, options.retryMs) : 1000;
|
|
const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryMs, options.retryMaxMs) : 30000;
|
|
let syncFlight = null;
|
|
let syncAccount = '';
|
|
let debounceTimer = null;
|
|
let retryTimer = null;
|
|
let retryAccount = '';
|
|
let retryAttempt = 0;
|
|
let operationSequence = 0;
|
|
let pinsExpanded = false;
|
|
|
|
options.pinnedToggle?.addEventListener?.('click', () => {
|
|
pinsExpanded = !pinsExpanded;
|
|
render();
|
|
});
|
|
|
|
function operationId() {
|
|
operationSequence += 1;
|
|
return Date.now().toString(36) + '-' + operationSequence.toString(36);
|
|
}
|
|
|
|
function clearRetry(resetAttempt = false) {
|
|
if (retryTimer) clearTimer(retryTimer);
|
|
retryTimer = null;
|
|
retryAccount = '';
|
|
if (resetAttempt) retryAttempt = 0;
|
|
}
|
|
|
|
function scheduleRetry(accountKey) {
|
|
if (retryTimer || key() !== accountKey || !hasPending(read())) return false;
|
|
retryAccount = accountKey;
|
|
const delay = Math.min(retryMaxMs, retryMs * (2 ** retryAttempt));
|
|
retryAttempt += 1;
|
|
retryTimer = setTimer(() => {
|
|
const timer = retryTimer;
|
|
retryTimer = null;
|
|
retryAccount = '';
|
|
if (timer) clearTimer(timer);
|
|
if (key() === accountKey && hasPending(read())) void sync();
|
|
}, delay);
|
|
return true;
|
|
}
|
|
|
|
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, maximum = limit) {
|
|
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 === maximum) break;
|
|
}
|
|
return unique;
|
|
}
|
|
|
|
function normalizePinOps(value) {
|
|
if (!Array.isArray(value)) return [];
|
|
const unique = [];
|
|
for (const candidate of value) {
|
|
const action = candidate?.action;
|
|
const item = action === 'pin' ? normalize(candidate.item) : null;
|
|
const route = action === 'pin' ? item?.route : String(candidate?.route || '');
|
|
if ((action !== 'pin' && action !== 'unpin') || !route || (action === 'pin' && !item)) continue;
|
|
if (!unique.some(existing => (existing.item?.route || existing.route) === route)) {
|
|
const normalized = action === 'pin' ? {action, item} : {action, route};
|
|
if (typeof candidate.operationId === 'string' && candidate.operationId) normalized.operationId = candidate.operationId;
|
|
unique.push(normalized);
|
|
}
|
|
if (unique.length === pinnedLimit) break;
|
|
}
|
|
return unique;
|
|
}
|
|
|
|
function normalizePending(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)) continue;
|
|
if (typeof candidate.operationId === 'string' && candidate.operationId) item.operationId = candidate.operationId;
|
|
unique.push(item);
|
|
if (unique.length === limit) break;
|
|
}
|
|
return unique;
|
|
}
|
|
|
|
function empty() {
|
|
return {items:[], pinned:[], pending:[], pinOps:[]};
|
|
}
|
|
|
|
function read() {
|
|
const storageKey = key();
|
|
if (!storageKey) return empty();
|
|
try {
|
|
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
|
if (Array.isArray(parsed)) return {...empty(), items:normalizeList(parsed)};
|
|
return {
|
|
items:normalizeList(parsed?.items),
|
|
pinned:normalizeList(parsed?.pinned, pinnedLimit),
|
|
pending:normalizePending(parsed?.pending),
|
|
pinOps:normalizePinOps(parsed?.pinOps),
|
|
};
|
|
} catch (_) {
|
|
return empty();
|
|
}
|
|
}
|
|
|
|
function persist(value, accountKey = key()) {
|
|
if (!accountKey || accountKey !== key()) return false;
|
|
try {
|
|
storage.setItem(accountKey, JSON.stringify({
|
|
items:normalizeList(value.items),
|
|
pinned:normalizeList(value.pinned, pinnedLimit),
|
|
pending:normalizePending(value.pending),
|
|
pinOps:normalizePinOps(value.pinOps),
|
|
}));
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function hasPending(value) {
|
|
return value.pending.length > 0 || value.pinOps.length > 0;
|
|
}
|
|
|
|
function announce(value = read(), status = null) {
|
|
if (!options.status) return;
|
|
options.status.textContent = status || (hasPending(value) ? 'Sync pending.' : '');
|
|
}
|
|
|
|
function items() {
|
|
return read().items;
|
|
}
|
|
|
|
function pinned() {
|
|
return read().pinned;
|
|
}
|
|
|
|
function state() {
|
|
const value = read();
|
|
const pendingCount = value.pending.length + value.pinOps.length;
|
|
return {pending:pendingCount > 0, pendingCount};
|
|
}
|
|
|
|
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.pinned = current.pinned.some(existing => existing.route === normalized.route)
|
|
? [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)]
|
|
: current.pinned;
|
|
current.pending = [{...normalized, operationId:operationId()}, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit);
|
|
if (!persist(current, accountKey)) return false;
|
|
announce(current);
|
|
render();
|
|
scheduleSync();
|
|
return true;
|
|
}
|
|
|
|
function queuePinOp(current, operation) {
|
|
const route = operation.item?.route || operation.route;
|
|
current.pinOps = [{...operation, operationId:operationId()}, ...current.pinOps.filter(existing => (existing.item?.route || existing.route) !== route)];
|
|
}
|
|
|
|
function pin(item) {
|
|
const accountKey = key();
|
|
const normalized = normalize(item);
|
|
if (!accountKey || !normalized) return false;
|
|
const current = read();
|
|
current.pinned = [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)].slice(0, pinnedLimit);
|
|
queuePinOp(current, {action:'pin', item:normalized});
|
|
if (!persist(current, accountKey)) return false;
|
|
announce(current);
|
|
render();
|
|
scheduleSync();
|
|
return true;
|
|
}
|
|
|
|
function unpin(route) {
|
|
const accountKey = key();
|
|
route = String(route || '');
|
|
if (!accountKey || !route) return false;
|
|
const current = read();
|
|
if (!current.pinned.some(item => item.route === route)) return false;
|
|
current.pinned = current.pinned.filter(item => item.route !== route);
|
|
queuePinOp(current, {action:'unpin', route});
|
|
if (!persist(current, accountKey)) return false;
|
|
announce(current);
|
|
render();
|
|
scheduleSync();
|
|
return true;
|
|
}
|
|
|
|
function applyPinOps(remote, operations) {
|
|
let result = normalizeList(remote, pinnedLimit);
|
|
for (const operation of [...normalizePinOps(operations)].reverse()) {
|
|
const route = operation.item?.route || operation.route;
|
|
result = operation.action === 'pin'
|
|
? [operation.item, ...result.filter(item => item.route !== route)].slice(0, pinnedLimit)
|
|
: result.filter(item => item.route !== route);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function adopt(snapshot, accountKey, pending = [], pinOps = []) {
|
|
if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false;
|
|
const remote = normalizeList(snapshot.items);
|
|
const remotePinned = normalizeList(snapshot.pinned, pinnedLimit);
|
|
const unsent = normalizePending(pending);
|
|
const unsentPinOps = normalizePinOps(pinOps);
|
|
const value = {
|
|
items:normalizeList([...unsent, ...remote]),
|
|
pinned:applyPinOps(remotePinned, unsentPinOps),
|
|
pending:unsent,
|
|
pinOps:unsentPinOps,
|
|
};
|
|
persist(value, accountKey);
|
|
announce(value);
|
|
render();
|
|
return true;
|
|
}
|
|
|
|
async function drain(accountKey) {
|
|
while (key() === accountKey) {
|
|
const current = read();
|
|
if (!hasPending(current)) return current;
|
|
const sending = current.pending[current.pending.length - 1];
|
|
const pinOperation = sending ? null : current.pinOps[current.pinOps.length - 1];
|
|
announce(current, 'Syncing recent work…');
|
|
try {
|
|
let snapshot;
|
|
if (sending) {
|
|
snapshot = await fetchJson('api/v1/recent-work', {
|
|
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(normalize(sending)),
|
|
});
|
|
} else {
|
|
const isPin = pinOperation.action === 'pin';
|
|
snapshot = await fetchJson('api/v1/recent-work/pin', {
|
|
method:isPin ? 'PUT' : 'DELETE',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify(isPin ? pinOperation.item : {route:pinOperation.route}),
|
|
});
|
|
}
|
|
if (key() !== accountKey) return read();
|
|
const latest = read();
|
|
const pending = sending
|
|
? latest.pending.filter(item => item.operationId !== sending.operationId)
|
|
: latest.pending;
|
|
const pinOps = pinOperation
|
|
? latest.pinOps.filter(operation => {
|
|
const sameRoute = (operation.item?.route || operation.route) === (pinOperation.item?.route || pinOperation.route);
|
|
return !sameRoute || operation.action !== pinOperation.action || operation.operationId !== pinOperation.operationId;
|
|
})
|
|
: latest.pinOps;
|
|
if (!adopt(snapshot, accountKey, pending, pinOps)) throw new Error('Recent work response is invalid.');
|
|
retryAttempt = 0;
|
|
} catch (_error) {
|
|
if (key() === accountKey) {
|
|
announce(read());
|
|
scheduleRetry(accountKey);
|
|
}
|
|
return read();
|
|
}
|
|
}
|
|
return read();
|
|
}
|
|
|
|
function sync() {
|
|
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
|
|
const accountKey = key();
|
|
if (retryTimer && retryAccount !== accountKey) clearRetry(true);
|
|
else if (retryTimer) clearRetry(false);
|
|
if (!fetchJson || !accountKey || !hasPending(read())) 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, current.pinOps);
|
|
return hasPending(current) ? sync() : read();
|
|
} catch (_error) {
|
|
if (key() === accountKey) announce(read(), hasPending(read()) ? null : 'Recent work could not sync.');
|
|
return read();
|
|
}
|
|
}
|
|
|
|
function startLifecycle(lifecycle = {}) {
|
|
const reconcile = () => hasPending(read()) ? sync() : load();
|
|
lifecycle.window?.addEventListener?.('online', () => { void reconcile(); });
|
|
lifecycle.document?.addEventListener?.('visibilitychange', () => {
|
|
if (!lifecycle.document.hidden) void reconcile();
|
|
});
|
|
return reconcile;
|
|
}
|
|
|
|
function detail(item) {
|
|
return item.kind === 'update'
|
|
? 'Update · #' + item.number
|
|
: item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
|
|
}
|
|
|
|
function row(item, isPinned) {
|
|
const itemDetail = detail(item);
|
|
const wrapper = options.document.createElement('div');
|
|
const button = options.document.createElement('button');
|
|
const action = options.document.createElement('button');
|
|
const copy = options.document.createElement('span');
|
|
const primary = options.document.createElement('strong');
|
|
const secondary = options.document.createElement('small');
|
|
wrapper.setAttribute('class', 'mobile-recent-work-row');
|
|
primary.textContent = item.title;
|
|
secondary.textContent = itemDetail;
|
|
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 + ', ' + itemDetail.toLowerCase().replace(' · ', ' '));
|
|
button.addEventListener('click', () => {
|
|
if (isPinned) record(item);
|
|
options.openRoute?.(item.route);
|
|
});
|
|
action.textContent = isPinned ? 'Unpin' : 'Pin';
|
|
action.setAttribute('type', 'button');
|
|
action.setAttribute('data-recent-work-pin', isPinned ? 'unpin' : 'pin');
|
|
action.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + item.title);
|
|
action.addEventListener('click', () => isPinned ? unpin(item.route) : pin(item));
|
|
wrapper.appendChild(button);
|
|
wrapper.appendChild(action);
|
|
return wrapper;
|
|
}
|
|
|
|
function render() {
|
|
const recent = items();
|
|
const fixed = pinned();
|
|
const fixedRoutes = new Set(fixed.map(item => item.route));
|
|
const visibleRecent = recent.filter(item => !fixedRoutes.has(item.route));
|
|
const list = options.list;
|
|
const section = options.section;
|
|
if (list && section && options.document) {
|
|
const rows = visibleRecent.map(item => row(item, false));
|
|
list.replaceChildren(...rows);
|
|
section.hidden = rows.length === 0;
|
|
}
|
|
if (options.pinnedList && options.pinnedSection && options.document) {
|
|
const visiblePins = pinsExpanded ? fixed : fixed.slice(0, 3);
|
|
const rows = visiblePins.map(item => row(item, true));
|
|
options.pinnedList.replaceChildren(...rows);
|
|
options.pinnedSection.hidden = rows.length === 0;
|
|
}
|
|
if (options.pinnedToggle) {
|
|
options.pinnedToggle.hidden = fixed.length <= 3;
|
|
options.pinnedToggle.textContent = pinsExpanded ? 'Show fewer' : 'Show all ' + fixed.length;
|
|
options.pinnedToggle.setAttribute('aria-expanded', pinsExpanded ? 'true' : 'false');
|
|
options.pinnedToggle.setAttribute('aria-controls', 'mobile-pinned-work-list');
|
|
}
|
|
return visibleRecent.length + fixed.length;
|
|
}
|
|
|
|
return {items, pinned, record, pin, unpin, render, load, sync, startLifecycle, state};
|
|
});
|