RSNewWebUI/webui-src/app/comments.js
jolavillette 9338fef86e webui: a thumbs-up is not a downvote
The shared comment component defined VOTE_UP = 1, VOTE_DOWN = 2; the
core's contract is the opposite (rsgxscommon.h RsGxsVoteType: DOWN = 1,
UP = 2 -- the same values boards_util and channels_util already use).
Every thumbs-up on a board or channel comment was published as a
downvote and vice versa, and a GXS vote is a message that cannot be
retracted. Worth a release note to users of the affected build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 11:04:13 +02:00

286 lines
11 KiB
JavaScript

const m = require('mithril');
const rs = require('rswebui');
const peopleUtil = require('people/people_util');
const chatEmoji = require('chat/chat_emoji');
// The core's contract (rsgxscommon.h RsGxsVoteType, same as the legacy
// GXS_VOTE_* constants used by boards_util/channels_util): DOWN = 1, UP = 2.
// These were inverted at first, and a GXS vote is a published message that
// cannot be retracted: every thumbs-up was recorded as a downvote.
const VOTE_UP = 2;
const VOTE_DOWN = 1;
const CommentsSection = () => {
let replyTo = null;
let composerText = '';
let authorId = null;
let submitting = false;
let submitError = '';
let showEmojiPicker = false;
const expandedReplies = {};
const metaOf = (comment) => (comment && comment.mMeta) || {};
const idOf = (comment) => metaOf(comment).mMsgId || comment.msgId || comment.id;
const parentOf = (comment) => metaOf(comment).mParentId || comment.parentId || '';
const textOf = (comment) => comment.mComment || comment.comment || comment.mBody || '';
const nameOf = (id) => (!id || Number(id) === 0 ? 'Anonymous' : (rs.userList.username(id) || rs.userList.userMap[id] || `${String(id).slice(0, 10)}`));
const dateOf = (value) => {
const seconds = value && typeof value === 'object' ? value.xint64 : value;
const date = Number(seconds) ? new Date(Number(seconds) * 1000) : null;
return date && !Number.isNaN(date.getTime()) ? date.toLocaleString() : '';
};
function buildTree(rawComments, rootThreadId) {
const nodes = {};
const roots = [];
const list = Array.isArray(rawComments)
? rawComments
: Object.values(rawComments || {}).map((entry) => entry.comment || entry);
list.forEach((comment) => {
const id = idOf(comment);
if (id) nodes[id] = { comment, children: [] };
});
Object.keys(nodes).forEach((id) => {
const node = nodes[id];
const parent = parentOf(node.comment);
if (parent && parent !== id && parent !== rootThreadId && nodes[parent]) {
nodes[parent].children.push(node);
} else {
roots.push(node);
}
});
const chronological = (a, b) => {
const ta = Number(metaOf(a.comment).mPublishTs && (metaOf(a.comment).mPublishTs.xint64 || metaOf(a.comment).mPublishTs)) || 0;
const tb = Number(metaOf(b.comment).mPublishTs && (metaOf(b.comment).mPublishTs.xint64 || metaOf(b.comment).mPublishTs)) || 0;
return ta - tb;
};
roots.sort(chronological);
Object.keys(nodes).forEach((id) => nodes[id].children.sort(chronological));
return { roots, totalCount: list.length };
}
async function handleSubmit(vnode) {
const comment = composerText.trim();
if (!comment || !authorId || submitting) return;
submitting = true;
submitError = '';
try {
if (typeof vnode.attrs.onSubmitComment === 'function') {
await vnode.attrs.onSubmitComment({
text: comment,
authorId,
parentId: replyTo ? idOf(replyTo) : null,
replyTo,
});
}
composerText = '';
replyTo = null;
} catch (err) {
console.warn('CommentsSection: submission failed', err);
submitError = (err && err.message) || 'Your comment could not be posted. Please try again.';
} finally {
submitting = false;
m.redraw();
}
}
function renderCommentNode(node, depth, vnode) {
const comment = node.comment;
const id = idOf(comment);
const meta = metaOf(comment);
const name = nameOf(meta.mAuthorId);
const repliesCount = node.children.length;
const repliesExpanded = expandedReplies[id] === true;
const votes = typeof vnode.attrs.getCommentVotes === 'function'
? vnode.attrs.getCommentVotes(id, comment)
: {
upvotes: Number(comment.mUpVotes || 0),
downvotes: Number(comment.mDownVotes || 0),
};
const voteIdentity = vnode.attrs.voteIdentity;
return m('.comment', { key: id, class: depth ? 'comment--reply' : '' }, [
m('.comment-avatar', m(peopleUtil.IdentityAvatar, {
identityId: meta.mAuthorId,
name,
size: '100%',
})),
m('.comment__content', [
m('.comment__header', [
m('.comment__meta', [
m('b', name),
dateOf(meta.mPublishTs) ? m('span', dateOf(meta.mPublishTs)) : null,
]),
]),
m('p.comment__text', textOf(comment)),
m('.comment__actions', [
m('button[type=button]', {
disabled: !voteIdentity,
onclick: () => {
if (typeof vnode.attrs.onVoteComment === 'function') {
vnode.attrs.onVoteComment({
commentId: id,
voteType: VOTE_UP,
voteIdentity,
comment,
});
}
},
}, [m('i.fas.fa-thumbs-up'), ` ${votes.upvotes || 0}`]),
m('button[type=button]', {
disabled: !voteIdentity,
onclick: () => {
if (typeof vnode.attrs.onVoteComment === 'function') {
vnode.attrs.onVoteComment({
commentId: id,
voteType: VOTE_DOWN,
voteIdentity,
comment,
});
}
},
}, m('i.fas.fa-thumbs-down')),
m('button[type=button]', {
onclick: () => {
replyTo = comment;
composerText = '';
submitError = '';
},
}, 'Reply'),
]),
repliesCount ? m('button.comment__replies-toggle[type=button]', {
'aria-expanded': repliesExpanded,
onclick: () => { expandedReplies[id] = !repliesExpanded; },
}, [
`${repliesCount} ${repliesCount === 1 ? 'reply' : 'replies'} `,
m('i.fas', { class: repliesExpanded ? 'fa-chevron-up' : 'fa-chevron-down' }),
]) : null,
repliesCount && repliesExpanded
? m('.comment__replies', node.children.map((child) => renderCommentNode(child, depth + 1, vnode)))
: null,
]),
]);
}
return {
view: (vnode) => {
const identities = (vnode.attrs.identities || []).filter((id) => Number(id) !== 0);
if (!authorId && identities.length) authorId = identities[0];
if (authorId && identities.length && !identities.includes(authorId)) {
authorId = identities[0];
}
const { roots, totalCount } = buildTree(vnode.attrs.comments, vnode.attrs.rootThreadId);
const showVoter = typeof vnode.attrs.onVoteIdentity === 'function';
return m('.comments', [
m('.comments__heading', [
m('h3', `${totalCount} Comment${totalCount === 1 ? '' : 's'}`),
m('span', [m('i.fas.fa-sort-amount-down'), ' Oldest first']),
showVoter ? m('.comments__voter', [
m('label[for=comment-voter-select]', 'Voter identity'),
m('select#comment-voter-select', {
value: vnode.attrs.voteIdentity || '',
disabled: identities.length === 0,
onchange: (e) => vnode.attrs.onVoteIdentity(e.target.value),
}, identities.length
? identities.map((id) => m('option', { value: id }, nameOf(id)))
: m('option', { value: '' }, vnode.attrs.identitiesLoading ? 'Loading identities…' : 'No identity available')),
]) : null,
]),
m('.comment-composer', [
m('.comment-avatar', m(peopleUtil.IdentityAvatar, {
identityId: authorId,
name: nameOf(authorId),
size: '100%',
})),
m('.comment-composer__body', [
replyTo ? m('.comment-composer__replying', [
'Replying to ',
m('b', nameOf(metaOf(replyTo).mAuthorId)),
m('button[type=button][aria-label=Cancel reply]', {
onclick: () => { replyTo = null; composerText = ''; },
}, m('i.fas.fa-times')),
]) : null,
identities.length ? m('select.comment-composer__identity', {
value: authorId || '',
onchange: (e) => { authorId = e.target.value; },
}, identities.map((id) => m('option', { value: id }, nameOf(id)))) : null,
m('textarea.comment-composer__input[rows=1][placeholder=Add a comment…]', {
value: composerText,
disabled: !authorId || submitting,
oninput: (e) => { composerText = e.target.value; },
onkeydown: (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
handleSubmit(vnode);
}
},
}),
!authorId ? m('p.comment-composer__hint', vnode.attrs.identitiesLoading ? 'Loading identities…' : 'Create or select an identity to post a comment.') : null,
submitError ? m('p.comment-composer__error', submitError) : null,
m('.comment-composer__actions', [
m('.comment-composer__emoji', [
m('button[type=button][title=Insert emoji][aria-label=Insert emoji]', {
style: {
width: '32px',
height: '32px',
padding: '0',
borderRadius: '50%',
border: '0',
boxShadow: 'none',
background: showEmojiPicker ? '#e0f2fe' : 'transparent',
color: '#475569',
fontSize: '1.15rem',
},
onclick: () => { showEmojiPicker = !showEmojiPicker; },
}, m('i.fas.fa-smile')),
showEmojiPicker && chatEmoji && chatEmoji.EMOJI_DATA && chatEmoji.EMOJI_DATA.Smileys ? m('.comment-emoji-popover', chatEmoji.EMOJI_DATA.Smileys.slice(0, 48).map((emoji) => m('button[type=button]', {
style: {
width: '28px',
height: '28px',
padding: '0',
border: '0',
boxShadow: 'none',
background: 'transparent',
fontSize: '1.1rem',
},
onclick: () => {
composerText += emoji;
showEmojiPicker = false;
},
}, emoji))) : null,
]),
composerText || replyTo ? m('button.comment-composer__cancel[type=button]', {
onclick: () => {
composerText = '';
replyTo = null;
submitError = '';
},
}, 'Cancel') : null,
m('button.comment-composer__submit[type=button]', {
disabled: !composerText.trim() || !authorId || submitting,
onclick: () => handleSubmit(vnode),
}, submitting ? 'Posting…' : 'Comment'),
]),
]),
]),
vnode.attrs.loading ? m('.comments__status', [m('i.fas.fa-spinner.fa-spin'), ' Loading comments…'])
: roots.length ? m('.comments__list', roots.map((node) => renderCommentNode(node, 0, vnode)))
: m('.comments__empty', [m('i.fas.fa-comment'), m('p', 'No comments yet. Start the conversation.')]),
]);
},
};
};
module.exports = {
CommentsSection,
ThreadedComments: CommentsSection,
VOTE_UP,
VOTE_DOWN,
};