feat: read full unread update conversations (#219)
CI / lint (pull_request) Successful in 20s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy committed 2026-08-07 18:48:37 +00:00
1 parent 3824ff180a
commit ea909ef222
8 files changed
+379 -20

No files matched your search

+39 -10
View File
@@ -548,9 +548,10 @@ textarea { resize: vertical; min-height: 120px; }
<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>
<h2>Full conversation</h2>
<div id="update-comments"></div>
<button class="conversation-more" id="load-older-update-comments" type="button" hidden>Load older messages</button>
<div id="update-conversation-status" class="small" aria-live="assertive"></div>
<details>
<summary><h2>Subject context</h2></summary>
<p class="update-sheet-content muted" id="update-subject-body"></p>
@@ -845,6 +846,16 @@ textarea { resize: vertical; min-height: 120px; }
return payload;
}
async function fetchNotificationConversation(notificationId, page) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
'/conversation?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading older messages failed.');
return payload;
}
async function fetchWorkPage(stream, page) {
const response = await fetch('api/v1/work/' + encodeURIComponent(stream) + '?page=' + encodeURIComponent(page), {
headers: { Accept: 'application/json' },
@@ -924,14 +935,16 @@ textarea { resize: vertical; min-height: 120px; }
});
const notificationReader = createNotificationReader({
load: fetchNotificationDetail,
loadConversation: fetchNotificationConversation,
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-comments').textContent = '';
qs('#update-conversation-status').textContent = '';
qs('#load-older-update-comments').hidden = true;
qs('#update-subject-body').textContent = '';
qs('#update-subject-type').textContent = item.subject_type || 'Update';
qs('#update-subject-state').textContent = item.state || '';
@@ -943,18 +956,14 @@ textarea { resize: vertical; min-height: 120px; }
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;
},
onConversation: renderUpdateConversation,
onItems: items => {
const readId = selectedUpdate?.notification_id;
lastMyWork = items;
@@ -1365,6 +1374,15 @@ textarea { resize: vertical; min-height: 120px; }
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
}
function renderUpdateConversation(state) {
const comments = state?.comments || [];
qs('#update-comments').innerHTML = comments.length ?
comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-update-comments').hidden = !Number.isInteger(state?.older_page);
qs('#update-conversation-status').textContent = comments.length ?
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
}
function renderPullConversation(state) {
const comments = state?.comments || [];
qs('#pull-comments').innerHTML = comments.length ? comments.map(comment =>
@@ -2713,6 +2731,16 @@ textarea { resize: vertical; min-height: 120px; }
qs('#retry-update-load').addEventListener('click', () => {
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
});
qs('#load-older-update-comments').addEventListener('click', async () => {
const button = qs('#load-older-update-comments');
const panel = qs('#update-sheet .update-sheet-panel');
const previousHeight = panel.scrollHeight;
const previousTop = panel.scrollTop;
button.disabled = true;
await notificationReader.loadOlder();
panel.scrollTop = previousTop + (panel.scrollHeight - previousHeight);
button.disabled = false;
});
qs('#update-reply').addEventListener('input', event => {
if (selectedUpdate) notificationReplier.saveDraft(selectedUpdate, event.target.value);
});
@@ -2728,6 +2756,7 @@ textarea { resize: vertical; min-height: 120px; }
const result = await notificationReplier.submit(selectedUpdate, body);
qs('#send-update-reply').disabled = false;
if (result) {
notificationReader.appendReply(result);
qs('#update-reply').value = '';
qs('#mark-update-read-next').focus();
} else {
+38 -1
View File
@@ -249,10 +249,16 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
};
}
function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, onStatus, onClose }) {
function createNotificationReader({
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
loadConversation = null,
onConversation = () => {},
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
}) {
let selected = null;
let loadVersion = 0;
let marking = false;
let conversationPager = null;
async function open(item) {
selected = item;
@@ -263,6 +269,14 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
const detail = await load(item.notification_id);
if (selected !== item || version !== loadVersion) return false;
onDetail(detail);
if (createPager && loadConversation && detail.conversation) {
conversationPager = createPager({
loadPage: page => loadConversation(item.notification_id, page),
});
onConversation(conversationPager.reset(detail.conversation));
} else {
conversationPager = null;
}
onStatus('Update ready.');
return true;
} catch (_error) {
@@ -275,6 +289,29 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
return {
open,
appendReply(comment) {
if (!conversationPager) return false;
onConversation(conversationPager.append(comment));
return true;
},
async loadOlder() {
if (!conversationPager || !selected) return false;
const pager = conversationPager;
const version = loadVersion;
onStatus('Loading older messages…');
try {
const state = await pager.loadOlder();
if (pager !== conversationPager || version !== loadVersion) return false;
onConversation(state);
onStatus(state.comments.length + ' of ' + state.total + ' messages loaded.');
return true;
} catch (_error) {
if (pager === conversationPager && version === loadVersion) {
onStatus('Could not load older messages. Retry.');
}
return false;
}
},
async markReadAndNext(items) {
if (!selected || marking) return false;
const current = selected;