stackchain-dashboard/frontend/comment-actions.js
timmy b4f3c6d419
All checks were successful
CI / lint (pull_request) Successful in 3m46s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m29s
CI / release-candidate (pull_request) Has been skipped
feat: react to mobile conversation comments (Closes #1380)
2026-08-25 05:09:32 +00:00

196 lines
9.3 KiB
JavaScript

function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) {
const reactionLabels = {
'+1': 'Thumbs up', '-1': 'Thumbs down', laugh: 'Laugh', hooray: 'Hooray',
confused: 'Confused', heart: 'Heart', rocket: 'Rocket', eyes: 'Eyes',
};
const encodedRepository = repository => String(repository || '').split('/')
.map(encodeURIComponent).join('/');
function pathFor(context, commentId) {
const item = context?.item || {};
if (context?.kind === 'update') {
return 'api/v1/notifications/' + encodeURIComponent(item.notification_id) +
'/comments/' + encodeURIComponent(commentId);
}
if (!['issue', 'pull'].includes(context?.kind)) throw new Error('Comment conversation is unavailable.');
return 'api/v1/repos/' + encodedRepository(item.repository) + '/' +
(context.kind === 'pull' ? 'pulls/' : 'issues/') + encodeURIComponent(item.number) +
'/comments/' + encodeURIComponent(commentId);
}
function reactionPath(context, commentId, content = '') {
return pathFor(context, commentId) + '/reactions' +
(content ? '/' + encodeURIComponent(content) : '');
}
const pendingReactions = new Set();
const controller = {
isOwned(comment) {
const login = String(getLogin() || '').trim();
return Boolean(login && comment && comment.author === login);
},
async edit(context, pager, commentId, body) {
const draft = String(body || '').trim();
if (!draft) throw new Error('Comment must not be blank.');
const comment = await fetchJson(pathFor(context, commentId), {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ body: draft }),
});
pager.replace(comment);
return pager.snapshot();
},
async remove(context, pager, commentId) {
if (!confirmDelete('Delete this comment permanently?')) return null;
const result = await fetchJson(pathFor(context, commentId), {
method: 'DELETE', headers: { Accept: 'application/json' },
});
if (!result?.deleted || Number(result.id) !== Number(commentId)) {
throw new Error('Comment deletion was not confirmed.');
}
pager.remove(commentId);
return pager.snapshot();
},
async loadReactions(context, commentId) {
return fetchJson(reactionPath(context, commentId), {
headers: { Accept: 'application/json' },
});
},
async setReaction(context, commentId, content, active) {
return fetchJson(reactionPath(context, commentId, content), {
method: 'PUT',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ active: Boolean(active) }),
});
},
reactionHtml(state) {
const current = new Map((state?.reactions || []).map(item => [item.content, item]));
return Object.entries(reactionLabels).map(([content, label]) => {
const item = current.get(content) || {};
const count = Number.isInteger(item.count) && item.count > 0 ? item.count : 0;
const selected = item.selected === true;
return '<button type="button" data-comment-reaction="' + content + '" ' +
'aria-pressed="' + selected + '" aria-label="' + label + ' ' + count + '">' +
label + ' ' + count + '</button>';
}).join('') + '<button type="button" data-comment-reactions-close>Cancel</button>';
},
actionHtml(comment) {
const owned = controller.isOwned(comment) ?
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
'<button type="button" data-comment-action="edit">Edit</button>' +
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
return owned + '<div class="comment-reactions" aria-label="Comment reactions">' +
'<button type="button" data-comment-reactions-open aria-expanded="false" ' +
'aria-label="React to this comment">React</button>' +
'<div class="comment-reaction-menu" data-comment-reaction-menu hidden></div></div>';
},
wire({ root, getSurface, isOffline, escapeHtml }) {
root.addEventListener('click', async event => {
const actionButton = event.target.closest('[data-comment-action]');
const reactionClose = actionButton ? null : event.target.closest('[data-comment-reactions-close]');
const reactionButton = actionButton ? null : event.target.closest('[data-comment-reaction]');
const reactionTrigger = actionButton ? null : event.target.closest('[data-comment-reactions-open]');
if (reactionClose) {
const card = reactionClose.closest('.issue-comment');
const menu = card?.querySelector('[data-comment-reaction-menu]');
const trigger = card?.querySelector('[data-comment-reactions-open]');
if (menu && trigger) {
menu.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
}
return;
}
if (reactionButton || reactionTrigger) {
const control = reactionButton || reactionTrigger;
const card = control.closest('.issue-comment');
const commentId = Number(card?.dataset.commentId);
const surface = getSurface();
const menu = card?.querySelector('[data-comment-reaction-menu]');
const trigger = card?.querySelector('[data-comment-reactions-open]');
if (!commentId || !menu || !trigger) return;
if (isOffline()) {
surface.status.textContent = 'Reconnect to react.';
return;
}
if (pendingReactions.has(commentId)) return;
pendingReactions.add(commentId);
control.disabled = true;
try {
if (reactionButton) {
const content = reactionButton.dataset.commentReaction;
const active = reactionButton.getAttribute('aria-pressed') !== 'true';
const state = await controller.setReaction(surface.context, commentId, content, active);
menu.innerHTML = controller.reactionHtml(state);
menu.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
surface.status.textContent = 'Reaction updated.';
} else {
surface.status.textContent = 'Loading reactions…';
const state = await controller.loadReactions(surface.context, commentId);
menu.innerHTML = controller.reactionHtml(state);
menu.hidden = false;
trigger.setAttribute('aria-expanded', 'true');
surface.status.textContent = '';
}
} catch (error) {
surface.status.textContent = error.message || 'Reactions are unavailable. Please retry.';
} finally {
pendingReactions.delete(commentId);
control.disabled = false;
}
return;
}
const button = actionButton;
if (!button) return;
const card = button.closest('.issue-comment');
const commentId = Number(card?.dataset.commentId);
const surface = getSurface();
const comment = surface.pager?.snapshot().comments.find(item => item.id === commentId);
if (!comment || !controller.isOwned(comment)) return;
if (isOffline()) {
surface.status.textContent = 'Reconnect to edit or delete this comment.';
return;
}
if (button.dataset.commentAction === 'delete') {
try {
const state = await controller.remove(surface.context, surface.pager, commentId);
if (state) {
surface.render(state);
surface.status.textContent = 'Comment deleted.';
}
} catch (error) {
surface.status.textContent = error.message || 'Comment deletion failed. Retry or open it in Gitea.';
}
return;
}
card.innerHTML = '<div class="small">Editing your comment</div>' +
'<textarea class="comment-edit-textarea" maxlength="10000" aria-label="Edit comment">' +
escapeHtml(comment.body || '') + '</textarea>' +
'<div class="comment-owned-actions"><button type="button" data-comment-edit-save>Save</button>' +
'<button type="button" data-comment-edit-cancel>Cancel</button></div>';
const textarea = card.querySelector('.comment-edit-textarea');
textarea.focus();
card.querySelector('[data-comment-edit-cancel]').addEventListener('click', () => surface.render(surface.pager.snapshot()));
card.querySelector('[data-comment-edit-save]').addEventListener('click', async saveEvent => {
const save = saveEvent.currentTarget;
save.disabled = true;
surface.status.textContent = 'Saving comment…';
try {
const state = await controller.edit(surface.context, surface.pager, commentId, textarea.value);
surface.render(state);
surface.status.textContent = 'Comment updated.';
} catch (error) {
save.disabled = false;
surface.status.textContent = error.message || 'Comment update failed. Your edit is safe; retry or open it in Gitea.';
textarea.focus();
}
});
});
},
};
return controller;
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentActions;