Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
81b8468
fix(mobile): avoid opening empty threads on message tap
brow Sep 19, 2026
15f35f4
Merge remote-tracking branch 'origin/main' into fix-mobile-message-tap
brow Sep 19, 2026
110e988
test(mobile): give avatar thread fixture a reply
brow Sep 19, 2026
24aef3e
fix(mobile): keep optimistic threads tappable
brow Sep 19, 2026
c462bd4
fix(mobile): use channel pending state for thread taps
brow Sep 19, 2026
0a4c8d7
fix(mobile): retain acknowledged replies without empty overlays
brow Sep 19, 2026
7a1c854
fix(mobile): preserve confirmed replies for channel navigation
brow Sep 19, 2026
831f04a
fix(mobile): settle local replies on live confirmation
brow Sep 19, 2026
a2900f1
Merge remote-tracking branch 'origin/main' into fix-mobile-message-tap
brow Sep 19, 2026
c19b4a3
fix(mobile): retain confirmed replies across reconnects
brow Sep 19, 2026
6131214
fix(mobile): reconcile cached replies after thread refresh
brow Sep 19, 2026
eb7fe1c
fix(mobile): reconcile reply evidence with explicit deletions
brow Sep 19, 2026
198fcbd
fix(mobile): evict roots and reply evidence together
brow Sep 19, 2026
08a2164
fix(mobile): bound reply caches and settle deleted overlays
brow Sep 19, 2026
c2b0ea1
fix(mobile): retain replies for pinned thread roots
brow Sep 19, 2026
ea5c1f6
fix(mobile): preserve bounded thread summaries with complete scans
brow Sep 19, 2026
8eee22d
test(mobile): cover summaries with evicted reply ancestors
brow Sep 19, 2026
0c06103
Recount bounded reply evidence and reconcile summary lifecycle
brow Sep 19, 2026
79bc831
Use braced control flow in thread recounts
brow Sep 19, 2026
be0113f
Reconcile absent and uncertain thread counts without losing navigation
brow Sep 19, 2026
6d14b51
Reconcile paginated summaries and preserve recount progress
brow Sep 19, 2026
4ef468b
Fence page summaries and target deletion recounts idempotently
brow Sep 19, 2026
18d18f7
Keep Riverpod access inside the channel notifier
brow Sep 19, 2026
6321801
Recover deleted reply summaries from retained relay metadata
brow Sep 19, 2026
ab55ed1
Preserve deletion lookup ordering and scoped reconciliation
brow Sep 19, 2026
e2a4cd5
Correlate deletion ownership recovery per target
brow Sep 19, 2026
ba4ba19
Fill thread pages past unreconstructable stored events
brow Sep 19, 2026
817ce09
Bound thread page refills after corrupt rows
brow Sep 19, 2026
222ac68
Retain processed deletion targets across reconnect pruning
brow Sep 19, 2026
fb02336
Reconcile cached thread summaries after WebSocket fallback
brow Sep 19, 2026
3ac1a9c
Reconcile live counts with contradictory cached replies
brow Sep 19, 2026
9b334a7
fix(mobile): recount cached reply roots after history fallback
brow Sep 19, 2026
7125f47
fix(mobile): fence ownership responses on known deletions
brow Sep 19, 2026
abf3a75
fix(mobile): bound deletion ownership recovery across events
brow Sep 19, 2026
4bd99e1
fix(mobile): retry transient deletion ownership queries
brow Sep 20, 2026
8124f55
fix(mobile): preserve uncertainty for superseded ownership counts
brow Sep 20, 2026
0434847
fix(mobile): resume deletion ownership recovery after reconnect
brow Sep 20, 2026
a6d07f0
fix(mobile): separate pending thread queries from applied evidence
brow Sep 20, 2026
af7bfd6
fix(mobile): retry transient thread recount failures
brow Sep 20, 2026
af02b00
fix(mobile): preserve broadcast reply tap navigation
brow Sep 20, 2026
03d50f9
fix(mobile): avoid recounting reconciled deletion proofs
brow Sep 20, 2026
540c734
fix(mobile): queue all deletion targets within shared bounds
brow Sep 20, 2026
e200a41
fix(mobile): retain deferred deletion targets for replay
brow Sep 20, 2026
a8a23d6
fix(mobile): drain deferred deletion lookups automatically
brow Sep 20, 2026
09ab90b
fix(mobile): retain replies acknowledged during thread scans
brow Sep 20, 2026
195e82e
fix(mobile): reconcile nested broadcast reply summaries
brow Sep 20, 2026
b1dc114
fix(mobile): bound pending reply deletion proofs per scan
brow Sep 20, 2026
0430b59
fix(mobile): reconcile delayed live thread summaries
brow Sep 20, 2026
37bdb51
fix(mobile): discard rejected live summary payloads
brow Sep 20, 2026
bd214cd
fix(mobile): mark nested recount summaries uncertain
brow Sep 20, 2026
cb25bdc
fix(mobile): recount visible branches of off-window roots
brow Sep 20, 2026
ef3028a
fix(mobile): accept scoped multi-target deletion proofs
brow Sep 20, 2026
34c7fcc
fix(mobile): rotate bounded deletion proof batches
brow Sep 20, 2026
0e00549
fix(mobile): reconcile fallback reply snapshots
brow Sep 20, 2026
65cb69f
fix(mobile): deduplicate scan-reconciled live deletions
brow Sep 20, 2026
c156ce4
fix(mobile): reconcile summaries when branches become visible
brow Sep 20, 2026
c99809a
fix(mobile): scope deletion proof reconciliation to queried targets
brow Sep 20, 2026
bcfa8cf
fix(mobile): reconcile retained targets in deletion proofs
brow Sep 20, 2026
13a80a8
fix(mobile): refresh retained threads after reconnect
brow Sep 20, 2026
7ac39d1
fix(mobile): avoid recounting unchanged nested evidence
brow Sep 20, 2026
9928882
fix(mobile): reconcile live summaries after zero-reply pages
brow Sep 20, 2026
d3ee9ff
fix(mobile): reconcile fallback reply cache evictions
brow Sep 20, 2026
9e7e41f
fix(mobile): include direct replies in deletion uncertainty
brow Sep 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/_ci-relay.yml
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ jobs:
./scripts/start-relay-for-tests.sh --no-build
- name: Relay E2E tests
run: |
cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_project -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_thread_roots --test e2e_project -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip29_departure_wire -- --ignored --nocapture
Expand Down
476 changes: 393 additions & 83 deletions crates/buzz-db/src/store/thread.rs

