Skip to content

Threads and replies - #157

Merged
martinmitrevski merged 2 commits into
mainfrom
threads-and-replies
Aug 10, 2026
Merged

Threads and replies#157
martinmitrevski merged 2 commits into
mainfrom
threads-and-replies

Conversation

@martinmitrevski

Copy link
Copy Markdown
Contributor

Threads were modelled on the read path only. MessageDto carried ParentId, ReplyCount, bShowInChannel and ThreadParticipants, but MessageRequestDto had // TODO ParentId, bShowInChannel and both MessageStore and ChannelState had // TODO Threads. Nothing could be replied to, so the read path had nothing to read.

Two commits, split the way attachments landed in #153 and #154: the SDK, then the sample UI.

SDK

The write path is ParentId + bShowInChannel on the request DTO, sent by FMessage::ToRequestDto. Alongside it, three endpoints — GetReplies, QueryThreads, GetThread — with DTOs under Request/Thread and Response/Thread.

FMessageStore keeps a thread's replies keyed by parent ID and out of the channel message list unless the reply was sent with show_in_channel. That is what the backend reports too: querying channel state returns the channel's history, not its threads, so storing them together is what would otherwise interleave a thread into the conversation. A reply visible in both lists is one shared FMessage, so an edit or reaction through either is seen by both, and a new reply bumps its parent's ReplyCount without a refetch.

UChatChannel gains SendReply, QueryReplies, QueryAdditionalReplies, GetReplies and RepliesUpdated. The thread-list surface is UStreamChatClientComponent::QueryThreads, returning FChatThread — named around the engine's own FThread in HAL/Thread.h — with FThreadSortOption.

Typing events already carried ParentId and led nowhere; OnThreadTypingIndicator now carries it, and OnTypingIndicator still fires for everything, so nothing existing changes.

Two API behaviours worth knowing

Both found by probing the live API rather than trusting the docs, and both pinned by tests because both fail silently:

  • A reply comes back as type regular, not reply, despite EMessageTypeDto::Reply existing and the docs saying otherwise. So a reply is identified by its parent: IsThreadReply() goes by ParentId, and SendReply leaves Type alone rather than making the optimistic copy disagree with the socket echo.
  • GET /threads/{message_id} returns no participants at all unless participant_limit is passed. Exposed at the client level so ThreadParticipants is not silently empty.

Sample UI

On a phone there was no way to reach message actions or reactions — both hang off UMessageHoverMenuWidget, created in NativeOnMouseEnter, and a touch screen has no mouse-enter. Long pressing a message now opens one sheet with the reaction picker above the action list, so replying in a thread, reacting, copying and editing are reachable on iOS for the first time. Hover is untouched, so desktop keeps the bar it always had.

The gesture does not fight scrolling: NativeOnMouseButtonDown returns unhandled so the list still gets the press for a scroll drag. Once the list takes the pointer no move or up event reaches the message, so instead the message's own position is compared against where it sat at press time — if the list scrolled, the message moved, and it was not a long press.

No .uasset in this change. The picker and action-list classes are read off the hover menu's defaults (the native classes have no widget tree and would render blank), the picker rides at the top of the action sheet via a new SetHeaderContent, and UThreadReplyContextMenuAction is inserted in C++ because WBP_ContextMenu predates threads — the same reasoning as the composer's attach button.

Under a message only a reply count remains, and only when there is one; starting a thread is an action.

One deliberate behaviour change

A reply arriving over the socket no longer appears inline in the channel message list unless show_in_channel is set. That matches what the server returns in channel state — previously such replies showed up live and then vanished on reload.

Verification

strict build (-NoPCH -NoSharedPCH -DisableUnity) tests
UE 5.7 0 errors, 0 warnings 59/59
UE 5.8.1 0 errors, 0 warnings 59/59

New tests: eleven offline FMessageStore specs (reply routing, show_in_channel in both lists, reply-count accounting, the store merge channel pagination performs), a live ChatApi.Thread block exercising all three endpoints, and the thread wire keys pinned in the naming-convention spec — a wrong key for parent_id is accepted as a plain channel message rather than rejected.

Also packaged, installed and run on an iPhone 15 Pro (iOS 26.5), confirmed connected to Stream from the server side. Two bugs only a device would have surfaced were fixed on the way: the thread footer was built in OnSetup, before the list view parents the widget, so GetTheme() would have tripped its bConstructed ensure; and GetPosition asked the store for the next message, which for a thread's parent answers with the next channel message and costs the parent its timestamp and avatar.

Not included

The queryThreads UI (a thread-list screen, as iOS has with ChatThreadListVC). The SDK surface for it is here; the screen is not.

🤖 Generated with Claude Code

martinmitrevski and others added 2 commits August 10, 2026 16:07
Threads were modelled on the read path only: MessageDto carried ParentId, ReplyCount,
bShowInChannel and ThreadParticipants, but MessageRequestDto had `// TODO ParentId,
bShowInChannel` and both MessageStore and ChannelState had `// TODO Threads`. Nothing
could be replied to, so the read path had nothing to read.

