feat: continue unread updates offline (#347)
CI / lint (pull_request) Successful in 36s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-08 22:42:20 +00:00
1 parent 0539b0a035
commit ae6cde29c2
11 files changed
+161 -14

No files matched your search

+31 -3
View File
@@ -429,6 +429,7 @@
qs('#send-update-reply').disabled = false;
qs('#update-ownership-action').hidden = true;
qs('#retry-update-load').hidden = true;
setOfflineUpdateControls(false);
qs('#keep-update-unread').focus();
},
onDetail: detail => {
@@ -437,7 +438,14 @@
qs('#update-subject-state').textContent = detail.state || '';
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
updateOwnership.open(detail, selectedUpdate);
if (offlineWorkMode) {
setOfflineUpdateControls(true);
} else {
updateOwnership.open(detail, selectedUpdate);
if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) {
offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail);
}
}
qs('#retry-update-load').hidden = true;
},
onConversation: renderUpdateConversation,
@@ -470,7 +478,8 @@
const offlineLogin = planningOwnerLogin || confirmedOwnerLogin ||
String(offlineWorkStore.load()?.user?.login || '').trim();
const savedDetail = offlineWorkStore.loadDetail(offlineLogin, item);
if (!todayWork.contains(item) || !savedDetail) {
const savedUpdate = item.kind === 'update' && item.has_update;
if ((!savedUpdate && !todayWork.contains(item)) || !savedDetail) {
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
return;
}
@@ -480,6 +489,14 @@
} else if (item.kind === 'pull' && !item.is_review) {
pullTrigger = trigger;
openPullSheet(item, trigger, savedDetail);
} else if (savedUpdate) {
updateTrigger = trigger;
notificationReader.open(item, savedDetail).then(opened => {
if (opened && selectedUpdate === item) {
qs('#update-sheet-status').textContent = 'Offline update · saved ' + fmt(savedDetail.saved_at) +
' · replies queue for sync. Reconnect to mark read, take ownership, defer, or load older messages.';
}
});
} else {
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
}
@@ -634,6 +651,13 @@
}
}
function setOfflineUpdateControls(offline) {
qs('#mark-update-read-next').disabled = offline;
qs('#update-ownership-action').disabled = offline;
qs('#load-older-update-comments').disabled = offline;
qs('#update-sheet .detail-defer').inert = offline;
}
function renderContextSnapshot(data) {
liveMode = true;
hasContextSnapshot = true;
@@ -959,7 +983,7 @@
const item = lastMyWork[Number(button.dataset.updateIndex)];
if (!item) return;
updateTrigger = button;
workRoute.open({ ...item, kind:'update' });
openRoutedWork(item, button);
});
});
document.querySelectorAll('[data-notification-id]').forEach(button => {
@@ -3018,6 +3042,10 @@
offlineWorkMode = value;
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
.forEach(selector => { const button = qs(selector); if (button) button.disabled = value; });
if (value) {
document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')
.forEach(button => { button.disabled = true; });
}
}
function hydrateOfflineWork(mode = 'offline') {
const saved = offlineWorkStore.load();
+6 -4
View File
@@ -288,14 +288,16 @@ function createNotificationReader({
let loadVersion = 0;
let marking = false;
let conversationPager = null;
let offlineHydrated = false;
async function open(item) {
async function open(item, savedDetail = null) {
selected = item;
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
const version = ++loadVersion;
onOpen(item);
onStatus('Loading update…');
try {
const detail = await load(item.notification_id);
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
if (selected !== item || version !== loadVersion) return false;
onDetail(detail);
if (createPager && loadConversation && detail.conversation) {
@@ -324,7 +326,7 @@ function createNotificationReader({
return true;
},
async loadOlder() {
if (!conversationPager || !selected) return false;
if (!conversationPager || !selected || offlineHydrated) return false;
const pager = conversationPager;
const version = loadVersion;
onStatus('Loading older messages…');
@@ -342,7 +344,7 @@ function createNotificationReader({
}
},
async markReadAndNext(items) {
if (!selected || marking) return false;
if (!selected || marking || offlineHydrated) return false;
const current = selected;
marking = true;
onStatus('Marking update read…');
+5
View File
@@ -19,6 +19,7 @@
];
const DETAIL_FIELDS = [
'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone',
'id', 'repository', 'subject_type', 'subject_body',
];
const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url'];
@@ -48,6 +49,10 @@
}
function detailKey(item) {
if (item?.kind === 'update') {
const notificationId = Number(item?.notification_id || 0);
return Number.isInteger(notificationId) && notificationId > 0 ? 'update:' + notificationId : '';
}
const kind = item?.kind === 'pull' ? 'pull' : item?.kind === 'issue' ? 'issue' : '';
const repository = String(item?.repository || '');
const number = Number(item?.number || 0);
+1 -1
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v39';
const CACHE = 'stackchain-dashboard-shell-v40';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,