Large diffs are not rendered by default.

24 changes: 23 additions & 1 deletion crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ use crate::state::AppState;

use super::{api_error, internal_error, not_found};

mod thread_roots;

pub(crate) async fn enforce_http_admission(
state: &AppState,
tenant: &TenantContext,
Expand Down Expand Up @@ -1185,10 +1187,30 @@ async fn query_events_authed(
let mut events: Vec<Value> = Vec::new();
let mut handled: std::collections::HashSet<usize> = std::collections::HashSet::new();

let ownership_targets: usize = raw_filters
.iter()
.zip(&filters)
.filter(|(raw, _)| extension_flag(raw, "resolve_thread_roots"))
.map(|(_, filter)| filter.ids.as_ref().map_or(0, |ids| ids.len()))
.sum();
if ownership_targets > 100 {
return Err(api_error(
StatusCode::BAD_REQUEST,
"resolve_thread_roots permits at most 100 targets per request",
));
}
// Resolve reply owners from retained thread metadata, including tombstones.
for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() {
if extension_flag(raw, "resolve_thread_roots") {
events.extend(thread_roots::query(state, tenant, filter, &accessible_channels).await?);
handled.insert(idx);
}
}

// Channel-window filters (`top_level: true`) — the GUI read-model surface.
// Dispatched first: a window filter is never a feed/thread/catchall query.
for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() {
if !extension_flag(raw, "top_level") {
if handled.contains(&idx) || !extension_flag(raw, "top_level") {
continue;
}
handle_channel_window_filter(
Expand Down
123 changes: 123 additions & 0 deletions crates/buzz-relay/src/api/bridge/thread_roots.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//! Metadata-only recovery after a reply deletion whose live recount was missed.

use axum::{http::StatusCode, Json};
use buzz_core::{kind::KIND_THREAD_SUMMARY, TenantContext};
use nostr::{EventBuilder, Filter, Kind, Tag};
use serde_json::Value;
use uuid::Uuid;

use super::{api_error, extract_channel_from_filter, internal_error};
use crate::state::AppState;

type QueryError = (StatusCode, Json<Value>);

fn request_channel(filter: &Filter) -> Result<Uuid, QueryError> {
let channel = extract_channel_from_filter(filter).ok_or_else(|| {
api_error(
StatusCode::BAD_REQUEST,
"resolve_thread_roots requires exactly one #h channel",
)
})?;
if !filter.kinds.as_ref().is_some_and(|kinds| {
kinds.len() == 1 && kinds.contains(&Kind::Custom(KIND_THREAD_SUMMARY as u16))
}) {
return Err(api_error(
StatusCode::BAD_REQUEST,
"resolve_thread_roots requires kinds [39005]",
));
}
if !filter
.ids
.as_ref()
.is_some_and(|ids| !ids.is_empty() && ids.len() <= 100)
{
return Err(api_error(
StatusCode::BAD_REQUEST,
"resolve_thread_roots requires 1 to 100 target ids",
));
}
Ok(channel)
}

/// Return signed root summaries from retained, channel-scoped reply metadata.
pub(super) async fn query(
state: &AppState,
tenant: &TenantContext,
filter: &Filter,
accessible_channels: &[Uuid],
) -> Result<Vec<Value>, QueryError> {
let channel = request_channel(filter)?;
if !accessible_channels.contains(&channel) {
return Ok(Vec::new());
}
let ids = filter
.ids
.iter()
.flatten()
.map(|id| id.as_bytes().to_vec())
.collect::<Vec<_>>();
let summaries = state
.db
.resolve_thread_root_summaries(tenant.community(), channel, &ids)
.await
.map_err(|e| internal_error(&format!("thread ownership summaries: {e}")))?;
let mut events = Vec::with_capacity(summaries.len());
for (root, summary) in summaries {
let root_hex = hex::encode(root);
let channel_hex = channel.to_string();
let tags = [
["e", root_hex.as_str()],
["d", root_hex.as_str()],
["h", channel_hex.as_str()],
]
.into_iter()
.map(Tag::parse)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| internal_error(&format!("thread summary tags: {e}")))?;
let content = serde_json::json!({
"reply_count": summary.reply_count,
"descendant_count": summary.descendant_count,
"last_reply_at": summary.last_reply_at.map(|t| t.timestamp()),
"participants": summary.participants.iter().map(hex::encode).collect::<Vec<_>>(),
});
let event = EventBuilder::new(
Kind::Custom(KIND_THREAD_SUMMARY as u16),
content.to_string(),
)
.tags(tags)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| internal_error(&format!("thread summary signing: {e}")))?;
events.push(
serde_json::to_value(event)
.map_err(|e| internal_error(&format!("thread summary serialization: {e}")))?,
);
}
Ok(events)
}

#[cfg(test)]
mod tests {
use super::*;

fn filter(ids: usize) -> Filter {
serde_json::from_value(serde_json::json!({
"kinds": [39005], "#h": ["11111111-1111-4111-8111-111111111111"],
"ids": (0..ids).map(|id| format!("{id:064x}")).collect::<Vec<_>>()
}))
.expect("valid filter")
}

#[test]
fn ownership_request_requires_bounded_ids_and_explicit_scope() {
assert!(request_channel(&filter(1)).is_ok());
assert!(request_channel(&filter(100)).is_ok());
assert!(request_channel(&filter(0)).is_err());
assert!(request_channel(&filter(101)).is_err());
let mut wrong_kind = filter(1);
wrong_kind.kinds = Some([Kind::TextNote].into_iter().collect());
assert!(request_channel(&wrong_kind).is_err());
let mut unscoped = filter(1);
unscoped.generic_tags.clear();
assert!(request_channel(&unscoped).is_err());
}
}
148 changes: 148 additions & 0 deletions crates/buzz-test-client/tests/e2e_thread_roots.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
//! Tombstone-aware ownership recovery through the authenticated Nostr bridge.
//! Run against a local development relay with `--ignored`.

use nostr::{Event, EventBuilder, Keys, Kind, Tag};
use serde_json::{json, Value};

fn url() -> String {
std::env::var("RELAY_URL")
.unwrap_or_else(|_| "ws://localhost:3000".into())
.replace("ws://", "http://")
.replace("wss://", "https://")
}

async fn post(keys: &Keys, event: &Event) {
let response = reqwest::Client::new()
.post(format!("{}/events", url()))
.header("X-Pubkey", keys.public_key().to_hex())
.json(event)
.send()
.await
.expect("event request");
assert!(
response.status().is_success(),
"event status {}",
response.status()
);
let body: Value = response.json().await.expect("event response");
assert_eq!(body["accepted"], true, "event rejected: {body}");
}

async fn query(keys: &Keys, filter: Value) -> Vec<Event> {
let response = reqwest::Client::new()
.post(format!("{}/query", url()))
.header("X-Pubkey", keys.public_key().to_hex())
.json(&vec![filter])
.send()
.await
.expect("query request");
assert!(
response.status().is_success(),
"query status {}",
response.status()
);
response.json().await.expect("signed event array")
}

async fn channel(keys: &Keys, private: bool) -> String {
let id = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(9007), "")
.tags([
Tag::parse(["h", &id]).unwrap(),
Tag::parse(["name", &format!("thread-roots-{id}")]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", if private { "private" } else { "open" }]).unwrap(),
])
.sign_with_keys(keys)
.unwrap();
post(keys, &event).await;
id
}

fn request(channel: &str, target: &Event) -> Value {
json!({"kinds": [39005], "ids": [target.id.to_hex()], "#h": [channel], "resolve_thread_roots": true})
}

#[tokio::test]
#[ignore]
async fn deleted_reply_roots_are_recovered_without_content_or_scope_leaks() {
let owner = Keys::generate();
let outsider = Keys::generate();
let other_channel = channel(&owner, false).await;
let bounded_filter = json!({
"kinds": [39005], "#h": [&other_channel], "resolve_thread_roots": true,
"ids": (0..60).map(|id| format!("{id:064x}")).collect::<Vec<_>>()
});
let oversized = reqwest::Client::new()
.post(format!("{}/query", url()))
.header("X-Pubkey", owner.public_key().to_hex())
.json(&vec![bounded_filter.clone(), bounded_filter])
.send()
.await
.expect("bounded batch");
assert_eq!(
oversized.status(),
reqwest::StatusCode::BAD_REQUEST,
"the target cap applies across filters, not just within each filter"
);
for deletion_kind in [5, 9005] {
let channel_id = channel(&owner, true).await;
let root = EventBuilder::new(Kind::Custom(9), "root")
.tags([Tag::parse(["h", &channel_id]).unwrap()])
.sign_with_keys(&owner)
.unwrap();
post(&owner, &root).await;
let reply = EventBuilder::new(Kind::Custom(9), "deleted secret payload")
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["e", &root.id.to_hex(), "", "reply"]).unwrap(),
])
.sign_with_keys(&owner)
.unwrap();
post(&owner, &reply).await;
let deletion = EventBuilder::new(Kind::Custom(deletion_kind), "")
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["e", &reply.id.to_hex()]).unwrap(),
])
.sign_with_keys(&owner)
.unwrap();
post(&owner, &deletion).await;

