Summary
On mobile, replies sent inside a nested thread (a thread opened on a reply, rather than on a top-level channel message) never appear. The message is signed, published, accepted by the relay, and visible to other clients — but the sender's own screen stays empty, with no error and no way to recover. Leaving the channel, pull-to-refresh, and restarting the app all fail to surface it.
It reads as data loss from the user's side: you type a message, it disappears, and only by opening desktop do you discover it was delivered the whole time.
Reproduction
- On mobile, open a channel and post a message — call it R.
- Open R's thread and post a reply — call it M.
- From inside R's thread, open M's own thread (the nested-thread row).
- Send any message there.
Expected: the message appears in M's thread.
Actual: nothing appears. The message is on the relay and renders on desktop.
Not specific to @-mentions or agents — plain text reproduces it. Verified on 7e34bee6.
Root cause
The relay's thread query is root-scoped (buzz-db/src/thread.rs#L397-L398):
WHERE tm.community_id = $1
AND tm.root_event_id = $2
It returns rows whose root is the supplied id, relying on depth (not parentage) to bound the result.
Mobile passes the nested thread's head as if it were a root (thread_detail_page.dart#L56-L58):
final repliesState = ref.watch(
threadRepliesProvider(
ThreadRepliesArgs(channelId: channelId, rootId: threadHead.id),
),
);
which becomes '#e': [args.rootId] in thread_replies_provider.dart#L56.
But a reply to M is stored with root_event_id = R, parent_event_id = M — the relay derives the root from the root marker and only falls back to the parent when absent (ingest.rs#L632):
let effective_root = meta.root_event_id.unwrap_or_else(|| parent_bytes.clone());
And mobile's own send path writes exactly that marker pair for a nested reply (send_message_provider.dart#L122-L136):
return [
['e', root, '', 'root'],
['e', parentEventId, '', 'reply'],
];
So root_event_id = M matches zero rows, and M's thread view is permanently empty. The write side is correct; only the read query is wrong.
Why desktop is unaffected
Desktop has no nested thread view. openThreadHeadId is only ever set from a top-level timeline message (ChannelScreen.tsx#L163-L166), so the id it queries with is always a genuine root and the root-scoped query returns every descendant at any depth. Mobile added nested-thread navigation (thread_detail_page.dart#L300) without adapting the query.
Possible fixes
Option A — query the true root, filter client-side (mobile only).
ThreadDetailPage already computes the right value one screen further down, for the compose bar (#L136):
final effectiveRootId = threadHead.rootId ?? threadHead.id;
Passing that to threadRepliesProvider is sufficient — the page already indexes replies by parent and selects childrenByParent[threadHead.id] (#L71-L78), so the narrowing is already implemented. Live refresh also already invalidates on both root and parent (channel_messages_provider.dart#L187-L208).
Small and matches desktop's effective behaviour. Cost: a nested view fetches the entire root thread and discards most of it, which grows with thread size.
Option B — support parent-scoped thread queries in the relay.
Add an opt-in filter so the query can bound on parent_event_id (or on a subtree) instead of root_event_id, and have mobile use it. No over-fetching and it makes nested threads a first-class concept, but it is a relay change with migration and API-surface implications.
Happy to send a PR for Option A, or for Option B if that is the preferred direction — I'd rather confirm before writing relay code.
Environment
main @ 7e34bee6
- Buzz Mobile on a physical iPhone XR (not a simulator)
- Reproduced against a relay where the same events render correctly on desktop
Summary
On mobile, replies sent inside a nested thread (a thread opened on a reply, rather than on a top-level channel message) never appear. The message is signed, published, accepted by the relay, and visible to other clients — but the sender's own screen stays empty, with no error and no way to recover. Leaving the channel, pull-to-refresh, and restarting the app all fail to surface it.
It reads as data loss from the user's side: you type a message, it disappears, and only by opening desktop do you discover it was delivered the whole time.
Reproduction
Expected: the message appears in M's thread.
Actual: nothing appears. The message is on the relay and renders on desktop.
Not specific to
@-mentions or agents — plain text reproduces it. Verified on7e34bee6.Root cause
The relay's thread query is root-scoped (
buzz-db/src/thread.rs#L397-L398):It returns rows whose root is the supplied id, relying on
depth(not parentage) to bound the result.Mobile passes the nested thread's head as if it were a root (
thread_detail_page.dart#L56-L58):which becomes
'#e': [args.rootId]inthread_replies_provider.dart#L56.But a reply to M is stored with
root_event_id = R,parent_event_id = M— the relay derives the root from therootmarker and only falls back to the parent when absent (ingest.rs#L632):And mobile's own send path writes exactly that marker pair for a nested reply (
send_message_provider.dart#L122-L136):So
root_event_id = Mmatches zero rows, and M's thread view is permanently empty. The write side is correct; only the read query is wrong.Why desktop is unaffected
Desktop has no nested thread view.
openThreadHeadIdis only ever set from a top-level timeline message (ChannelScreen.tsx#L163-L166), so the id it queries with is always a genuine root and the root-scoped query returns every descendant at any depth. Mobile added nested-thread navigation (thread_detail_page.dart#L300) without adapting the query.Possible fixes
Option A — query the true root, filter client-side (mobile only).
ThreadDetailPagealready computes the right value one screen further down, for the compose bar (#L136):Passing that to
threadRepliesProvideris sufficient — the page already indexes replies by parent and selectschildrenByParent[threadHead.id](#L71-L78), so the narrowing is already implemented. Live refresh also already invalidates on both root and parent (channel_messages_provider.dart#L187-L208).Small and matches desktop's effective behaviour. Cost: a nested view fetches the entire root thread and discards most of it, which grows with thread size.
Option B — support parent-scoped thread queries in the relay.
Add an opt-in filter so the query can bound on
parent_event_id(or on a subtree) instead ofroot_event_id, and have mobile use it. No over-fetching and it makes nested threads a first-class concept, but it is a relay change with migration and API-surface implications.Happy to send a PR for Option A, or for Option B if that is the preferred direction — I'd rather confirm before writing relay code.
Environment
main@7e34bee6