feat: bind offline outboxes to Gitea identity (#238)
This commit is contained in:
1 parent
d155f0081f
commit
a73b3bc773
9 files changed
+237
-63
No files matched your search
+21
-10
@@ -1,4 +1,4 @@
|
||||
function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 50 }) {
|
||||
function createAuthoredOutbox({ storage, fetchJson, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
|
||||
const storageKey = 'stackchain.authored-outbox.v1';
|
||||
const makeId = createOperationId || (() =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
@@ -9,17 +9,19 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item && supportedKinds.has(item.kind));
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
}
|
||||
|
||||
function enqueue(message) {
|
||||
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
|
||||
const items = read();
|
||||
const requestedOperationId = String(message.operationId || '').slice(0, 128);
|
||||
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
|
||||
@@ -34,6 +36,7 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
|
||||
number: Number(message.number || 0),
|
||||
notificationId: Number(message.notificationId || 0),
|
||||
body: String(message.body || ''),
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
@@ -75,7 +78,8 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
|
||||
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
|
||||
}
|
||||
|
||||
async function sendItem(item) {
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const request = (async () => {
|
||||
try {
|
||||
@@ -108,23 +112,30 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = ()
|
||||
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
async function flush(currentLogin) {
|
||||
const confirmed = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
const outcome = await sendItem(item);
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const outcome = await sendItem(item, currentLogin);
|
||||
if (outcome.result) confirmed.push(outcome.result);
|
||||
if (outcome.transient) break;
|
||||
}
|
||||
return { confirmed, remaining: read() };
|
||||
return { confirmed, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function retry(id) {
|
||||
async function retry(id, currentLogin) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return { confirmed: [], remaining: read() };
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
return { confirmed: [], remaining: read(), blocked: 1 };
|
||||
}
|
||||
const queued = item.status === 'attention' ? update(id, item) : item;
|
||||
const outcome = await sendItem(queued);
|
||||
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read() };
|
||||
const outcome = await sendItem(queued, currentLogin);
|
||||
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 };
|
||||
}
|
||||
|
||||
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
|
||||
|
||||
+21
-5
@@ -1,4 +1,4 @@
|
||||
function createDraftInbox({ storage, now = () => Date.now() }) {
|
||||
function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Date.now() }) {
|
||||
const indexKey = 'stackchain.draft-index.v1';
|
||||
|
||||
function readIndex() {
|
||||
@@ -101,10 +101,14 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
|
||||
function parseOutbox(raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return record.items.filter(item =>
|
||||
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
|
||||
).map(item => ({
|
||||
).map(item => {
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
return {
|
||||
id: 'stackchain.issue-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
kind: 'issue-outbox',
|
||||
@@ -113,20 +117,28 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
|
||||
repository: item.repository,
|
||||
title: textPreview(item.title) || 'Untitled queued issue',
|
||||
preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
|
||||
copy_text: [item.title, item.body].filter(Boolean).join('\n\n'),
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
}));
|
||||
};
|
||||
});
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function parseAuthoredOutbox(raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
|
||||
.map(item => {
|
||||
const isUpdate = item.kind === 'update-reply';
|
||||
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
return {
|
||||
id: 'stackchain.authored-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
@@ -136,6 +148,10 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
|
||||
repository: isUpdate ? '' : item.repository,
|
||||
title: target,
|
||||
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
||||
copy_text: item.body,
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
|
||||
{ kind:routeKind, repository:item.repository, number:item.number },
|
||||
|
||||
+39
-16
@@ -847,6 +847,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
let bulkMarkPending = false;
|
||||
let reviewHandoffPending = false;
|
||||
let editingOutboxId = null;
|
||||
let confirmedOwnerLogin = '';
|
||||
let activeFlushLogin = '';
|
||||
|
||||
async function fetchReviewJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
@@ -876,8 +878,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
loadMilestones: item => issueController.loadMilestones(item),
|
||||
});
|
||||
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
|
||||
const authoredOutbox = createAuthoredOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
|
||||
const issueOutbox = createIssueOutbox({
|
||||
storage: localStorage, fetchJson: fetchReviewJson, getOwnerLogin: () => confirmedOwnerLogin,
|
||||
});
|
||||
const authoredOutbox = createAuthoredOutbox({
|
||||
storage: localStorage, fetchJson: fetchReviewJson, getOwnerLogin: () => confirmedOwnerLogin,
|
||||
});
|
||||
const shareParams = new URLSearchParams(location.search);
|
||||
const sharedLaunch = {
|
||||
title: shareParams.get('title') || '',
|
||||
@@ -887,7 +893,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
let sharedLaunchState = Object.values(sharedLaunch).some(Boolean) ?
|
||||
issueCapture.stageSharedContent(sharedLaunch) : null;
|
||||
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const draftInbox = createDraftInbox({ storage: localStorage });
|
||||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
|
||||
const findWorkController = createFindWork({
|
||||
fetchJson: fetchReviewJson,
|
||||
@@ -1242,6 +1248,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
function handleContextError(e) {
|
||||
console.error('context failed', e);
|
||||
liveMode = false;
|
||||
activeFlushLogin = '';
|
||||
if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;
|
||||
setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable');
|
||||
if (!hasContextSnapshot) {
|
||||
@@ -1301,7 +1308,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
const list = qs('#my-work-list');
|
||||
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
|
||||
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
||||
const outboxActions = item.kind === 'issue-outbox' ?
|
||||
const outboxActions = item.quarantined ?
|
||||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||
item.kind === 'issue-outbox' ?
|
||||
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
|
||||
'<button class="draft-send" data-draft-index="' + index + '" type="button">Send now</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||
@@ -1312,7 +1322,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Resume draft</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
|
||||
const state = isOutbox ?
|
||||
'<span class="pill">' + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync') + '</span>' : '';
|
||||
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
|
||||
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
||||
return '<article class="my-work-card draft-card">' +
|
||||
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||
@@ -1344,8 +1356,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item?.outbox_id) return;
|
||||
button.disabled = true;
|
||||
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id));
|
||||
else applyOutboxResult(await issueOutbox.retry(item.outbox_id));
|
||||
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id, activeFlushLogin));
|
||||
else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin));
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-copy').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item?.copy_text) return;
|
||||
await navigator.clipboard.writeText(item.copy_text);
|
||||
qs('#my-work-action-status').textContent = 'Queued content copied without sending it.';
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-discard').forEach(button => {
|
||||
@@ -2022,8 +2042,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
|
||||
async function flushIssueOutbox() {
|
||||
if (!navigator.onLine || !issueOutbox.list().length) return;
|
||||
applyOutboxResult(await issueOutbox.flush());
|
||||
if (!navigator.onLine || !activeFlushLogin || !issueOutbox.list().length) return;
|
||||
applyOutboxResult(await issueOutbox.flush(activeFlushLogin));
|
||||
}
|
||||
|
||||
function applyAuthoredOutboxResult(result) {
|
||||
@@ -2036,8 +2056,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
|
||||
async function flushAuthoredOutbox() {
|
||||
if (!navigator.onLine || !authoredOutbox.list().length) return;
|
||||
applyAuthoredOutboxResult(await authoredOutbox.flush());
|
||||
if (!navigator.onLine || !activeFlushLogin || !authoredOutbox.list().length) return;
|
||||
applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin));
|
||||
}
|
||||
|
||||
function canQueueMessage(error) {
|
||||
@@ -2259,6 +2279,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
if (snapshot.context) {
|
||||
setOfflineWorkMode(false);
|
||||
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
||||
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (activeFlushLogin) confirmedOwnerLogin = activeFlushLogin;
|
||||
snapshot.context.notifications = lastNotifications;
|
||||
renderContextSnapshot(snapshot.context);
|
||||
if (contextFreshness?.stale) markMyWorkStale();
|
||||
@@ -2271,6 +2295,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
});
|
||||
updateOfflineWorkControls();
|
||||
}
|
||||
flushIssueOutbox();
|
||||
flushAuthoredOutbox();
|
||||
} else handleContextError(new Error('Context section unavailable'));
|
||||
if (Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
|
||||
if (eventsFreshness?.revalidating) {
|
||||
@@ -2669,7 +2695,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
closeCreateIssueSheet();
|
||||
refreshMyWorkView();
|
||||
qs('#my-work-action-status').textContent = 'Queued for sync.';
|
||||
if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id), true);
|
||||
if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id, activeFlushLogin), true);
|
||||
} catch (error) {
|
||||
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||
button.disabled = false;
|
||||
@@ -3222,6 +3248,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
return true;
|
||||
}
|
||||
function showOfflineStatus() {
|
||||
activeFlushLogin = '';
|
||||
offlineStatus.hidden = false;
|
||||
setStatus('Offline');
|
||||
if (!hasContextSnapshot) hydrateOfflineWork();
|
||||
@@ -3248,8 +3275,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
if (!navigator.onLine) showOfflineStatus();
|
||||
window.addEventListener('offline', showOfflineStatus);
|
||||
window.addEventListener('online', reconnectLiveData);
|
||||
window.addEventListener('online', flushIssueOutbox);
|
||||
window.addEventListener('online', flushAuthoredOutbox);
|
||||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
qs('#start-work-session').addEventListener('click', () => {
|
||||
@@ -3334,8 +3359,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
);
|
||||
}
|
||||
contextPoller.start();
|
||||
flushIssueOutbox();
|
||||
flushAuthoredOutbox();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
contextPoller.setVisible(!document.hidden);
|
||||
});
|
||||
|
||||
+21
-10
@@ -1,4 +1,4 @@
|
||||
function createIssueOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 20 }) {
|
||||
function createIssueOutbox({ storage, fetchJson, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) {
|
||||
const storageKey = 'stackchain.issue-outbox.v1';
|
||||
const operationId = createOperationId || (() =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
@@ -8,16 +8,18 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item && typeof item === 'object');
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
}
|
||||
|
||||
function enqueue(draft) {
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
|
||||
const items = read();
|
||||
if (items.length >= maxItems) throw new Error('Issue outbox is full. Send or discard a queued issue first.');
|
||||
const item = {
|
||||
@@ -27,6 +29,7 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
|
||||
title: String(draft?.title || ''),
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
@@ -64,7 +67,8 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendItem(item) {
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
const request = (async () => {
|
||||
@@ -98,23 +102,30 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () =>
|
||||
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
async function flush(currentLogin) {
|
||||
const confirmed = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
const result = await sendItem(item);
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const result = await sendItem(item, currentLogin);
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.transient) break;
|
||||
}
|
||||
return { confirmed, remaining: read() };
|
||||
return { confirmed, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function retry(id) {
|
||||
async function retry(id, currentLogin) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return { confirmed: [], remaining: read() };
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
return { confirmed: [], remaining: read(), blocked: 1 };
|
||||
}
|
||||
update(id, item);
|
||||
const result = await sendItem({ ...item, status: 'queued' });
|
||||
return { confirmed: result.issue ? [result.issue] : [], remaining: read() };
|
||||
const result = await sendItem({ ...item, status: 'queued' }, currentLogin);
|
||||
return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 };
|
||||
}
|
||||
|
||||
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'stackchain-dashboard-shell-v7';
|
||||
const CACHE = 'stackchain-dashboard-shell-v8';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
const SHELL = [
|
||||
|
||||
Reference in new issue
Block a user