feat: triage unread updates in My Work (#151)
This commit is contained in:
1 parent
5b864c028e
commit
f7073e1393
7 files changed
+628
-8
No files matched your search
+128
-3
@@ -66,6 +66,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.mark-update-read { min-height:44px; width:100%; }
|
||||
.read-update { min-height:44px; width:100%; }
|
||||
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
|
||||
.load-more-notifications[hidden] { display:none; }
|
||||
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
@@ -102,6 +103,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; }
|
||||
.review-action { min-height:44px; }
|
||||
.review-retry { min-height:44px; margin-top:10px; }
|
||||
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.update-sheet.open { display:flex; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
@media (max-width: 600px) {
|
||||
header { align-items:flex-start; }
|
||||
.my-work { margin:0; }
|
||||
@@ -109,6 +119,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-filters { width:100%; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@@ -235,6 +246,35 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
|
||||
<section class="update-sheet-panel">
|
||||
<div class="update-sheet-header">
|
||||
<div>
|
||||
<div class="small" id="update-sheet-key"></div>
|
||||
<h3 id="update-sheet-title">Unread update</h3>
|
||||
</div>
|
||||
<button id="keep-update-unread" type="button">Keep unread</button>
|
||||
</div>
|
||||
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
|
||||
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
|
||||
<div class="row">
|
||||
<span class="pill" id="update-subject-type">Update</span>
|
||||
<span class="pill" id="update-subject-state"></span>
|
||||
</div>
|
||||
<h2>Latest comment</h2>
|
||||
<div class="small" id="update-comment-meta"></div>
|
||||
<p class="update-sheet-content" id="update-comment-body"></p>
|
||||
<details>
|
||||
<summary><h2>Subject context</h2></summary>
|
||||
<p class="update-sheet-content muted" id="update-subject-body"></p>
|
||||
</details>
|
||||
<div class="update-sheet-actions">
|
||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="review-sheet" id="review-sheet" role="dialog" aria-modal="true" aria-labelledby="review-sheet-title">
|
||||
<section class="review-sheet-panel">
|
||||
<div class="review-sheet-header">
|
||||
@@ -327,6 +367,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
let hasContextSnapshot = false;
|
||||
let selectedReview = null;
|
||||
let reviewTrigger = null;
|
||||
let selectedUpdate = null;
|
||||
let updateTrigger = null;
|
||||
let progress = null;
|
||||
let draft = null;
|
||||
let reviewFiles = [];
|
||||
@@ -382,6 +424,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function fetchNotificationDetail(notificationId) {
|
||||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId), {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Loading the update failed.');
|
||||
return payload;
|
||||
}
|
||||
|
||||
const notificationAcknowledger = createNotificationAcknowledger({
|
||||
markRead: markNotificationRead,
|
||||
onItems: items => {
|
||||
@@ -417,6 +468,51 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
},
|
||||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
const notificationReader = createNotificationReader({
|
||||
load: fetchNotificationDetail,
|
||||
markRead: markNotificationRead,
|
||||
onOpen: item => {
|
||||
selectedUpdate = item;
|
||||
qs('#update-sheet').classList.add('open');
|
||||
qs('#update-sheet-key').textContent = item.key || '';
|
||||
qs('#update-sheet-title').textContent = item.title || 'Unread update';
|
||||
qs('#update-comment-meta').textContent = '';
|
||||
qs('#update-comment-body').textContent = '';
|
||||
qs('#update-subject-body').textContent = '';
|
||||
qs('#update-subject-type').textContent = item.subject_type || 'Update';
|
||||
qs('#update-subject-state').textContent = item.state || '';
|
||||
qs('#open-update-gitea').href = item.url || '#';
|
||||
qs('#retry-update-load').hidden = true;
|
||||
qs('#keep-update-unread').focus();
|
||||
},
|
||||
onDetail: detail => {
|
||||
const comment = detail.latest_comment || {};
|
||||
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
|
||||
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
|
||||
qs('#update-subject-state').textContent = detail.state || '';
|
||||
qs('#update-comment-meta').textContent = comment.author ?
|
||||
comment.author + (comment.created_at ? ' · ' + fmt(comment.created_at) : '') :
|
||||
(comment.created_at ? fmt(comment.created_at) : 'No comment author reported');
|
||||
qs('#update-comment-body').textContent = comment.body || 'No comment body was provided.';
|
||||
qs('#update-subject-body').textContent = detail.subject_body || 'No subject context was provided.';
|
||||
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
|
||||
qs('#retry-update-load').hidden = true;
|
||||
},
|
||||
onItems: items => {
|
||||
const readId = selectedUpdate?.notification_id;
|
||||
lastMyWork = items;
|
||||
if (Number.isInteger(readId)) {
|
||||
lastNotifications = lastNotifications.filter(item => item.id !== readId);
|
||||
}
|
||||
refreshMyWorkView();
|
||||
},
|
||||
onStatus: message => {
|
||||
qs('#update-sheet-status').textContent = message;
|
||||
qs('#retry-update-load').hidden = !message.startsWith('Could not load update.');
|
||||
if (message === 'Inbox cleared.') qs('#my-work-action-status').textContent = message;
|
||||
},
|
||||
onClose: () => closeUpdateSheet(false),
|
||||
});
|
||||
|
||||
function renderContextSnapshot(data) {
|
||||
liveMode = true;
|
||||
@@ -481,6 +577,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
function renderMyWork() {
|
||||
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
|
||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||||
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
||||
const contents =
|
||||
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
|
||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||
@@ -489,15 +586,24 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
||||
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
|
||||
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
|
||||
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
|
||||
'<button class="read-update" data-update-index="' + index + '">Read update</button>' : '';
|
||||
if (item.is_review) {
|
||||
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
||||
return '<article class="my-work-card"><button class="my-work-card-main review-trigger" data-review-index="' + index + '">' + contents + '</button>' + markRead + '</article>';
|
||||
return '<article class="my-work-card"><button class="my-work-card-main review-trigger" data-review-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
|
||||
}
|
||||
return '<article class="my-work-card"><a class="my-work-card-main" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>' + markRead + '</article>';
|
||||
return '<article class="my-work-card"><a class="my-work-card-main" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>' + readUpdate + markRead + '</article>';
|
||||
}).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.</div>';
|
||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||
button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], button));
|
||||
});
|
||||
document.querySelectorAll('[data-update-index]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const item = lastMyWork[Number(button.dataset.updateIndex)];
|
||||
if (!item) return;
|
||||
updateTrigger = button;
|
||||
notificationReader.open(item, lastMyWork);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-notification-id]').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const notificationId = Number(button.dataset.notificationId);
|
||||
@@ -652,6 +758,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
if (reviewTrigger?.isConnected) reviewTrigger.focus();
|
||||
}
|
||||
|
||||
function closeUpdateSheet(restoreTrigger = true) {
|
||||
qs('#update-sheet').classList.remove('open');
|
||||
selectedUpdate = null;
|
||||
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
|
||||
else qs('[data-work-filter="update"]')?.focus();
|
||||
}
|
||||
|
||||
function markMyWorkStale() {
|
||||
qs('#my-work').setAttribute('data-stale', 'true');
|
||||
qs('#my-work-status').textContent = lastMyWork.length ?
|
||||
@@ -793,6 +906,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
|
||||
document.addEventListener('keydown', (e) => { if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); qs('#cmd-palette').classList.toggle('open'); if(qs('#cmd-palette').classList.contains('open')){ qs('#cmd-input').focus(); renderCommands(''); } } });
|
||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||
qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true));
|
||||
qs('#retry-update-load').addEventListener('click', () => {
|
||||
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
|
||||
});
|
||||
qs('#mark-update-read-next').addEventListener('click', async () => {
|
||||
qs('#mark-update-read-next').disabled = true;
|
||||
try {
|
||||
await notificationReader.markReadAndNext(lastMyWork);
|
||||
} finally {
|
||||
qs('#mark-update-read-next').disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
|
||||
qs('#retry-review-load').addEventListener('click', () => {
|
||||
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
|
||||
|
||||
@@ -169,6 +169,63 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, onStatus, onClose }) {
|
||||
let selected = null;
|
||||
let loadVersion = 0;
|
||||
let marking = false;
|
||||
|
||||
async function open(item) {
|
||||
selected = item;
|
||||
const version = ++loadVersion;
|
||||
onOpen(item);
|
||||
onStatus('Loading update…');
|
||||
try {
|
||||
const detail = await load(item.notification_id);
|
||||
if (selected !== item || version !== loadVersion) return false;
|
||||
onDetail(detail);
|
||||
onStatus('Update ready.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (selected === item && version === loadVersion) {
|
||||
onStatus('Could not load update. Retry or open it in Gitea.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
async markReadAndNext(items) {
|
||||
if (!selected || marking) return false;
|
||||
const current = selected;
|
||||
marking = true;
|
||||
onStatus('Marking update read…');
|
||||
try {
|
||||
await markRead(current.notification_id);
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item =>
|
||||
item && item.has_update && Number.isInteger(item.notification_id)
|
||||
);
|
||||
if (next) await open(next);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
} catch (_error) {
|
||||
onStatus('Could not mark update read. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
marking = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function filterMyWork(items, selectedFilter) {
|
||||
if (selectedFilter === 'all') return items;
|
||||
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
|
||||
@@ -205,5 +262,6 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.notificationIds = notificationIds;
|
||||
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
||||
buildMyWork.createNotificationPager = createNotificationPager;
|
||||
buildMyWork.createNotificationReader = createNotificationReader;
|
||||
module.exports = buildMyWork;
|
||||
}
|
||||
Reference in new issue
Block a user