let ordinary = query(
&owner,
json!({"kinds":[9],"ids":[reply.id.to_hex()],"#h":[channel_id]}),
)
.await;
assert!(
ordinary.is_empty(),
"ordinary history must exclude the deleted payload"
);
let summaries = query(&owner, request(&channel_id, &reply)).await;
assert_eq!(summaries.len(), 1, "metadata must survive deletion");
let summary = &summaries[0];
summary.verify().expect("relay signature");
assert_eq!(summary.kind, Kind::Custom(39005));
assert!(summary
.tags
.iter()
.any(|tag| tag.as_slice() == ["e", &root.id.to_hex()]));
let counts: Value = serde_json::from_str(&summary.content).unwrap();
assert_eq!(counts["reply_count"], 0);
assert_eq!(counts["descendant_count"], 0);
assert!(!summary.content.contains("deleted secret payload"));

assert!(
query(&owner, request(&other_channel, &reply))
.await
.is_empty(),
"wrong channel must not leak ownership"
);
assert!(
query(&outsider, request(&channel_id, &reply))
.await
.is_empty(),
"private channel membership is required"
);
}
}
22 changes: 22 additions & 0 deletions docs/nips/NIP-CW.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,25 @@ A client with neither an authenticated transport nor a verifiable relay identity
- **NIP-50** and relay-side search: sibling precedent — a relay-computed view requested through extended filter fields, invisible to relays that do not implement it.
- **NIP-98**: Authenticates the HTTP query surface Buzz serves windows on.
- **NIP-11**: Names the relay identity that signs overlays and the natural place to advertise support.

