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, };