Threads and replies - #157
Merged
Merged
Conversation
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>
This was referenced Aug 10, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Threads were modelled on the read path only.
MessageDtocarriedParentId,ReplyCount,bShowInChannelandThreadParticipants, butMessageRequestDtohad// TODO ParentId, bShowInChanneland bothMessageStoreandChannelStatehad// 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+bShowInChannelon the request DTO, sent byFMessage::ToRequestDto. Alongside it, three endpoints —GetReplies,QueryThreads,GetThread— with DTOs underRequest/ThreadandResponse/Thread.FMessageStorekeeps a thread's replies keyed by parent ID and out of the channel message list unless the reply was sent withshow_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 sharedFMessage, so an edit or reaction through either is seen by both, and a new reply bumps its parent'sReplyCountwithout a refetch.UChatChannelgainsSendReply,QueryReplies,QueryAdditionalReplies,GetRepliesandRepliesUpdated. The thread-list surface isUStreamChatClientComponent::QueryThreads, returningFChatThread— named around the engine's ownFThreadinHAL/Thread.h— withFThreadSortOption.Typing events already carried
ParentIdand led nowhere;OnThreadTypingIndicatornow carries it, andOnTypingIndicatorstill 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:
regular, notreply, despiteEMessageTypeDto::Replyexisting and the docs saying otherwise. So a reply is identified by its parent:IsThreadReply()goes byParentId, andSendReplyleavesTypealone rather than making the optimistic copy disagree with the socket echo.GET /threads/{message_id}returns no participants at all unlessparticipant_limitis passed. Exposed at the client level soThreadParticipantsis not silently empty.Sample UI
On a phone there was no way to reach message actions or reactions — both hang off
UMessageHoverMenuWidget, created inNativeOnMouseEnter, 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:
NativeOnMouseButtonDownreturns 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
.uassetin 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 newSetHeaderContent, andUThreadReplyContextMenuActionis inserted in C++ becauseWBP_ContextMenupredates 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_channelis set. That matches what the server returns in channel state — previously such replies showed up live and then vanished on reload.Verification
-NoPCH -NoSharedPCH -DisableUnity)New tests: eleven offline
FMessageStorespecs (reply routing,show_in_channelin both lists, reply-count accounting, the store merge channel pagination performs), a liveChatApi.Threadblock exercising all three endpoints, and the thread wire keys pinned in the naming-convention spec — a wrong key forparent_idis 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, soGetTheme()would have tripped itsbConstructedensure; andGetPositionasked 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
queryThreadsUI (a thread-list screen, as iOS has withChatThreadListVC). The SDK surface for it is here; the screen is not.🤖 Generated with Claude Code