## Recovering summaries after deletion

The HTTP bridge additionally supports `resolve_thread_roots: true` with
`kinds: [39005]`, exactly one accessible `#h` channel, and 1–100 full event
`ids`. The IDs identify target replies, including soft-deleted replies,
not the IDs of the returned summary events. The request permits at most
100 targets across all such filters.

The relay resolves ownership from retained thread metadata on the writer,
checks both target and root channel scope, and returns one signed
`kind:39005` summary per distinct owning root, including zero counts.
It returns no original target content, author, or signature. Missing or
non-reply targets and inaccessible channels produce no summaries.
These reads use the writer so deletion recovery does not depend on
replica replay or the delivery of a live summary. No bounds event is returned.

Clients use this bounded metadata operation when a deletion target has
left their reply cache. Ordinary event queries exclude tombstones and
cannot perform this recovery. This extension requires a supporting relay;
clients must not interpret an unsupported/empty response as proof that an
unknown target's thread is empty.
1 change: 1 addition & 0 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import '../profile/user_profile_sheet.dart';
import 'small_avatar.dart';
import 'sticky_date_header.dart';
import 'thread_detail_page.dart';
import 'thread_replies_provider.dart';
import 'timeline_message.dart';

part 'channel_detail_page/message_list.dart';
Expand Down
Loading
Loading