MessageRequestDto gains ParentId and bShowInChannel, and FMessage::ToRequestDto sends
them, which is the whole of the write path. Alongside that, three endpoints: GetReplies
(GET messages/{parent_id}/replies), QueryThreads (POST /threads) and GetThread
(GET /threads/{message_id}), with DTOs under Request/Thread and Response/Thread.

FMessageStore now keeps a thread's replies keyed by parent ID and out of the channel
message list unless the reply was sent with show_in_channel, which is what the backend
reports too: querying channel state returns the channel's history, not its threads.
Storing them together is what would otherwise interleave a thread into the conversation.
A reply visible in both lists is one shared FMessage, so an edit or a reaction through
either is seen by both, and a newly arrived reply bumps its parent's ReplyCount so a
reply count moves without refetching the parent.

UChatChannel gains SendReply, QueryReplies, QueryAdditionalReplies, GetReplies and a
RepliesUpdated delegate. The thread-list surface is
UStreamChatClientComponent::QueryThreads, returning FChatThread (named for the engine's
own FThread in HAL/Thread.h) with FThreadSortOption for ordering.

Typing events already carried ParentId and led nowhere. OnThreadTypingIndicator now
carries it; OnTypingIndicator still fires for every typing event, so nothing existing
changes.

Two API behaviours found by probing the live API rather than reading the docs, both
pinned by tests because both fail silently:

- A reply comes back as type `regular`, not `reply`, despite EMessageTypeDto::Reply
  existing and the docs saying otherwise. A reply is identified by its parent, so
  FMessage::IsThreadReply() goes by ParentId and SendReply deliberately leaves Type
  alone rather than making the optimistic copy disagree with the socket echo.
- GET /threads/{message_id} returns no thread_participants at all unless
  participant_limit is passed, so that parameter is exposed at the client level too.

One deliberate behaviour change: a reply arriving over the socket no longer appears
inline in the channel message list unless show_in_channel is set. That matches what the
server returns in channel state; previously such replies showed up live and vanished on
reload.

Tests: eleven offline FMessageStore specs covering reply routing, show_in_channel in both
lists, reply-count accounting, and the store merge that channel pagination performs; a
live ChatApi.Thread block sending a parent and a reply and reading it back through all
three endpoints; and the thread wire keys pinned in the naming-convention spec, since a
wrong key for parent_id is accepted as a plain channel message rather than rejected.

Verified on UE 5.7 and 5.8.1: strict builds with zero errors and zero warnings, and 59/59
automation tests on each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sample had no way to reach a thread, and on a phone no way to reach message actions or
reactions either: both hang off UMessageHoverMenuWidget, which is created in
NativeOnMouseEnter, and a touch screen has no mouse-enter. Long pressing a message now
opens one sheet with the reaction picker above the action list, so replying in a thread,
reacting, copying, editing and the rest are reachable on iOS for the first time. Hover is
untouched, so desktop keeps the bar it always had.

The gesture does not fight the conversation. NativeOnMouseButtonDown deliberately returns
unhandled so the list view still gets the press to start a scroll drag; capturing it would
stop the list scrolling. That means once the list takes the pointer no move or up event
reaches the message, so a drag cannot be detected the usual way. Instead the message's own
absolute position is compared against where it sat when the press began: if the list
scrolled, the message moved, and it was a scroll rather than a long press. A quick tap
whose release went to the list is caught by an IsHovered() check when the timer fires.

The picker and action list are configured on WBP_MessageHoverMenu, not on the message, so
they are read from that class's defaults. Instantiating the native classes directly gives
a blank sheet, since they have no widget tree behind them. UMenuAnchor shows exactly one
UUserWidget and its UWidget-returning binding is deprecated with a warning that the
project will stop compiling, so rather than fake a widget tree to wrap two widgets,
UContextMenuWidget gained SetHeaderContent and the picker rides at the top of its panel,
which is where a phone user expects it anyway.

UThreadReplyContextMenuAction is inserted into the action list in C++, guarded and skipped
at design time, because WBP_ContextMenu predates threads. Same reasoning as the composer's
attach button: no asset in this change, so nothing to merge in a .uasset.

The channel context owns which thread is open and fetches its replies; the message list,
composer and header follow OnThreadChanged. The list shows the parent above the replies
and pages back through them on scroll, the composer reuses the edit banner for "Replying
in thread" with its cancel button as the way out, and the header reads "Thread".

Under a message only a reply count remains, and only when there is one. Starting a thread
is an action, so messages without replies carry nothing.

Two bugs that only a device would have shown. The thread footer was built in OnSetup,
which runs before the list view parents the widget, so GetTheme() would have tripped its
bConstructed ensure; it moved to NativeConstruct. And GetPosition asked the store for the
next message, which for a thread's parent answers with the next channel message and costs
the parent its timestamp and avatar; it now reads the list actually on screen.

Verified on UE 5.7 and 5.8.1: strict builds with zero errors and zero warnings, and 59/59
automation tests on each. Installed and run on an iPhone 15 Pro (iOS 26.5), confirmed
connected to Stream from the server side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@martinmitrevski
martinmitrevski merged commit 1583337 into main Aug 10, 2026
7 of 13 checks passed
@martinmitrevski
martinmitrevski deleted the threads-and-replies branch August 10, 2026 14:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant