Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/dist/roundtable.css

Large diffs are not rendered by default.

22 changes: 11 additions & 11 deletions assets/dist/roundtable.js

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions assets/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,10 @@ export function fetchComments(caseId) {
* Post a comment on a public topic.
* @param {string} caseId
* @param {string} body HTML comment body (the proxy converts it to markdown server-side)
* @param {string} [parentId] The comment this replies to (one level deep); omitted for a
* top-level comment.
* @returns {Promise<{comment:object}|{error:{kind:string}}>}
*/
export function postComment(caseId, body) {
return post('/cases/' + encodeURIComponent(caseId) + '/comments', { body });
export function postComment(caseId, body, parentId) {
return post('/cases/' + encodeURIComponent(caseId) + '/comments', parentId ? { body, parent_id: parentId } : { body });
}
10 changes: 6 additions & 4 deletions assets/src/comment-actions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ const UP_SVG = (
);

/**
* Per-comment actions from the mockup. Voting/replies need hub APIs (post-MVP, hub §16).
* Per-comment actions. Reply is wired via `onReply` (one-level threading);
* comment voting still needs a hub API (post-MVP, hub §16) and stays disabled.
* @param {{onReply?:()=>void}} props
*/
export function CommentActions() {
export function CommentActions({ onReply }) {
return (
<div class="rt-cm-actions">
<button
Expand All @@ -25,8 +27,8 @@ export function CommentActions() {
<button
type="button"
class="rt-cm-action rt-cm-reply"
disabled
title="Nested replies — coming in a later release"
disabled={!onReply}
onClick={onReply}
>
Reply
</button>
Expand Down
18 changes: 16 additions & 2 deletions assets/src/comment-editor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,30 @@ import { htmlHasText, uniqueEditorId, WysiwygEditor } from './wysiwyg-editor.jsx
* Comment composer built on WordPress's bundled TinyMCE (via `wp.editor`),
* restricted to bold/italic/lists/link — no headings or block-format select
* (see WysiwygEditor). Falls back to a plain textarea when TinyMCE isn't
* available.
* @param {{value:string, onInput:(v:string)=>void, onSubmit:()=>void, posting:boolean, error:boolean, submitDisabled?:boolean, resetNonce?:number}} props
* available. In reply mode (`replyTarget` set), shows a "Replying to
* <handle>" chip with a cancel above the editor and moves focus into it.
* @param {{value:string, onInput:(v:string)=>void, onSubmit:()=>void, posting:boolean, error:boolean, submitDisabled?:boolean, resetNonce?:number, focusNonce?:number, replyTarget?:{handle:string, parentId:string}|null, onCancelReply?:()=>void}} props
*/
export function CommentEditor({
value, onInput, onSubmit, posting, error, submitDisabled = false, resetNonce = 0,
focusNonce = 0, replyTarget = null, onCancelReply,
}) {
const [editorId] = useState(() => uniqueEditorId('rt-comment'));
const canSubmit = htmlHasText(value);

return (
<div class="rt-cm-composer">
{replyTarget && (
<div class="rt-reply-chip">
<span>Replying to <b>{replyTarget.handle}</b></span>
<button
type="button"
class="rt-reply-chip-cancel"
aria-label="Cancel reply"
onClick={onCancelReply}
>×</button>
</div>
)}
<div class="rt-editor rt-mdeditor">
<WysiwygEditor
id={editorId}
Expand All @@ -30,6 +43,7 @@ export function CommentEditor({
placeholder="Add a comment…"
className="rt-ed-input"
resetNonce={resetNonce}
focusNonce={focusNonce}
/>
<div class="rt-ed-foot">
{error ? <span class="rt-ed-error">Could not post. Try again.</span> : <span class="rt-ed-spacer" />}
Expand Down
5 changes: 5 additions & 0 deletions assets/src/styles.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 35 additions & 9 deletions assets/src/topic-detail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@ import { fetchComments, postComment } from './api.js';
import { renderMarkdown } from './markdown.js';
import { AgentAvatar, PersonAvatar } from './avatars.jsx';
import { agentDisplayName } from './config.js';
import { mapCommentToView } from './topics.js';
import { buildThreadRows, mapCommentToView } from './topics.js';
import { DisabledVote, VoteControl } from './vote-control.jsx';
import { CommentEditor } from './comment-editor.jsx';
import { CommentActions } from './comment-actions.jsx';
import { htmlHasText } from './wysiwyg-editor.jsx';
import { MaintainerBadge } from './badges.jsx';

function CommentRow({ comment }) {
export function CommentRow({ comment, isReply = false, onReply }) {
const Ava = comment.isAgent ? AgentAvatar : PersonAvatar;
const avaProps = comment.isAgent ? { size: 'sm' } : { initials: comment.initials, size: 'sm' };
return (
<div class="rt-cm">
<div class={'rt-cm' + (isReply ? ' rt-cm-reply-row' : '')}>
<Ava {...avaProps} class="rt-cm-ava" />
<div class="rt-cm-body">
<div class="rt-cm-who">
Expand All @@ -29,7 +29,7 @@ function CommentRow({ comment }) {
{comment.isTombstone
? <p class="rt-cm-tomb">{comment.tombstoneLabel}</p>
: <div class="rt-cm-txt rt-prose" dangerouslySetInnerHTML={{ __html: comment.html }} />}
{!comment.isTombstone && <CommentActions />}
{!comment.isTombstone && <CommentActions onReply={onReply} />}
</div>
</div>
);
Expand All @@ -42,7 +42,9 @@ function repliesTitle(count, loading, error) {
return count + ' replies';
}

function CommentComposer({ caseId, onPosted, disabled = false }) {
function CommentComposer({
caseId, onPosted, disabled = false, replyTarget, focusNonce = 0, onCancelReply,
}) {
const [text, setText] = useState('');
const [posting, setPosting] = useState(false);
const [error, setError] = useState(false);
Expand All @@ -52,14 +54,15 @@ function CommentComposer({ caseId, onPosted, disabled = false }) {
if (disabled || posting || !htmlHasText(text)) return;
setPosting(true);
setError(false);
const res = await postComment(caseId, text.trim());
const res = await postComment(caseId, text.trim(), replyTarget?.parentId);
setPosting(false);
if (res.error) {
setError(true);
return;
}
setText('');
setResetNonce((n) => n + 1);
onCancelReply && onCancelReply();
onPosted();
};

Expand All @@ -72,6 +75,9 @@ function CommentComposer({ caseId, onPosted, disabled = false }) {
error={error}
submitDisabled={disabled}
resetNonce={resetNonce}
focusNonce={focusNonce}
replyTarget={replyTarget}
onCancelReply={onCancelReply}
/>
);
}
Expand All @@ -81,8 +87,16 @@ export function TopicDetail({ topic, onBack, onVoteChange }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [refreshNonce, setRefreshNonce] = useState(0);
const [replyTarget, setReplyTarget] = useState(null);
const [replyNonce, setReplyNonce] = useState(0);
const agentName = agentDisplayName();

function startReply(target) {
setReplyTarget(target);
setReplyNonce((n) => n + 1);
}
const cancelReply = () => setReplyTarget(null);

const loadComments = useCallback(async (cancelledRef) => {
setLoading(true);
setError(false);
Expand All @@ -94,8 +108,10 @@ export function TopicDetail({ topic, onBack, onVoteChange }) {
setComments([]);
return;
}
const rows = (res.comments || []).map((row) => mapCommentToView(row, agentName));
setComments(rows.reverse());
// Kept chronological (oldest-first, the hub's own order) — groupCommentThreads
// below both sorts top-level comments newest-first and orders each one's
// replies oldest-first from this same order.
setComments((res.comments || []).map((row) => mapCommentToView(row, agentName)));
}, [topic.id, agentName]);

useEffect(() => {
Expand Down Expand Up @@ -158,7 +174,14 @@ export function TopicDetail({ topic, onBack, onVoteChange }) {
{!loading && !error && comments.length === 0 && <p class="rt-csec-empty">No comments yet.</p>}
{!loading && !error && comments.length > 0 && (
<div class="rt-cm-thread">
{comments.map((c) => <CommentRow key={c.id} comment={c} />)}
{buildThreadRows(comments).map((row) => (
<CommentRow
key={row.comment.id}
comment={row.comment}
isReply={row.isReply}
onReply={() => startReply(row.replyTarget)}
/>
))}
</div>
)}
</Fragment>
Expand All @@ -173,6 +196,9 @@ export function TopicDetail({ topic, onBack, onVoteChange }) {
onVoteChange && onVoteChange();
}}
disabled={topic.isPending}
replyTarget={replyTarget}
focusNonce={replyNonce}
onCancelReply={cancelReply}
/>
</div>
</div>
Expand Down
75 changes: 75 additions & 0 deletions assets/src/topics.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ export function mapCommentToView(row, agentName = 'Roundtable') {
const role = resolveCommentRole(row);
return {
id: row.id,
// Defaults to null when absent so the UI works against an older hub that
// doesn't send parent_id on comment responses yet (every comment reads
// as top-level).
parentId: row.parent_id || null,
handle,
initials: handleInitials(handle),
age: formatAge(row.created_at),
Expand All @@ -193,4 +197,75 @@ export function mapCommentToView(row, agentName = 'Roundtable') {
tombstoneLabel: TOMBSTONE_LABELS[status] || 'This comment is unavailable.',
html: isTombstone ? '' : renderMarkdown(row.body),
};
}

/**
* Thread a flat, chronological (oldest-first) comment list into render
* groups: one entry per top-level comment, in the current sort order
* (newest-first, unchanged), each carrying its own replies in a `replies`
* array (oldest-first — their relative order in the input is preserved).
*
* Threading is one level deep — the hub contract flattens a reply-to-a-reply
* onto the grandparent, so every reply's `parentId` already points at a
* top-level comment in this same list. A tombstoned top-level comment is
* grouped exactly like any other and still anchors its replies; only its own
* rendering (handled elsewhere) differs. A reply whose `parentId` doesn't
* match any top-level comment in this list is dropped rather than shown
* detached.
*
* @param {Array<{id:string, parentId:string|null}>} comments Chronological
* (oldest-first) mapped comment view models, as returned by mapCommentToView.
* @returns {Array<object>} One entry per top-level comment (newest-first),
* each spread with a `replies` array (oldest-first).
*/
export function groupCommentThreads(comments) {
const list = comments || [];
const repliesByParent = new Map();
list.forEach((c) => {
if (!c.parentId) return;
if (!repliesByParent.has(c.parentId)) repliesByParent.set(c.parentId, []);
repliesByParent.get(c.parentId).push(c);
});

return list
.filter((c) => !c.parentId)
.slice()
.reverse()
.map((top) => ({ ...top, replies: repliesByParent.get(top.id) || [] }));
}

/**
* Flatten `groupCommentThreads`' grouped output into one render-ready row
* descriptor per comment, in display order (each top-level comment
* immediately followed by its own replies). Pulled out of the render loop so
* the row-building — which row is a reply, and which comment a Reply click
* on it targets — is unit-testable on its own.
*
* Every row within a thread — the top-level comment and each of its
* replies — carries the SAME `replyTarget.parentId` (the thread's top-level
* id, matching the hub's one-level flattening: a reply-to-a-reply lands on
* the grandparent regardless), while `replyTarget.handle` is always that
* specific row's own handle (whichever comment was actually clicked).
*
* @param {Array<{id:string, parentId:string|null}>} comments Chronological
* (oldest-first) mapped comment view models, as returned by mapCommentToView.
* @returns {Array<{comment:object, isReply:boolean, replyTarget:{parentId:string, handle:string}}>}
*/
export function buildThreadRows(comments) {
const rows = [];
groupCommentThreads(comments).forEach((thread) => {
rows.push({
comment: thread,
isReply: false,
replyTarget: { parentId: thread.id, handle: thread.handle },
});
thread.replies.forEach((reply) => {
rows.push({
comment: reply,
isReply: true,
replyTarget: { parentId: thread.id, handle: reply.handle },
});
});
});
return rows;
}
18 changes: 15 additions & 3 deletions assets/src/wysiwyg-editor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,21 @@ export function htmlHasText(html) {
* the editor's *initial* content only (re-feeding it into TinyMCE on every
* keystroke would fight its own cursor/undo state). Every edit is reported
* back via `onInput(html)`. Pass a changing `resetNonce` to clear the editor
* in place (e.g. after a successful submit) without unmounting it.
* in place (e.g. after a successful submit) without unmounting it. Pass a
* changing `focusNonce` to move keyboard focus into the editor (e.g. when
* the comment composer enters reply mode).
*
* Falls back to a plain, fully-controlled `<textarea>` when `window.wp.editor`
* isn't available (defensive — the host page's `wp_enqueue_editor()` call
* should always have made it available).
*
* @param {{id:string, value:string, onInput:(html:string)=>void, disabled?:boolean,
* toolbar1:string, blockFormats?:string, height?:number, rows?:number,
* placeholder?:string, resetNonce?:number, className?:string}} props
* placeholder?:string, resetNonce?:number, focusNonce?:number, className?:string}} props
*/
export function WysiwygEditor({
id, value, onInput, disabled = false, toolbar1, blockFormats, height = 220,
rows = 8, placeholder, resetNonce = 0, className,
rows = 8, placeholder, resetNonce = 0, focusNonce = 0, className,
}) {
const onInputRef = useRef(onInput);
onInputRef.current = onInput;
Expand Down Expand Up @@ -91,6 +93,16 @@ export function WysiwygEditor({
onInputRef.current('');
}, [resetNonce]);

useEffect(() => {
if (focusNonce <= 0) return;
if (!available) {
areaRef.current && areaRef.current.focus();
return;
}
const ed = window.tinymce && window.tinymce.get(id);
ed && ed.focus();
}, [focusNonce]);

if (!available) {
return (
<textarea
Expand Down
29 changes: 29 additions & 0 deletions assets/test/api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,32 @@ test('streamMessage includes trigger in the body when given', async () => {

assert.equal(capturedBody.trigger, 'create_topic');
});

test('postComment includes parent_id when given', async () => {
globalThis.window = { RoundtableConfig: { restUrl: 'https://example.test/wp-json/roundtable/v1', nonce: 'n' } };
let capturedBody = null;
globalThis.fetch = async (url, init) => {
capturedBody = JSON.parse(init.body);
return { ok: true, json: async () => ({ comment: {} }) };
};
const { postComment } = await import('../src/api.js');

await postComment('c1', '<p>Reply</p>', 'cm1');

assert.equal(capturedBody.parent_id, 'cm1');
assert.equal(capturedBody.body, '<p>Reply</p>');
});

test('postComment omits parent_id for a top-level comment', async () => {
globalThis.window = { RoundtableConfig: { restUrl: 'https://example.test/wp-json/roundtable/v1', nonce: 'n' } };
let capturedBody = null;
globalThis.fetch = async (url, init) => {
capturedBody = JSON.parse(init.body);
return { ok: true, json: async () => ({ comment: {} }) };
};
const { postComment } = await import('../src/api.js');

await postComment('c1', 'Top level');

assert.equal('parent_id' in capturedBody, false);
});
Loading
Loading