block / block/buzz

[Bug] Mobile: replies sent in a nested thread never appear — thread query is root-scoped

Open
#2,415 3 comments 2 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
32.7k
Forks
4.3k
Avg merge
1d 13h
Merged PRs (30d)
253

Description

## 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

1. On mobile, open a channel and post a message — call it **R**.
2. Open R's thread and post a reply — call it **M**.
3. From inside R's thread, open **M's own thread** (the nested-thread row).
4. 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`](https://github.com/block/buzz/blob/7e34bee62cacaa9d8a96c14d5892a471b59a1983/crates/buzz-db/src/thread.rs#L397-L398)):

```sql
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`](https://github.com/block/buzz/blob/7e34bee62cacaa9d8a96c14d5892a471b59a1983/mobile/lib/features/channels/thread_detail_page.dart#L56-L58)):

```dart
final repliesState = ref.watch(
threadRepliesProvider(
ThreadRepliesArgs(channelId: channelId, rootId: threadHead.id),
),
);
```

which becomes `'#e': [args.rootId]` in [`thread_replies_provider.dart#L56`](https://github.com/block/buzz/blob/7e34bee62cacaa9d8a96c14d5892a471b59a1983/mobile/lib/features/channels/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`](https://github.com/block/buzz/blob/7e34bee62cacaa9d8a96c14d5892a471b59a1983/crates/buzz-relay/src/handlers/ingest.rs#L632)):

```rust
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`](https://github.com/block/buzz/blob/7e34bee62cacaa9d8a96c14d5892a471b59a1983/mobile/lib/features/channels/send_message_provider.dart#L122-L136)):

```dart
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`](https://github.com/block/buzz/blob/7e34bee62cacaa9d8a96c14d5892a471b59a1983/desktop/src/features/channels/ui/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`](https://github.com/block/buzz/blob/7e34bee62cacaa9d8a96c14d5892a471b59a1983/mobile/lib/features/channels/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`](https://github.com/block/buzz/blob/7e34bee62cacaa9d8a96c14d5892a471b59a1983/mobile/lib/features/channels/thread_detail_page.dart#L136)):

```dart
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`](https://github.com/block/buzz/blob/7e34bee62cacaa9d8a96c14d5892a471b59a1983/mobile/lib/features/channels/thread_detail_page.dart#L71-L78)), so the narrowing is already implemented. Live refresh also already invalidates on both root and parent ([`channel_messages_provider.dart#L187-L208`](https://github.com/block/buzz/blob/7e34bee62cacaa9d8a96c14d5892a471b59a1983/mobile/lib/features/channels/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

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.