feat(threads): "View Thread (N replies)" on a thread's root message (#165)
CI / Build & Quality Checks (push) Successful in 1m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 12m7s

The SDK gives a thread root its own id as threadRootId, so the
`!isThreadedMessage` guard hid the thread action on exactly the message that
has a thread; the "N replies" chip was the only way in. Roots now get the
action in both the hover bar and the ⋮ menu, labelled "View thread" /
"View Thread (14 replies)" with the thread icon; other messages keep
"Reply in Thread". Same handler, which opens the thread panel.

Verified in Chromium: on a 14-reply root both entries show and open the panel;
an ordinary message still shows "Reply in thread".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-24 22:35:39 -04:00
co-authored by Claude Opus 5.5
parent b71f9c95c6
commit 2b1f4ee5bc
+20 -6
View File
@@ -1176,6 +1176,18 @@ export const Message = React.memo(
};
const isThreadedMessage = mEvent.threadRootId !== undefined;
// [Gitea #165] A root that already has a thread: the same action opens it,
// so say "View Thread (N replies)" rather than "Reply in Thread".
// The SDK gives a thread root its own id as threadRootId, so without this
// a root (the one message that HAS a thread) got no thread action at all.
const isThreadRoot = !!mEvent.getId() && mEvent.threadRootId === mEvent.getId();
const showThreadAction = !isThreadedMessage || isThreadRoot;
const existingThread = mEvent.getId() ? room.getThread(mEvent.getId()!) : null;
const threadReplies = existingThread?.length ?? 0;
const threadActionLabel =
threadReplies > 0
? `View Thread (${threadReplies} ${threadReplies === 1 ? 'reply' : 'replies'})`
: 'Reply in Thread';
// The full action menu, shared by the desktop PopOut and the touch
// bottom sheet (#166).
@@ -1273,10 +1285,12 @@ export const Message = React.memo(
</Text>
</MenuItem>
)}
{!isThreadedMessage && (
{showThreadAction && (
<MenuItem
size="300"
after={<Icon src={Icons.ThreadPlus} size="100" />}
after={
<Icon src={threadReplies > 0 ? Icons.Thread : Icons.ThreadPlus} size="100" />
}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
@@ -1285,7 +1299,7 @@ export const Message = React.memo(
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Reply in Thread
{threadActionLabel}
</Text>
</MenuItem>
)}
@@ -1465,16 +1479,16 @@ export const Message = React.memo(
>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
{!isThreadedMessage && (
{showThreadAction && (
<IconButton
onClick={(ev) => onReplyClick(ev, true)}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Reply in thread"
aria-label={threadReplies > 0 ? 'View thread' : 'Reply in thread'}
>
<Icon src={Icons.ThreadPlus} size="100" />
<Icon src={threadReplies > 0 ? Icons.Thread : Icons.ThreadPlus} size="100" />
</IconButton>
)}
{canEditEventOrCaption(mx, mEvent) && onEditId && (