feat: load complete mobile conversations (#209)
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 4s

This commit is contained in:
timmy committed 2026-08-07 16:22:30 +00:00
1 parent e04d6753ac
commit c63b23865c
9 files changed
+528 -30

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
function createConversationPager({ loadPage }) {
let state = { comments: [], page: 1, older_page: null, total: 0 };
let olderRequest = null;
const validComments = comments => Array.isArray(comments)
? comments.filter(comment => comment && Number.isInteger(comment.id))
: [];
const unique = comments => {
const seen = new Set();
return comments.filter(comment => {
if (seen.has(comment.id)) return false;
seen.add(comment.id);
return true;
}).sort((left, right) => {
const leftTime = Date.parse(left.created_at || '');
const rightTime = Date.parse(right.created_at || '');
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return left.id - right.id;
});
};
const snapshot = () => ({ ...state, comments: state.comments.map(comment => ({ ...comment })) });
return {
reset(page) {
const comments = unique(validComments(page?.comments));
state = {
comments,
page: Number.isInteger(page?.page) ? page.page : 1,
older_page: Number.isInteger(page?.older_page) ? page.older_page : null,
total: Number.isInteger(page?.total) ? Math.max(page.total, comments.length) : comments.length,
};
olderRequest = null;
return snapshot();
},
snapshot,
loadOlder() {
if (olderRequest) return olderRequest;
if (!Number.isInteger(state.older_page)) return Promise.resolve(snapshot());
const requestedPage = state.older_page;
olderRequest = Promise.resolve(loadPage(requestedPage)).then(page => {
state = {
comments: unique(validComments(page?.comments).concat(state.comments)),
page: Number.isInteger(page?.page) ? page.page : requestedPage,
older_page: Number.isInteger(page?.older_page) ? page.older_page : null,
total: Number.isInteger(page?.total) ? Math.max(page.total, state.comments.length) : state.total,
};
return snapshot();
}).finally(() => { olderRequest = null; });
return olderRequest;
},
append(comment) {
if (!comment || !Number.isInteger(comment.id)) return snapshot();
if (!state.comments.some(existing => existing.id === comment.id)) {
state = {
...state,
comments: state.comments.concat(comment),
total: Math.max(state.total + 1, state.comments.length + 1),
};
}
return snapshot();
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationPager;
+76 -13
View File
@@ -212,6 +212,7 @@ textarea { resize: vertical; min-height: 120px; }
.pull-sheet-header button, .pull-sheet-actions button, .pull-sheet-actions a, .pull-comment-composer button { min-height:44px; }
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
.conversation-more { min-height:44px; width:100%; margin:8px 0; }
.pull-file-toggle, .pull-review-file { min-height:44px; width:100%; }
.pull-file-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; text-align:left; }
.pull-review-file { margin-top:8px; }
@@ -449,8 +450,10 @@ textarea { resize: vertical; min-height: 120px; }
</div>
<div id="issue-edit-status" class="small" aria-live="assertive"></div>
</form>
<h2>Recent discussion</h2>
<h2>Full conversation</h2>
<div id="issue-comments"></div>
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
<div id="issue-conversation-status" class="small" aria-live="assertive"></div>
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
<h2 id="issue-comment-title">Add comment</h2>
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
@@ -569,7 +572,9 @@ textarea { resize: vertical; min-height: 120px; }
<h2>Changed files</h2>
<div class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
<div id="pull-files"></div>
<h2>Recent discussion</h2><div id="pull-comments"></div>
<h2>Full conversation</h2><div id="pull-comments"></div>
<button class="conversation-more" id="load-older-pull-comments" type="button" hidden>Load older messages</button>
<div id="pull-conversation-status" class="small" aria-live="assertive"></div>
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
<h2 id="pull-comment-title">Add comment</h2>
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
@@ -657,6 +662,7 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/widgets.js"></script>
<script src="static/my-work.js"></script>
<script src="static/pick-work.js"></script>
<script src="static/conversation.js"></script>
<script src="static/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script>
<script src="static/pull-sheet.js"></script>
@@ -713,10 +719,12 @@ textarea { resize: vertical; min-height: 120px; }
let updateTrigger = null;
let selectedIssue = null;
let selectedIssueDetail = null;
let issueConversation = null;
let issueTrigger = null;
let selectedPull = null;
let pullTrigger = null;
let selectedPullDetail = null;
let pullConversation = null;
let pullReviewState = null;
let creatingIssue = false;
let findingWork = false;
@@ -1271,13 +1279,32 @@ textarea { resize: vertical; min-height: 120px; }
}
function renderIssueComment(comment) {
return '<div class="issue-comment"><div class="small">' +
return '<div class="issue-comment" data-comment-id="' + Number(comment.id || 0) + '"><div class="small">' +
escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') +
'</div><div class="issue-sheet-content">' +
escapeHtml(comment.body || 'No comment body provided.') + '</div></div>';
}
function renderIssueConversation(state) {
const comments = state?.comments || [];
qs('#issue-comments').innerHTML = comments.length ?
comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-issue-comments').hidden = !Number.isInteger(state?.older_page);
qs('#issue-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 =>
'<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>'
).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-pull-comments').hidden = !Number.isInteger(state?.older_page);
qs('#pull-conversation-status').textContent = comments.length ?
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
}
async function loadIssueLabelEditor(item, confirmedNames) {
const list = qs('#issue-label-list');
const status = qs('#issue-label-status');
@@ -1336,6 +1363,7 @@ textarea { resize: vertical; min-height: 120px; }
if (!item) return;
selectedIssue = item;
selectedIssueDetail = null;
issueConversation = null;
issueTrigger = trigger;
qs('#issue-sheet').classList.add('open');
qs('#issue-sheet-key').textContent = item.key || '';
@@ -1345,6 +1373,8 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-labels').textContent = '';
qs('#issue-assignees').textContent = '';
qs('#issue-comments').textContent = '';
qs('#load-older-issue-comments').hidden = true;
qs('#issue-conversation-status').textContent = 'Loading newest messages…';
qs('#issue-comment').value = issueController.loadDraft(item);
qs('#issue-comment-status').textContent = '';
qs('#issue-label-list').textContent = '';
@@ -1371,6 +1401,7 @@ textarea { resize: vertical; min-height: 120px; }
const detail = await issueController.load(item);
if (selectedIssue !== item) return;
selectedIssueDetail = detail;
issueConversation = issueController.conversation(item, detail.conversation);
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
qs('#issue-sheet-body').textContent = detail.body || 'No description provided.';
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
@@ -1380,8 +1411,7 @@ textarea { resize: vertical; min-height: 120px; }
loadIssueMilestoneEditor(item, detail.milestone);
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
qs('#issue-comments').innerHTML = (detail.comments || []).length ?
detail.comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
renderIssueConversation(issueConversation.snapshot());
qs('#open-issue-gitea').href = detail.url || item.url || '#';
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
qs('#edit-issue-content').disabled = false;
@@ -1408,6 +1438,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-sheet').classList.remove('open');
selectedIssue = null;
selectedIssueDetail = null;
issueConversation = null;
if (issueTrigger?.isConnected) issueTrigger.focus();
}
@@ -1463,6 +1494,7 @@ textarea { resize: vertical; min-height: 120px; }
selectedPull = item;
pullTrigger = trigger;
selectedPullDetail = null;
pullConversation = null;
pullReviewState = null;
qs('#pull-sheet').classList.add('open');
qs('#pull-sheet-key').textContent = item.key || '';
@@ -1473,6 +1505,8 @@ textarea { resize: vertical; min-height: 120px; }
qs('#pull-review-progress').textContent = 'Loading review progress…';
qs('#next-unreviewed-pull-file').disabled = true;
qs('#pull-comments').textContent = '';
qs('#load-older-pull-comments').hidden = true;
qs('#pull-conversation-status').textContent = 'Loading newest messages…';
qs('#pull-comment').value = pullController.loadDraft(item);
qs('#pull-comment-status').textContent = '';
qs('#pull-ci-state').textContent = 'CI unknown';
@@ -1485,13 +1519,12 @@ textarea { resize: vertical; min-height: 120px; }
const detail = await pullController.load(item);
if (selectedPull !== item) return;
selectedPullDetail = detail;
pullConversation = pullController.conversation(item, detail.conversation);
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
qs('#pull-sheet-body').textContent = detail.body || 'No description provided.';
qs('#pull-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
renderPullReview(detail);
qs('#pull-comments').innerHTML = (detail.comments || []).length ? detail.comments.map(comment =>
'<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>'
).join('') : '<div class="muted">No comments yet.</div>';
renderPullConversation(pullConversation.snapshot());
qs('#open-pull-gitea').href = detail.url || item.url || '#';
qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author');
} catch (error) {
@@ -1510,6 +1543,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#pull-sheet').classList.remove('open');
selectedPull = null;
selectedPullDetail = null;
pullConversation = null;
pullReviewState = null;
if (pullTrigger?.isConnected) pullTrigger.focus();
}
@@ -2326,6 +2360,22 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-comment').addEventListener('input', event => {
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
});
qs('#load-older-issue-comments').addEventListener('click', async () => {
if (!issueConversation) return;
const button = qs('#load-older-issue-comments');
const panel = qs('#issue-sheet .issue-sheet-panel');
const previousHeight = panel.scrollHeight;
button.disabled = true;
qs('#issue-conversation-status').textContent = 'Loading older messages…';
try {
renderIssueConversation(await issueConversation.loadOlder());
panel.scrollTop += panel.scrollHeight - previousHeight;
} catch (error) {
qs('#issue-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.';
} finally {
button.disabled = false;
}
});
qs('#save-issue-labels').addEventListener('click', async () => {
if (!selectedIssue || !lastContextSnapshot) return;
const editing = selectedIssue;
@@ -2428,9 +2478,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-comment-status').textContent = 'Posting comment…';
try {
const comment = await issueController.comment(selectedIssue, body);
const empty = qs('#issue-comments .muted');
if (empty) empty.remove();
qs('#issue-comments').insertAdjacentHTML('beforeend', renderIssueComment(comment));
if (issueConversation) renderIssueConversation(issueConversation.append(comment));
qs('#issue-comment').value = '';
qs('#issue-comment-status').textContent = 'Comment posted.';
} catch (error) {
@@ -2486,6 +2534,22 @@ textarea { resize: vertical; min-height: 120px; }
if (selectedPull) openPullSheet(selectedPull, pullTrigger);
});
qs('#next-unreviewed-pull-file').addEventListener('click', focusNextUnreviewedPullFile);
qs('#load-older-pull-comments').addEventListener('click', async () => {
if (!pullConversation) return;
const button = qs('#load-older-pull-comments');
const panel = qs('#pull-sheet .pull-sheet-panel');
const previousHeight = panel.scrollHeight;
button.disabled = true;
qs('#pull-conversation-status').textContent = 'Loading older messages…';
try {
renderPullConversation(await pullConversation.loadOlder());
panel.scrollTop += panel.scrollHeight - previousHeight;
} catch (error) {
qs('#pull-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.';
} finally {
button.disabled = false;
}
});
qs('#pull-comment').addEventListener('input', event => {
if (selectedPull) pullController.saveDraft(selectedPull, event.target.value);
});
@@ -2502,8 +2566,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#pull-comment-status').textContent = 'Posting comment…';
try {
const comment = await pullController.comment(selectedPull, body);
qs('#pull-comments .muted')?.remove();
qs('#pull-comments').insertAdjacentHTML('beforeend', '<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>');
if (pullConversation) renderPullConversation(pullConversation.append(comment));
qs('#pull-comment').value = '';
qs('#pull-comment-status').textContent = 'Comment posted.';
} catch (error) {
+10 -1
View File
@@ -1,4 +1,4 @@
function createIssueSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
@@ -21,6 +21,15 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
headers: { Accept: 'application/json' },
});
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
}),
});
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
return pager;
},
loadLabels(item) {
return fetchJson(issuePath(item) + '/labels', {
headers: { Accept: 'application/json' },
+10 -1
View File
@@ -37,7 +37,7 @@ function renderFile(file, index, reviewed, escapeHtml) {
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
}
function createPullSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
@@ -62,6 +62,15 @@ function createPullSheet({ fetchJson, storage, createOperationId = () => globalT
load(item) {
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
}),
});
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
return pager;
},
reviewState,
toggleReviewed(item, detail, filename) {
const state = reviewState(item, detail);