From 52d13efc08ec2b80878f24672913ac465b37e148 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Wed, 14 Oct 2026 08:27:26 -0400 Subject: [PATCH 001/144] fix(buzz-db): delete workflow and membership children explicitly (#8212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Workflow deletion and relay-member removal relied on `ON DELETE CASCADE` to remove child rows. Both paths now delete their own children inside the same transaction, so a later migration can drop those foreign-key actions without changing behavior. **Workflow deletion** (`store/workflow/deletion.rs`, the coordinate path used by NIP-09 deletes of workflow definitions): - Locks the target workflow row with `SELECT … FOR UPDATE`, under the same owner and coordinate predicate as before. A concurrent run or fire insert has to lock that row for its foreign key, so the child deletes see every committed child. - Deletes scheduled fires first. A new fire has to lock the workflow row, and a cron attach locks the fire before the run, so deleting fires before the run lock keeps the lock order consistent and avoids a deadlock. - Then locks the workflow's runs `FOR UPDATE` in `id` order. An approval that names another workflow but references one of these runs key-share locks only the run, so the workflow lock alone doesn't exclude it. - `delete_workflow_with_children` then deletes approvals (for the workflow or any of its runs), then runs, then the workflow, and returns its `channel_id`. **Relay-member removal** (`store/relay_members.rs`): `remove_relay_member` and `remove_relay_member_if_role` share a new `delete_member_with_acceptances` helper. It locks the member row under the existing predicate (any non-owner, or an exact role), deletes `join_policy_acceptances`, then deletes the member, in one transaction. The `RemoveResult` outcomes are unchanged. **Removed:** `delete_workflow` (no callers) and `delete_workflow_for_owner` (test-only), plus their `Db` wrappers. The community-confinement test now drives the production coordinate deletion. ## Tests - `test_support::retire_foreign_key_delete_actions` rewrites every `CASCADE`, `SET NULL` and `SET DEFAULT` foreign key in the test's own per-test database as `NO ACTION`, which is the target schema of the follow-up migration. It asserts that none remain. All triggers stay live, including foreign-key checks and the community write fence. It refuses to run unless `current_database()` has the per-test wrapper's exact shape (`buzz_nt_` plus 24 hex digits), which excludes the run's `_desired` template. It checks the name only. - `workflow_deletion_removes_children_without_cascades` and `member_removal_deletes_acceptances_without_cascades` run the production paths on that schema and require every child row to be gone. One approval references another workflow but this workflow's run, which pins the `run_id` branch. The owner, role-mismatch and not-found outcomes still hold. - `workflow_deletion_waits_for_and_removes_concurrent_run` and `member_removal_waits_for_and_removes_concurrent_acceptance` hold a child insert open in a second transaction, wait until the removal is blocked on a lock, and then commit. The removal must succeed with no children left. - `workflow_deletion_waits_for_concurrent_fire_attach_without_deadlock` holds a fire row the way the cron attach does, then attaches a run to it while the deletion waits. Both must succeed. - `workflow_deletion_waits_for_and_removes_concurrent_cross_workflow_approval` does the same with an approval for another workflow that references this workflow's run. - The bridge test schema now includes `join_policy_acceptances`. ## What got simpler Two delete paths no longer depend on implicit constraint actions, and two unused workflow-delete functions are gone. This is the first step of retiring the `CASCADE` and `SET NULL` foreign keys. **Identified debt (later PRs):** `channel_members` and `personal_read_frontiers` (no hard-delete path found yet for their parents), the deletion `manifest_keys` cascade, the `SET NULL` actions on `executor_heartbeats` and `product_feedback`, and the `users` self-reference. That last one is a composite `(community_id, agent_owner_pubkey)` `ON DELETE SET NULL` that would try to null the `NOT NULL` `community_id`. Also, a scheduled fire for workflow B can link to a run of A (that FK is already `NO ACTION`), which would block A's run delete; the scheduler always attaches a run to its own workflow's fire, so this needs same-workflow enforcement in the cascade work. ## Verification Head `081be4c0d`, tree `ab649487`, on a Linux cloud workstation: fmt, buzz-db and workspace clippy, source policy and buzz-db lib pass; the six new tests pass; buzz-db PostgreSQL passes 490/490 and buzz-relay PostgreSQL 378/378. A sixth mutation, moving the fire delete back after the run lock, makes `workflow_deletion_waits_for_concurrent_fire_attach_without_deadlock` fail with `40P01` (deadlock detected). Previous head: Head `787c7c42d`, tree `6b9cf5d5`, on a Linux cloud workstation: fmt, buzz-db and workspace clippy, source policy and buzz-db lib pass; the new tests pass 5/5; buzz-relay PostgreSQL passes 378/378; buzz-db PostgreSQL passes 488/489, the one failure being `parent_contention_times_out_without_blocking_the_other_table` in `store/partition/`, which this PR doesn't touch and which passes on its own at the same tree. A fifth mutation, removing the run lock, makes the new cross-workflow approval test fail with the reviewer's exact 23503 on `workflow_approvals_community_id_run_id_fkey`. Previous head `00cba2f4a`, tree `2f89fed4`, base `e9269cbdf`. Results from a Linux cloud workstation: - fmt, buzz-db and workspace clippy, the source policy and buzz-db lib all pass. - The buzz-db PostgreSQL suite passes 488/488. - The buzz-relay PostgreSQL suite passes 378/378. Each of four mutations makes exactly one of the new tests fail with the foreign-key violation you'd expect (23503): - swapping the fires and runs deletes - deleting approvals by `workflow_id` only - removing the workflow row lock - removing the member row lock GitHub CI is green at this head. buzz-review-completed --------- Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> --- crates/buzz-db/src/store/community.rs | 38 +-- crates/buzz-db/src/store/relay_members.rs | 199 +++++++++++-- crates/buzz-db/src/store/workflow.rs | 82 +---- crates/buzz-db/src/store/workflow/deletion.rs | 74 ++++- .../src/store/workflow/deletion_tests.rs | 279 +++++++++++++++++- crates/buzz-db/src/test_support.rs | 90 ++++++ crates/buzz-relay/src/api/bridge.rs | 4 +- 7 files changed, 619 insertions(+), 147 deletions(-) diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index 6cf43ee7c53..24075359587 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -1180,39 +1180,7 @@ mod postgres_tests { } async fn named_contender_db(application_name: &str) -> Db { - use std::str::FromStr as _; - let options = - sqlx::postgres::PgConnectOptions::from_str(&crate::test_support::database_url()) - .expect("parse test database URL") - .application_name(application_name); - let pool = sqlx::postgres::PgPoolOptions::new() - .max_connections(1) - .connect_with(options) - .await - .expect("connect contender DB"); - Db::from_pool(pool) - } - - async fn wait_for_lock_wait(db: &Db, application_name: &str) { - tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - let waiting: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM pg_stat_activity \ - WHERE datname = current_database() AND application_name = $1 \ - AND wait_event_type = 'Lock')", - ) - .bind(application_name) - .fetch_one(&db.pool) - .await - .expect("inspect contender lock wait"); - if waiting { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("contender reached a lock wait"); + Db::from_pool(crate::test_support::named_pool(application_name).await) } /// An archive that queues behind an ownership transfer must re-check @@ -1256,7 +1224,7 @@ mod postgres_tests { .await } }); - wait_for_lock_wait(&db, &transfer_name).await; + crate::test_support::wait_for_lock_wait(&db.pool, &transfer_name).await; let archive_name = format!("archive-transfer-archive-{}", Uuid::new_v4().simple()); let archive_db = named_contender_db(&archive_name).await; @@ -1269,7 +1237,7 @@ mod postgres_tests { .await } }); - wait_for_lock_wait(&db, &archive_name).await; + crate::test_support::wait_for_lock_wait(&db.pool, &archive_name).await; owner_rows_gate .rollback() diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index 8db20820146..742e9173f2a 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -243,11 +243,51 @@ pub enum RemoveResult { RoleMismatch, } +/// Deletes a membership and its join-policy acceptances in one transaction. +/// +/// With `expected_role`, only a member holding exactly that role is removed; +/// without it, any non-owner is. The member row is locked under that predicate +/// first, so a concurrent acceptance insert (which must lock the member row for +/// its foreign key) either commits before the child delete sees it or fails. +/// Children go before the parent so no foreign-key action is needed. +async fn delete_member_with_acceptances( + connection: &mut sqlx::PgConnection, + community: CommunityId, + pubkey: &str, + expected_role: Option<&str>, +) -> Result { + let mut tx = sqlx::Connection::begin(connection).await?; + let locked = sqlx::query( + "SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2 \ + AND CASE WHEN $3::text IS NULL THEN role <> 'owner' ELSE role = $3 END \ + FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(expected_role) + .fetch_optional(&mut *tx) + .await?; + if locked.is_none() { + return Ok(false); + } + sqlx::query("DELETE FROM join_policy_acceptances WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) + .bind(pubkey) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM relay_members WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) + .bind(pubkey) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(true) +} + /// Removes a relay member atomically, refusing to delete the owner. /// -/// Uses a single conditional `DELETE … WHERE role <> 'owner'` so the -/// owner-protection check and the deletion are one atomic operation — -/// no TOCTOU race between a separate read and delete. +/// The owner-protection check and the deletion run in one transaction under a +/// row lock, so there is no TOCTOU race between a separate read and delete. pub async fn remove_relay_member( pool: &PgPool, community: CommunityId, @@ -255,20 +295,11 @@ pub async fn remove_relay_member( ) -> Result { let mut connection = observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; - let result = sqlx::query( - "DELETE FROM relay_members \ - WHERE community_id = $1 AND pubkey = $2 AND role <> 'owner'", - ) - .bind(community.as_uuid()) - .bind(pubkey) - .execute(&mut *connection) - .await?; - - if result.rows_affected() > 0 { + if delete_member_with_acceptances(&mut connection, community, pubkey, None).await? { return Ok(RemoveResult::Removed); } - // rows_affected == 0: either not found or is owner. One cheap read to + // Nothing was locked: either not found or is owner. One cheap read to // distinguish the two cases so callers can return the right error message. let exists = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) @@ -285,9 +316,8 @@ pub async fn remove_relay_member( /// Removes a relay member only if their current role matches `expected_role`. /// -/// The delete and the role check are collapsed into a single -/// `DELETE … WHERE pubkey = $1 AND role = $2`, making the operation atomic — -/// no TOCTOU race between a prior read and this delete. +/// The role check and the delete run in one transaction under a row lock, so +/// there is no TOCTOU race between a prior read and this delete. /// /// Returns: /// - `Removed` — row was deleted. @@ -304,20 +334,13 @@ pub async fn remove_relay_member_if_role( ) -> Result { let mut connection = observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; - let result = sqlx::query( - "DELETE FROM relay_members WHERE community_id = $1 AND pubkey = $2 AND role = $3", - ) - .bind(community.as_uuid()) - .bind(pubkey) - .bind(expected_role) - .execute(&mut *connection) - .await?; - - if result.rows_affected() > 0 { + if delete_member_with_acceptances(&mut connection, community, pubkey, Some(expected_role)) + .await? + { return Ok(RemoveResult::Removed); } - // rows_affected == 0: either not found or role changed. One cheap read to + // Nothing was locked: either not found or role changed. One cheap read to // distinguish the cases so callers can return the right error message. let row = sqlx::query("SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) @@ -1392,6 +1415,126 @@ mod postgres_tests { ); } + async fn acceptances(pool: &PgPool, community: CommunityId, pubkey: &str) -> i64 { + sqlx::query_scalar( + "SELECT count(*) FROM join_policy_acceptances WHERE community_id = $1 AND pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_one(pool) + .await + .expect("count acceptances") + } + + /// Both removal paths delete acceptance evidence themselves: with every + /// foreign-key delete action retired, nothing cascades. Owners and a role + /// mismatch keep theirs. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn member_removal_deletes_acceptances_without_cascades() { + let pool = setup_pool().await; + crate::test_support::retire_foreign_key_delete_actions(&pool).await; + let (community, owner) = owned_community(&pool).await; + let version = "a".repeat(64); + sqlx::query( + "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ + VALUES ($1, $2, $3)", + ) + .bind(community.as_uuid()) + .bind(&owner) + .bind(&version) + .execute(&pool) + .await + .expect("owner acceptance"); + let member = test_pubkey(); + let admin = test_pubkey(); + for (pubkey, role) in [(&member, "member"), (&admin, "admin")] { + claim_relay_membership(&pool, community, pubkey, role, Some(&version)) + .await + .expect("claim membership"); + assert_eq!(acceptances(&pool, community, pubkey).await, 1); + } + + assert_eq!( + remove_relay_member(&pool, community, &owner) + .await + .expect("owner removal"), + RemoveResult::IsOwner + ); + assert_eq!(acceptances(&pool, community, &owner).await, 1); + assert_eq!( + remove_relay_member_if_role(&pool, community, &admin, "member") + .await + .expect("mismatched removal"), + RemoveResult::RoleMismatch + ); + assert_eq!(acceptances(&pool, community, &admin).await, 1); + + assert_eq!( + remove_relay_member(&pool, community, &member) + .await + .expect("member removal"), + RemoveResult::Removed + ); + assert_eq!(acceptances(&pool, community, &member).await, 0); + assert_eq!( + remove_relay_member_if_role(&pool, community, &admin, "admin") + .await + .expect("role removal"), + RemoveResult::Removed + ); + assert_eq!(acceptances(&pool, community, &admin).await, 0); + } + + /// An acceptance inserted concurrently with the removal holds the member + /// row's key-share lock. Removal must wait for it at its row lock and then + /// delete it; with no cascade, a removal that took its child snapshot first + /// would fail the foreign-key check on the member. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn member_removal_waits_for_and_removes_concurrent_acceptance() { + let pool = setup_pool().await; + crate::test_support::retire_foreign_key_delete_actions(&pool).await; + let (community, _owner) = owned_community(&pool).await; + let member = test_pubkey(); + claim_relay_membership(&pool, community, &member, "member", Some(&"a".repeat(64))) + .await + .expect("claim membership"); + + let mut inserter = pool.begin().await.expect("begin acceptance insert"); + sqlx::query( + "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ + VALUES ($1, $2, $3)", + ) + .bind(community.as_uuid()) + .bind(&member) + .bind("b".repeat(64)) + .execute(&mut *inserter) + .await + .expect("insert concurrent acceptance"); + + let name = format!("member-remove-{}", Uuid::new_v4().simple()); + let remover = crate::test_support::named_pool(&name).await; + let removal = tokio::spawn({ + let member = member.clone(); + async move { remove_relay_member(&remover, community, &member).await } + }); + crate::test_support::wait_for_lock_wait(&pool, &name).await; + inserter + .commit() + .await + .expect("commit concurrent acceptance"); + + assert_eq!( + removal + .await + .expect("join removal") + .expect("removal after concurrent acceptance"), + RemoveResult::Removed + ); + assert_eq!(acceptances(&pool, community, &member).await, 0); + } + async fn owned_community(pool: &PgPool) -> (CommunityId, String) { let community = make_test_community(pool).await; let owner = test_pubkey(); diff --git a/crates/buzz-db/src/store/workflow.rs b/crates/buzz-db/src/store/workflow.rs index da02c7f75d3..c89bf0cbf06 100644 --- a/crates/buzz-db/src/store/workflow.rs +++ b/crates/buzz-db/src/store/workflow.rs @@ -775,56 +775,6 @@ pub async fn disable_workflows_for_owner_in_channel_on_conn( Ok(affected) } -/// Delete a workflow and all its runs/approvals (CASCADE). -/// -/// NOTE: see the cache-invalidation note on [`update_workflow`]. The relay's -/// deletion path uses [`delete_workflow_for_owner`], which returns the -/// `channel_id` needed for invalidation. (No current callers.) -pub async fn delete_workflow(pool: &PgPool, community_id: CommunityId, id: Uuid) -> Result<()> { - let affected = sqlx::query("DELETE FROM workflows WHERE community_id = $1 AND id = $2") - .bind(community_id.as_uuid()) - .bind(id) - .execute(pool) - .await? - .rows_affected(); - - if affected == 0 { - return Err(DbError::NotFound(format!("workflow {id}"))); - } - Ok(()) -} - -/// Delete a workflow only when it belongs to `owner_pubkey`. -/// -/// Used by event-driven deletion paths where the workflow UUID is attacker -/// controlled. Keeping the owner predicate in the DELETE statement avoids a -/// check-then-delete race and ensures a caller cannot delete another user's -/// workflow just by learning its UUID. -/// -/// Returns the deleted workflow's `channel_id` so the caller can invalidate -/// the per-channel trigger cache without a separate lookup. -pub async fn delete_workflow_for_owner( - pool: &PgPool, - community_id: CommunityId, - id: Uuid, - owner_pubkey: &[u8], -) -> Result> { - let row = sqlx::query( - "DELETE FROM workflows WHERE community_id = $1 AND id = $2 AND owner_pubkey = $3 \ - RETURNING channel_id", - ) - .bind(community_id.as_uuid()) - .bind(id) - .bind(owner_pubkey) - .fetch_optional(pool) - .await?; - - match row { - Some(row) => Ok(row.try_get("channel_id")?), - None => Err(DbError::NotFound(format!("workflow {id}"))), - } -} - // -- Workflow Run CRUD -------------------------------------------------------- /// Insert a new workflow run. Returns the new run's UUID. @@ -1684,24 +1634,6 @@ impl Db { .await } - /// Delete a workflow and all its runs/approvals. - #[datastore_span(name = "delete_workflow", system = "postgresql")] - pub async fn delete_workflow(&self, community_id: CommunityId, id: Uuid) -> Result<()> { - crate::workflow::delete_workflow(&self.pool, community_id, id).await - } - - /// Delete a workflow only when it belongs to the provided owner. - /// Returns the deleted workflow's `channel_id`. - #[datastore_span(name = "delete_workflow_for_owner", system = "postgresql")] - pub async fn delete_workflow_for_owner( - &self, - community_id: CommunityId, - id: Uuid, - owner_pubkey: &[u8], - ) -> Result> { - crate::workflow::delete_workflow_for_owner(&self.pool, community_id, id, owner_pubkey).await - } - /// Find a workflow by owner pubkey and name within a community. Used for /// NIP-09 a-tag deletion where the d-tag is the workflow name (not UUID). #[datastore_span(name = "find_workflow_by_owner_and_name", system = "postgresql")] @@ -2656,9 +2588,9 @@ mod postgres_tests { } /// Issue 4 (workflow lifecycle): deleting `A/id` must not delete `B/id` - /// when both communities hold the same workflow UUID. Pre-fix - /// `delete_workflow` predicated only on `id`, so a NIP-09 a-tag deletion in - /// one community would erase the colliding workflow in every community. + /// when both communities hold the same workflow UUID. Pre-fix the workflow + /// delete predicated only on `id`, so a NIP-09 a-tag deletion in one + /// community would erase the colliding workflow in every community. #[tokio::test] #[ignore = "requires Postgres"] async fn workflow_delete_is_confined_to_its_community() { @@ -2684,7 +2616,13 @@ mod postgres_tests { ) .await; - delete_workflow(&pool, community_a, shared_workflow_id) + crate::Db::from_pool(pool.clone()) + .delete_workflow_by_coordinate( + community_a, + &[0xb2; 32], + &shared_workflow_id.to_string(), + chrono::Utc::now().timestamp(), + ) .await .expect("delete A's workflow"); diff --git a/crates/buzz-db/src/store/workflow/deletion.rs b/crates/buzz-db/src/store/workflow/deletion.rs index 27fde32b471..84f4b828459 100644 --- a/crates/buzz-db/src/store/workflow/deletion.rs +++ b/crates/buzz-db/src/store/workflow/deletion.rs @@ -4,7 +4,6 @@ use buzz_core::{kind::KIND_WORKFLOW_DEF, CommunityId, StoredEvent}; use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use nostr::Event; -use sqlx::Row; use uuid::Uuid; use crate::AdmittedTx; @@ -115,13 +114,16 @@ async fn delete_workflow_in_transaction( } // UUID coordinates are canonical; retain the legacy name-based path. - // The owner predicate remains in the mutation, not just a prior check. + // The owner predicate is in the locking SELECT; FOR UPDATE keeps it + // binding for the deletes below. let workflow_id = Uuid::parse_str(d_tag).ok(); - let row = sqlx::query( - "DELETE FROM workflows WHERE community_id = $1 AND owner_pubkey = $2 \ + // Lock the workflow first: a concurrent run or fire insert must lock it for + // its foreign key, so the child deletes below see every committed child. + let target: Option = sqlx::query_scalar( + "SELECT id FROM workflows WHERE community_id = $1 AND owner_pubkey = $2 \ AND id = COALESCE($3::uuid, (SELECT id FROM workflows \ WHERE community_id = $1 AND owner_pubkey = $2 AND name = $4 LIMIT 1)) \ - RETURNING channel_id", + FOR UPDATE", ) .bind(community_id.as_uuid()) .bind(owner_pubkey) @@ -129,6 +131,10 @@ async fn delete_workflow_in_transaction( .bind(d_tag) .fetch_optional(tx.conn()) .await?; + let deleted_channel = match target { + Some(id) => Some(delete_workflow_with_children(tx, id).await?), + None => None, + }; let definitions = sqlx::query( "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND kind = $2 \ AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL AND created_at <= $5", @@ -140,17 +146,65 @@ async fn delete_workflow_in_transaction( .bind(cutoff) .execute(tx.conn()) .await?; - let changed = row.is_some() || definitions.rows_affected() > 0; - let channel_id = row - .map(|row| row.try_get("channel_id")) - .transpose()? - .flatten(); + let changed = deleted_channel.is_some() || definitions.rows_affected() > 0; + let channel_id = deleted_channel.flatten(); Ok(WorkflowDeletionOutcome { changed, channel_id, }) } +/// Delete a locked workflow and the rows that hang off it, children first, so +/// no foreign-key action is needed: scheduled fires, approvals (of the workflow +/// or any of its runs), then runs. Returns the workflow's `channel_id`. +async fn delete_workflow_with_children(tx: &mut AdmittedTx, id: Uuid) -> Result> { + let community = *tx.community().as_uuid(); + // Fires go before the run lock. A new fire must key-share lock the workflow, + // which is already locked. Attaching a run to a fire locks the fire row and + // then the run, so taking the run lock first would invert that order and can + // deadlock; deleting the fire first waits for any in-flight attach instead. + sqlx::query( + "DELETE FROM scheduled_workflow_fires WHERE community_id = $1 AND workflow_id = $2", + ) + .bind(community) + .bind(id) + .execute(tx.conn()) + .await?; + // The workflow lock stops new runs, but an approval naming another workflow + // can still reference one of these runs; it key-share locks only that run. + // Lock the runs (in a fixed order) so such an insert either commits before + // the approval delete below or waits until this transaction ends. + sqlx::query( + "SELECT id FROM workflow_runs WHERE community_id = $1 AND workflow_id = $2 \ + ORDER BY id FOR UPDATE", + ) + .bind(community) + .bind(id) + .execute(tx.conn()) + .await?; + sqlx::query( + "DELETE FROM workflow_approvals WHERE community_id = $1 AND (workflow_id = $2 \ + OR run_id IN (SELECT id FROM workflow_runs \ + WHERE community_id = $1 AND workflow_id = $2))", + ) + .bind(community) + .bind(id) + .execute(tx.conn()) + .await?; + sqlx::query("DELETE FROM workflow_runs WHERE community_id = $1 AND workflow_id = $2") + .bind(community) + .bind(id) + .execute(tx.conn()) + .await?; + Ok(sqlx::query_scalar( + "DELETE FROM workflows WHERE community_id = $1 AND id = $2 RETURNING channel_id", + ) + .bind(community) + .bind(id) + .fetch_one(tx.conn()) + .await?) +} + #[cfg(test)] #[path = "deletion_tests.rs"] mod postgres_tests; diff --git a/crates/buzz-db/src/store/workflow/deletion_tests.rs b/crates/buzz-db/src/store/workflow/deletion_tests.rs index 0dd4909179d..7210d2efb7f 100644 --- a/crates/buzz-db/src/store/workflow/deletion_tests.rs +++ b/crates/buzz-db/src/store/workflow/deletion_tests.rs @@ -174,7 +174,10 @@ async fn workflow_deletion_preserves_newer_definition_and_cleans_legacy_orphan() assert_present(&db, &query, id).await; // Old relay releases deleted the executable row, but not the definition. - db.delete_workflow_for_owner(community, id, &owner) + sqlx::query("DELETE FROM workflows WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(id) + .execute(&db.pool) .await .expect("legacy deletion"); assert_eq!( @@ -360,3 +363,277 @@ async fn quiescing_community_rejects_workflow_deletion_at_admission_before_repla .expect("count deletion request"); assert_eq!(stored, 0, "a rejected deletion request must not be stored"); } + +async fn count_where(db: &Db, table: &str, column: &str, community: CommunityId, id: Uuid) -> i64 { + sqlx::query_scalar(sqlx::AssertSqlSafe(format!( + "SELECT count(*) FROM {table} WHERE community_id = $1 AND {column} = $2" + ))) + .bind(community.as_uuid()) + .bind(id) + .fetch_one(&db.pool) + .await + .expect("count children") +} + +/// With every foreign-key delete action retired, the coordinate delete must +/// remove approvals (of the workflow or of its runs), fires and runs itself, +/// in an order the remaining constraints accept. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_deletion_removes_children_without_cascades() { + let (db, community) = setup().await; + crate::test_support::retire_foreign_key_delete_actions(&db.pool).await; + let keys = Keys::generate(); + let owner = keys.public_key().to_bytes(); + let id = Uuid::new_v4(); + let d_tag = id.to_string(); + let now = Timestamp::now().as_secs(); + let query = seed(&db, community, &keys, id, &d_tag, now).await; + let other = Uuid::new_v4(); + seed(&db, community, &keys, other, &other.to_string(), now).await; + let run_id = create_workflow_run(&db.pool, community, id, None, None) + .await + .expect("run"); + sqlx::query( + "INSERT INTO scheduled_workflow_fires \ + (community_id, workflow_id, scheduled_for, workflow_run_id) VALUES ($1, $2, NOW(), $3)", + ) + .bind(community.as_uuid()) + .bind(id) + .bind(run_id) + .execute(&db.pool) + .await + .expect("scheduled claim linked to run"); + // One approval hangs off the workflow; the other names a different + // workflow but this workflow's run, so only the run_id branch reaches it. + for (token, workflow_id) in [("workflow-approval", id), ("run-approval", other)] { + crate::workflow::create_approval( + &db.pool, + crate::workflow::CreateApprovalParams { + community_id: community, + token, + workflow_id, + run_id, + step_id: "gate", + step_index: 0, + approver_spec: "@anyone", + expires_at: chrono::Utc::now() + chrono::Duration::hours(1), + }, + ) + .await + .expect("approval"); + } + + let outcome = db + .delete_workflow_by_coordinate(community, &owner, &d_tag, now as i64) + .await + .expect("delete without cascades"); + assert!(outcome.changed); + assert_absent(&db, &query, id).await; + for (table, column, key) in [ + ("workflow_approvals", "workflow_id", id), + ("workflow_approvals", "run_id", run_id), + ("scheduled_workflow_fires", "workflow_id", id), + ("workflow_runs", "workflow_id", id), + ] { + assert_eq!( + count_where(&db, table, column, community, key).await, + 0, + "{table} rows by {column} must be deleted explicitly" + ); + } + assert!(get_workflow(&db.pool, community, other).await.is_ok()); +} + +/// A run inserted concurrently with the delete holds the workflow's key-share +/// lock. The delete must wait for it at its row lock and then remove the run; +/// with no cascade, a delete that took its child snapshot first would fail the +/// foreign-key check on the workflow. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_deletion_waits_for_and_removes_concurrent_run() { + let (db, community) = setup().await; + crate::test_support::retire_foreign_key_delete_actions(&db.pool).await; + let keys = Keys::generate(); + let owner = keys.public_key().to_bytes(); + let id = Uuid::new_v4(); + let d_tag = id.to_string(); + let now = Timestamp::now().as_secs(); + seed(&db, community, &keys, id, &d_tag, now).await; + + let mut inserter = db.pool.begin().await.expect("begin run insert"); + sqlx::query( + "INSERT INTO workflow_runs (community_id, id, workflow_id, status, current_step, execution_trace) \ + VALUES ($1, $2, $3, 'pending', 0, '[]')", + ) + .bind(community.as_uuid()) + .bind(Uuid::new_v4()) + .bind(id) + .execute(&mut *inserter) + .await + .expect("insert concurrent run"); + + let name = format!("workflow-delete-{}", Uuid::new_v4().simple()); + let deleter = Db::from_pool(crate::test_support::named_pool(&name).await); + let deletion = tokio::spawn(async move { + deleter + .delete_workflow_by_coordinate(community, &owner, &d_tag, now as i64) + .await + }); + crate::test_support::wait_for_lock_wait(&db.pool, &name).await; + inserter.commit().await.expect("commit concurrent run"); + + let outcome = deletion + .await + .expect("join deletion") + .expect("delete after concurrent run"); + assert!(outcome.changed); + assert_eq!( + count_where(&db, "workflow_runs", "workflow_id", community, id).await, + 0 + ); +} + +/// An approval naming another workflow but one of this workflow's runs only +/// key-share locks that run, so the workflow lock does not exclude it. The +/// delete must lock the runs, wait for the insert, and then remove it; with no +/// cascade, committing it between the approval and run deletes would fail the +/// run delete's foreign-key check. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_deletion_waits_for_and_removes_concurrent_cross_workflow_approval() { + let (db, community) = setup().await; + crate::test_support::retire_foreign_key_delete_actions(&db.pool).await; + let keys = Keys::generate(); + let owner = keys.public_key().to_bytes(); + let id = Uuid::new_v4(); + let d_tag = id.to_string(); + let now = Timestamp::now().as_secs(); + seed(&db, community, &keys, id, &d_tag, now).await; + let other = Uuid::new_v4(); + seed(&db, community, &keys, other, &other.to_string(), now).await; + let run_id = create_workflow_run(&db.pool, community, id, None, None) + .await + .expect("run"); + + let mut inserter = db.pool.begin().await.expect("begin approval insert"); + sqlx::query( + "INSERT INTO workflow_approvals (community_id, token, workflow_id, run_id, step_id, \ + step_index, approver_spec, status, expires_at) \ + VALUES ($1, $2, $3, $4, 'gate', 0, '@anyone', 'pending', NOW() + INTERVAL '1 hour')", + ) + .bind(community.as_uuid()) + .bind(Uuid::new_v4().as_bytes().to_vec()) + .bind(other) + .bind(run_id) + .execute(&mut *inserter) + .await + .expect("insert concurrent cross-workflow approval"); + + let name = format!("workflow-delete-{}", Uuid::new_v4().simple()); + let deleter = Db::from_pool(crate::test_support::named_pool(&name).await); + let deletion = tokio::spawn(async move { + deleter + .delete_workflow_by_coordinate(community, &owner, &d_tag, now as i64) + .await + }); + crate::test_support::wait_for_lock_wait(&db.pool, &name).await; + inserter.commit().await.expect("commit concurrent approval"); + + let outcome = deletion + .await + .expect("join deletion") + .expect("delete after concurrent approval"); + assert!(outcome.changed); + for (table, column, key) in [ + ("workflow_approvals", "run_id", run_id), + ("workflow_runs", "workflow_id", id), + ] { + assert_eq!( + count_where(&db, table, column, community, key).await, + 0, + "{table}" + ); + } + assert!(get_workflow(&db.pool, community, other).await.is_ok()); +} + +/// Attaching a run to a scheduled fire locks the fire row, then key-share +/// locks the run for its foreign key. The delete must take those two in the +/// same order (fire before run), so an attach in flight when the delete +/// starts completes and is then removed, rather than deadlocking with it. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_deletion_waits_for_concurrent_fire_attach_without_deadlock() { + let (db, community) = setup().await; + crate::test_support::retire_foreign_key_delete_actions(&db.pool).await; + let keys = Keys::generate(); + let owner = keys.public_key().to_bytes(); + let id = Uuid::new_v4(); + let d_tag = id.to_string(); + let now = Timestamp::now().as_secs(); + seed(&db, community, &keys, id, &d_tag, now).await; + let run_id = create_workflow_run(&db.pool, community, id, None, None) + .await + .expect("run"); + let scheduled_for: chrono::DateTime = sqlx::query_scalar( + "INSERT INTO scheduled_workflow_fires (community_id, workflow_id, scheduled_for) \ + VALUES ($1, $2, date_trunc('second', NOW())) RETURNING scheduled_for", + ) + .bind(community.as_uuid()) + .bind(id) + .fetch_one(&db.pool) + .await + .expect("unlinked scheduled claim"); + + // Hold the fire row as an attach does before its foreign-key check runs. + let mut attacher = db.pool.begin().await.expect("begin fire attach"); + sqlx::query( + "SELECT 1 FROM scheduled_workflow_fires \ + WHERE community_id = $1 AND workflow_id = $2 AND scheduled_for = $3 FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(id) + .bind(scheduled_for) + .execute(&mut *attacher) + .await + .expect("lock fire row"); + + let name = format!("workflow-delete-{}", Uuid::new_v4().simple()); + let deleter = Db::from_pool(crate::test_support::named_pool(&name).await); + let deletion = tokio::spawn(async move { + deleter + .delete_workflow_by_coordinate(community, &owner, &d_tag, now as i64) + .await + }); + crate::test_support::wait_for_lock_wait(&db.pool, &name).await; + let attached = sqlx::query( + "UPDATE scheduled_workflow_fires SET workflow_run_id = $4 \ + WHERE community_id = $1 AND workflow_id = $2 AND scheduled_for = $3", + ) + .bind(community.as_uuid()) + .bind(id) + .bind(scheduled_for) + .bind(run_id) + .execute(&mut *attacher) + .await + .expect("attach run to fire without deadlock"); + assert_eq!(attached.rows_affected(), 1); + attacher.commit().await.expect("commit fire attach"); + + let outcome = deletion + .await + .expect("join deletion") + .expect("delete after concurrent fire attach"); + assert!(outcome.changed); + for (table, column, key) in [ + ("scheduled_workflow_fires", "workflow_id", id), + ("workflow_runs", "workflow_id", id), + ] { + assert_eq!( + count_where(&db, table, column, community, key).await, + 0, + "{table}" + ); + } +} diff --git a/crates/buzz-db/src/test_support.rs b/crates/buzz-db/src/test_support.rs index 73fa14c3663..e94b8b14491 100644 --- a/crates/buzz-db/src/test_support.rs +++ b/crates/buzz-db/src/test_support.rs @@ -82,3 +82,93 @@ pub(crate) fn desired_state_schema_sql() -> String { pub(crate) fn desired_state_schema_dir() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schema") } + +/// Rewrite every `ON DELETE CASCADE`, `SET NULL` and `SET DEFAULT` foreign key +/// in this test's database as `NO ACTION`: the schema the cascade retirement +/// targets. Every trigger stays live, including the foreign-key checks and the +/// community fence, so a delete path exercised afterwards must remove its +/// children itself, in an order the remaining constraints accept. +/// +/// Each PostgreSQL test process owns its database (`crates/buzz-db/TESTING.md`), +/// so the rewrite does not reach other tests. It refuses any database whose +/// name does not match the per-test wrapper's (`buzz_nt_` plus 24 hex digits), +/// which excludes the run's `_desired` template. The name is the only check: +/// a manually created database with that shape would pass. +pub(crate) async fn retire_foreign_key_delete_actions(pool: &sqlx::PgPool) { + let database: String = sqlx::query_scalar("SELECT current_database()") + .fetch_one(pool) + .await + .expect("read current database"); + assert!( + database + .strip_prefix("buzz_nt_") + .is_some_and(|hash| hash.len() == 24 + && hash + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))), + "refusing to rewrite foreign keys in shared database {database}; \ + run this test through scripts/postgres-test-run.sh" + ); + sqlx::raw_sql( + r#"DO $$ + DECLARE c record; + BEGIN + FOR c IN SELECT conrelid::regclass AS tbl, conname, pg_get_constraintdef(oid) AS def + FROM pg_constraint + WHERE contype = 'f' AND confdeltype IN ('c', 'n', 'd') AND conparentid = 0 + LOOP + EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I, ADD CONSTRAINT %I %s', + c.tbl, c.conname, c.conname, + regexp_replace(c.def, ' ON DELETE (CASCADE|SET NULL|SET DEFAULT)( \([^)]*\))?', '')); + END LOOP; + END $$"#, + ) + .execute(pool) + .await + .expect("retire foreign-key delete actions"); + let remaining: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_constraint WHERE contype = 'f' AND confdeltype IN ('c', 'n', 'd')", + ) + .fetch_one(pool) + .await + .expect("count foreign-key delete actions"); + assert_eq!(remaining, 0, "every foreign-key delete action is retired"); +} + +/// A single-connection pool whose session carries `application_name`, so +/// [`wait_for_lock_wait`] can tell when its statement is blocked. +pub(crate) async fn named_pool(application_name: &str) -> sqlx::PgPool { + use std::str::FromStr as _; + let options = sqlx::postgres::PgConnectOptions::from_str(&database_url()) + .expect("parse test database URL") + .application_name(application_name); + sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("connect named test pool") +} + +/// Wait until the session named `application_name` in this test's database is +/// blocked on a lock. +pub(crate) async fn wait_for_lock_wait(pool: &sqlx::PgPool, application_name: &str) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_stat_activity \ + WHERE datname = current_database() AND application_name = $1 \ + AND wait_event_type = 'Lock')", + ) + .bind(application_name) + .fetch_one(pool) + .await + .expect("inspect lock wait"); + if waiting { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("session reached a lock wait"); +} diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index bd39ada46e2..b762af391d1 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -8403,7 +8403,9 @@ pub(crate) mod postgres_tests { "CREATE SCHEMA {schema}; \ CREATE TABLE {schema}.users (LIKE public.users INCLUDING ALL); \ CREATE TABLE {schema}.community_bans (LIKE public.community_bans INCLUDING ALL); \ - CREATE TABLE {schema}.relay_members (LIKE public.relay_members INCLUDING ALL);" + CREATE TABLE {schema}.relay_members (LIKE public.relay_members INCLUDING ALL); \ + CREATE TABLE {schema}.join_policy_acceptances \ + (LIKE public.join_policy_acceptances INCLUDING ALL);" ))) .execute(state.db.pool()) .await From 8bb49a1b6a5ffb5b89eb2e860673ee7bff2fd29b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 14 Oct 2026 09:22:42 -0400 Subject: [PATCH 002/144] feat(relay): publish channel type and creator on every channel-state event (#8174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary Clients cannot tell who created a channel, and only the channel-metadata event says what type of channel it is. This PR makes every relay-signed channel-state event carry both facts, so a client can find "the channel that this key created" with one ordinary Nostr filter. - Channel metadata, admin list and member list events (kinds 39000, 39001 and 39002) now all carry the channel type as their first `t` tag, then the creator as a `P` tag. - The creator comes from the relay's own record of who signed the channel's create request. No client can set it, and it does not change when ownership moves. - Channel metadata (kind:39000) also carries `["created_at", ]`, the time the channel was created. The event's own `created_at` changes on every edit, so before this a client could not tell which of two channels is older. This is part 1 of a 3-part stack (channel labels and creator lookup). The system channels stack (#8180–#8182) builds on it. ### Related issue None found. ### Details - **Why `P`.** Several Nostr specs (NIP-22, NIP-34, NIP-72) use uppercase `P` for the author of the root object, and lowercase `p` for people involved. Lowercase `p` is already used on these events for members and DM participants, and a filter matches tag values, not their meaning, so `#p` would mix them. - **Trust.** A client that trusts a channel because of its creator must also check that the relay key signed the event. - **Order.** The type `t` tag comes first, so clients that read the first `t` tag still get the type after part 2 adds labels as more `t` tags. - **Creation time.** The relay reads it from `channels.created_at`, so an edit does not change it. #8176 uses it to pick one channel when two creates race. - `buzz-admin reconcile` writes the same tags when it rebuilds these events. `NOSTR.md` documents the tags. - **Spec.** New `docs/nips/NIP-CL.md` defines these tags, the trust rule and lookup by creator. Later PRs in both stacks add their parts to it. `NOSTR.md` links to it. ### Testing - Relay tests in `group_state_postgres_tests.rs`: each of the three kinds carries the type first, then the creator; the creator stays the same after an ownership transfer; kind:39000 carries the stored creation time, and still does after an edit. - Unit test for the shared tag builder that both the relay and `buzz-admin reconcile` use. ### Stack These are two stacks. Merge them in this order. Client support (creating, labeling and finding these channels) will be in block/buzz-app, not the buzz SDK and CLI. **Stack 1: channel labels and creator lookup (#8174–#8176)** 1. #8174 channel type and creator tags, and the channel creation time ← this PR 2. #8175 channel labels 3. #8176 database-side label and creator match, and the rule for choosing one channel **Stack 2: system channels (#8180–#8182)**, stacked on stack 1 because it uses labels and the creator tag 4. #8180 the system channel type 5. #8181 leave system channels out of reads that do not name them 6. #8182 channel-state filters opt in to system channels --------- Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- NOSTR.md | 25 +- crates/buzz-admin/src/main.rs | 15 +- crates/buzz-core/src/channel.rs | 52 +++- crates/buzz-db/src/store/channel.rs | 12 +- crates/buzz-db/src/store/channel_members.rs | 26 ++ .../handlers/group_state_postgres_tests.rs | 240 ++++++++++++++++++ crates/buzz-relay/src/handlers/mod.rs | 2 + .../buzz-relay/src/handlers/side_effects.rs | 54 +++- docs/nips/NIP-CL.md | 101 ++++++++ 9 files changed, 511 insertions(+), 16 deletions(-) create mode 100644 crates/buzz-relay/src/handlers/group_state_postgres_tests.rs create mode 100644 docs/nips/NIP-CL.md diff --git a/NOSTR.md b/NOSTR.md index cb1f20e84fb..a3a021e490a 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -57,9 +57,9 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **Admin delete event (kind:9005)** | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. | | **Group deletion (kind:9008)** | ✅ | Owner only. | | **Leave group (kind:9022)** | ✅ | Any member. Last-owner guard prevents orphaned groups. | -| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `closed` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels | -| **Group admins (kind:39001)** | ✅ | Relay-signed; `d` tag + `p` tags with roles (`owner`, `admin`) | -| **Group members (kind:39002)** | ✅ | Relay-signed; `d` tag + `p` tags for all members | +| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `closed`, `["t", ]`, `["P", ]` and `["created_at", ]` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels | +| **Group admins (kind:39001)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags with roles (`owner`, `admin`) | +| **Group members (kind:39002)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags for all members | | **Membership notifications** | ✅ | kind:44100 (added) / kind:44101 (removed); relay-signed, community-global scope (`channel_id=None` inside the connected community) | | **Presence (kind:20001)** | ✅ | Ephemeral; arbitrary status string (truncated to 128 chars); writes to Redis (`set_presence`/`clear_presence` on `"offline"`), then fan-out to local subscribers. In multi-community mode presence is scoped to the connected community. | | **Agent presence (kind:20003)** | ✅ | Ephemeral; an owner's presence for the agents it owns, named by `p` tags (at most 256); `online`/`away` set and `offline` clears each agent's presence as its own kind:20001 would. Not fanned out. | @@ -103,13 +103,26 @@ relay to specific external Nostr identities without granting full access. ### Group Discovery The relay emits NIP-29 group state events when channels are created, updated, or membership changes. +[NIP-CL](docs/nips/NIP-CL.md) defines the channel identity tags on these events and how clients +use them to find channels. All discovery events include a `d` tag set to the channel UUID (NIP-29 addressable event convention): | Kind | Tags | Content | |------|------|---------| -| **39000** | `d=`, `name`, `closed` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels only) | Group metadata. **Note:** `closed` is always emitted per NIP-29 convention (Buzz channels require explicit membership), but open channels are still readable/writable by non-members at runtime. The tag reflects the membership model, not access enforcement. | -| **39001** | `d=`, `p` tags with role label (`owner`, `admin`) | Admin list | -| **39002** | `d=`, `p` tags for all members | Member list | +| **39000** | `d=`, `name`, `closed`, `t=`, `P=`, `created_at=` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels only) | Group metadata. **Note:** `closed` is always emitted per NIP-29 convention (Buzz channels require explicit membership), but open channels are still readable/writable by non-members at runtime. The tag reflects the membership model, not access enforcement. | +| **39001** | `d=`, `t=`, `P=`, `p` tags with role label (`owner`, `admin`) | Admin list | +| **39002** | `d=`, `t=`, `P=`, `p` tags for all members | Member list | + +On all three kinds the channel type `t` tag comes first, then `P`. `P` is +the public key that signed the channel's kind:9007. The relay writes it +from the database, and it does not change when ownership moves. Uppercase +`P` follows NIP-22, NIP-34 and NIP-72, where it names the author of the root +object; lowercase `p` already lists members and DM participants on these +kinds. + +kind:39000 also carries `["created_at", ]`: the time the +channel was created. The event's own `created_at` is the time of the last +edit, so use the tag to find the older of two channels. Events are stored **channel-scoped** so access control applies — private channel member lists are only visible to members. Discover groups via historical REQ: diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 33fc74b6d1d..9c4177b701b 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -739,6 +739,7 @@ async fn reconcile_channels( channel_arg: Option, relay_key_arg: Option, ) -> Result<()> { + use buzz_core::channel::{channel_created_at_tag, group_state_identity_tags}; use buzz_core::kind::KIND_NIP29_GROUP_ADMINS; use buzz_db::event::EventQuery; @@ -839,7 +840,11 @@ async fn reconcile_channels( tags.push(Tag::parse(["hidden"])?); } tags.push(Tag::parse(["closed"])?); - tags.push(Tag::parse(["t", &channel.channel_type])?); + tags.extend(group_state_identity_tags( + &channel.channel_type, + &channel.created_by, + )?); + tags.push(channel_created_at_tag(channel.created_at.timestamp())?); let event = EventBuilder::new(Kind::Custom(39000), "") .tags(tags) @@ -852,6 +857,10 @@ async fn reconcile_channels( // kind:39001 — admins { let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; + tags.extend(group_state_identity_tags( + &channel.channel_type, + &channel.created_by, + )?); for m in members .iter() .filter(|m| m.role == "owner" || m.role == "admin") @@ -871,6 +880,10 @@ async fn reconcile_channels( // kind:39002 — members { let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; + tags.extend(group_state_identity_tags( + &channel.channel_type, + &channel.created_by, + )?); for m in &members { let pk = hex::encode(&m.pubkey); tags.push(Tag::parse(["p", &pk, "", &m.role])?); diff --git a/crates/buzz-core/src/channel.rs b/crates/buzz-core/src/channel.rs index 5c20afefd41..41ba48670cb 100644 --- a/crates/buzz-core/src/channel.rs +++ b/crates/buzz-core/src/channel.rs @@ -17,6 +17,39 @@ pub fn canonical_channel_name(name: &str) -> &str { .trim_end() } +/// Tags that identify a channel on every NIP-29 group-state event the relay +/// signs (kinds 39000–39002), in this order: +/// +/// 1. `["t", ]`. It comes first, so a client that reads the +/// first `t` tag gets the channel type. +/// 2. `["P", ]`: the key that signed the channel's kind:9007. +/// It comes from the database and does not change when ownership moves. +/// Uppercase `P` names the author of the root object, as in NIP-22, +/// NIP-34 and NIP-72; lowercase `p` already means members and DM +/// participants on these kinds. +/// +/// Filters such as `{kinds:[39000], #P:[]}` select by these tags. +pub fn group_state_identity_tags( + channel_type: &str, + created_by: &[u8], +) -> Result, nostr::event::tag::Error> { + Ok(vec![ + nostr::Tag::parse(["t", channel_type])?, + nostr::Tag::parse(["P", &hex::encode(created_by)])?, + ]) +} + +/// Tag on kind:39000 that holds the channel's creation time in Unix seconds. +/// +/// The event's own `created_at` is the time of the last edit, so it cannot +/// rank channels by age. +pub const CHANNEL_CREATED_AT_TAG: &str = "created_at"; + +/// The [`CHANNEL_CREATED_AT_TAG`] tag for a channel created at `unix_seconds`. +pub fn channel_created_at_tag(unix_seconds: i64) -> Result { + nostr::Tag::parse([CHANNEL_CREATED_AT_TAG, &unix_seconds.to_string()]) +} + /// Whether a channel is publicly visible or invite-only. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChannelVisibility { @@ -180,7 +213,24 @@ impl FromStr for MemberRole { #[cfg(test)] mod tests { - use super::canonical_channel_name; + use super::{canonical_channel_name, group_state_identity_tags}; + + #[test] + fn group_state_identity_tags_put_the_type_first_then_the_creator() { + let creator = [0xab; 32]; + let tags: Vec> = group_state_identity_tags("forum", &creator) + .expect("valid tags") + .into_iter() + .map(|tag| tag.to_vec()) + .collect(); + assert_eq!( + tags, + vec![ + vec!["t".to_string(), "forum".to_string()], + vec!["P".to_string(), "ab".repeat(32)], + ] + ); + } #[test] fn channel_names_trim_whitespace_and_drop_all_leading_hashes() { diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index 8d43473d501..46375595cf3 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -26,7 +26,7 @@ pub use crate::channel_members::{ get_users_bulk, is_member, list_large_channel_rosters_needing_reconciliation, lock_member_snapshot, membership_pairs, remove_member, verify_channel_roster_fence_behavior, verify_channel_roster_fence_catalog, AccessibleChannel, BotChannelEntry, BotMemberRecord, - LargeChannelRoster, LockedMemberSnapshot, MemberRecord, UserRecord, + ChannelIdentity, LargeChannelRoster, LockedMemberSnapshot, MemberRecord, UserRecord, }; async fn begin_event_write_transaction( @@ -99,6 +99,16 @@ pub struct ChannelRecord { pub ttl_deadline: Option>, } +impl ChannelRecord { + /// The facts every group-state event publishes about this channel. + pub fn identity(&self) -> ChannelIdentity { + ChannelIdentity { + channel_type: self.channel_type.clone(), + created_by: self.created_by.clone(), + } + } +} + /// Creates a new channel, bootstraps the creator as owner, and returns the record. #[allow(clippy::too_many_arguments)] pub async fn create_channel( diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 5cf3ed40dcf..7ff51467752 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -301,11 +301,25 @@ pub async fn insert_auto_membership_in_transaction( // ── End transaction-level helpers ───────────────────────────────────────────── +/// The immutable facts that every relay-signed group-state event +/// (kinds 39000–39002) publishes about its channel. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelIdentity { + /// Channel type string (e.g. `"stream"`, `"forum"`, `"dm"`). + pub channel_type: String, + /// Public key bytes of the channel creator (the kind:9007 signer). + pub created_by: Vec, +} + /// An active member roster captured while holding the channel's membership /// serialization lock on one writer connection. pub struct LockedMemberSnapshot { /// Canonical active members captured behind the lock. pub members: Vec, + /// The channel's type and creator, read on the same connection, so the + /// roster carries the same identity tags as the channel's other + /// group-state events. `None` when no channel row exists. + pub identity: Option, channel_id: Uuid, relay_pubkey: Vec, tx: AdmittedTx, @@ -477,8 +491,20 @@ pub async fn lock_member_snapshot( .into_iter() .map(row_to_member_record) .collect::>>()?; + let identity = sqlx::query_as::<_, (String, Vec)>( + "SELECT channel_type::text, created_by FROM channels WHERE community_id = $1 AND id = $2", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_optional(tx.conn()) + .await? + .map(|(channel_type, created_by)| ChannelIdentity { + channel_type, + created_by, + }); Ok(LockedMemberSnapshot { members, + identity, channel_id, relay_pubkey: relay_pubkey.to_vec(), tx, diff --git a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs new file mode 100644 index 00000000000..2df126d0974 --- /dev/null +++ b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs @@ -0,0 +1,240 @@ +//! Postgres-backed tests for the relay-signed NIP-29 group-state events +//! (kinds 39000–39002) that channel creation and edits publish. + +use std::sync::Arc; + +use buzz_core::TenantContext; +use buzz_db::event::EventQuery; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use uuid::Uuid; + +use crate::handlers::ingest::{ingest_event, HttpAuthMethod, IngestAuth}; +use crate::state::AppState; +use buzz_auth::Scope; + +pub(crate) async fn state() -> Arc { + crate::state::tests::test_state_with_database_url(&crate::test_support::database_url()).await +} + +pub(crate) async fn community(state: &AppState, label: &str) -> TenantContext { + let host = format!("{label}-{}.test", Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + TenantContext::resolved(community, &host) +} + +pub(crate) fn http_auth(keys: &Keys) -> IngestAuth { + IngestAuth::Http { + pubkey: keys.public_key(), + scopes: vec![ + Scope::ChannelsWrite, + Scope::MessagesWrite, + Scope::AdminChannels, + Scope::ChannelsRead, + ], + auth_method: HttpAuthMethod::Nip98, + } +} + +/// Ingest a kind:9007 that `keys` signs, with `extra` tags after the ID and +/// name, and return the new channel's ID. +pub(crate) async fn create_channel( + state: &Arc, + tenant: &TenantContext, + keys: &Keys, + extra: &[&[&str]], +) -> Uuid { + let channel_id = Uuid::new_v4(); + let mut tags = vec![ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["name", &format!("group-state-{}", channel_id.simple())]).unwrap(), + ]; + for tag in extra { + tags.push(Tag::parse(tag.iter().copied()).unwrap()); + } + let event = EventBuilder::new(Kind::Custom(9007), "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign 9007"); + ingest_event(state, tenant, event, http_auth(keys)) + .await + .unwrap_or_else(|error| panic!("create channel: {error:?}")); + channel_id +} + +/// The current relay-signed group-state event of `kind` for `channel_id`. +pub(crate) async fn group_state_tags( + state: &AppState, + tenant: &TenantContext, + channel_id: Uuid, + kind: i32, +) -> Vec> { + let events = state + .db + .query_events(&EventQuery { + kinds: Some(vec![kind]), + d_tag: Some(channel_id.to_string()), + limit: Some(1), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("query group state"); + let event = events + .first() + .unwrap_or_else(|| panic!("no kind:{kind} for {channel_id}")); + event + .event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect() +} + +/// kind:39000 carries the channel's creation time, and an edit keeps it. +/// Mutation: use the edit time instead of `channels.created_at` → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn metadata_carries_the_channel_creation_time() { + let state = state().await; + let tenant = community(&state, "group-state-created-at").await; + let creator = Keys::generate(); + let channel_id = create_channel(&state, &tenant, &creator, &[]).await; + + let created_at = |tags: &[Vec]| -> Vec { + tags.iter() + .filter(|tag| tag[0] == "created_at") + .map(|tag| tag[1].clone()) + .collect() + }; + let stored = state + .db + .get_channel_for_event_write(tenant.community(), channel_id) + .await + .expect("channel") + .created_at + .timestamp(); + let tags = group_state_tags(&state, &tenant, channel_id, 39000).await; + assert_eq!(created_at(&tags), vec![stored.to_string()], "{tags:?}"); + + // Move the creation time far into the past, then edit. The new 39000 + // must still report the creation time, not the time of the edit. + sqlx::query("UPDATE channels SET created_at = to_timestamp(1577836800) WHERE id = $1") + .bind(channel_id) + .execute(state.db.pool()) + .await + .expect("backdate channel"); + let edit = EventBuilder::new(Kind::Custom(9002), "") + .tags([ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["name", &format!("renamed-{}", channel_id.simple())]).unwrap(), + ]) + .sign_with_keys(&creator) + .unwrap(); + ingest_event(&state, &tenant, edit, http_auth(&creator)) + .await + .expect("edit channel"); + let tags = group_state_tags(&state, &tenant, channel_id, 39000).await; + assert!( + tags.iter() + .any(|tag| tag[0] == "name" && tag[1].starts_with("renamed-")), + "edit published: {tags:?}" + ); + assert_eq!( + created_at(&tags), + vec!["1577836800".to_string()], + "{tags:?}" + ); +} + +/// Every group-state kind carries the channel type and the creator, so +/// `#t` and `#P` filters match 39001 and 39002 as well as 39000. +/// Mutation: drop the identity tags from 39001 or 39002 → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn group_state_events_carry_type_then_creator() { + let state = state().await; + let tenant = community(&state, "group-state-identity").await; + let creator = Keys::generate(); + let channel_id = create_channel( + &state, + &tenant, + &creator, + &[&["channel_type", "forum"], &["visibility", "private"]], + ) + .await; + + let creator_hex = creator.public_key().to_hex(); + for kind in [39000, 39001, 39002] { + let tags = group_state_tags(&state, &tenant, channel_id, kind).await; + let t_tags: Vec<&Vec> = tags.iter().filter(|tag| tag[0] == "t").collect(); + assert_eq!( + t_tags.first().map(|tag| tag[1].as_str()), + Some("forum"), + "kind:{kind} must lead with the channel type: {tags:?}" + ); + let creators: Vec<&str> = tags + .iter() + .filter(|tag| tag[0] == "P") + .map(|tag| tag[1].as_str()) + .collect(); + assert_eq!( + creators, + vec![creator_hex.as_str()], + "kind:{kind}: {tags:?}" + ); + } +} + +/// The creator tag names the kind:9007 signer, not the current owner. +/// Mutation: derive `P` from the owner role → RED after the transfer. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn creator_tag_survives_ownership_transfer() { + let state = state().await; + let tenant = community(&state, "group-state-creator").await; + let creator = Keys::generate(); + let successor = Keys::generate(); + let channel_id = create_channel(&state, &tenant, &creator, &[]).await; + + // Add the successor as owner, then the creator leaves. + let channel_hex = channel_id.to_string(); + let add = EventBuilder::new(Kind::Custom(9000), "") + .tags([ + Tag::parse(["h", &channel_hex]).unwrap(), + Tag::parse(["p", &successor.public_key().to_hex()]).unwrap(), + Tag::parse(["role", "owner"]).unwrap(), + ]) + .sign_with_keys(&creator) + .unwrap(); + ingest_event(&state, &tenant, add, http_auth(&creator)) + .await + .expect("add successor"); + let leave = EventBuilder::new(Kind::Custom(9022), "") + .tags([Tag::parse(["h", &channel_hex]).unwrap()]) + .sign_with_keys(&creator) + .unwrap(); + ingest_event(&state, &tenant, leave, http_auth(&creator)) + .await + .expect("creator leaves"); + + let roster = group_state_tags(&state, &tenant, channel_id, 39002).await; + assert!( + !roster + .iter() + .any(|tag| tag[0] == "p" && tag[1] == creator.public_key().to_hex()), + "creator left the roster: {roster:?}" + ); + let creator_hex = creator.public_key().to_hex(); + for kind in [39001, 39002] { + let tags = group_state_tags(&state, &tenant, channel_id, kind).await; + assert!( + tags.iter() + .any(|tag| tag[0] == "P" && tag[1] == creator_hex), + "kind:{kind} keeps the creator: {tags:?}" + ); + } +} diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index e4eea46906c..8d5175aa458 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -14,6 +14,8 @@ pub mod community_provisioning; pub mod count; /// EVENT handler — WS dispatcher → ingest pipeline → fan-out. pub mod event; +#[cfg(test)] +pub(crate) mod group_state_postgres_tests; /// NIP-IA identity archive request handler (kinds 9035–9036). pub mod identity_archive; /// imeta tag validation helpers. diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 8cc7d3abc6b..d821a465164 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1165,9 +1165,27 @@ async fn emit_addressable_discovery_event( Ok(()) } -fn group_members_tags(group_id: &str, members: &[MemberRecord]) -> anyhow::Result> { - let mut tags: Vec = Vec::with_capacity(members.len() + 1); +/// Tags that identify the channel on every relay-signed group-state event: +/// the channel type `t` first, then the creator `P`. +fn group_state_identity_tags( + identity: &buzz_db::channel::ChannelIdentity, +) -> anyhow::Result> { + Ok(buzz_core::channel::group_state_identity_tags( + &identity.channel_type, + &identity.created_by, + )?) +} + +fn group_members_tags( + group_id: &str, + identity: Option<&buzz_db::channel::ChannelIdentity>, + members: &[MemberRecord], +) -> anyhow::Result> { + let mut tags: Vec = Vec::with_capacity(members.len() + 3); tags.push(Tag::parse(["d", group_id])?); + if let Some(identity) = identity { + tags.extend(group_state_identity_tags(identity)?); + } for member in members { let pubkey_hex = hex::encode(&member.pubkey); // NIP-29 convention: ["p", pubkey, relay_url, role]. Empty relay_url @@ -1182,7 +1200,11 @@ async fn store_group_members_event( member_snapshot: &mut buzz_db::channel::LockedMemberSnapshot, ) -> anyhow::Result> { let group_id = member_snapshot.channel_id().to_string(); - let tags = group_members_tags(&group_id, &member_snapshot.members)?; + let tags = group_members_tags( + &group_id, + member_snapshot.identity.as_ref(), + &member_snapshot.members, + )?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -1282,8 +1304,14 @@ pub async fn emit_group_discovery_events( } // Buzz channels always require explicit membership tags.push(Tag::parse(["closed"])?); - // Channel type tag so clients can distinguish stream/forum/dm without inference - tags.push(Tag::parse(["t", &channel.channel_type])?); + // Channel type tag so clients can distinguish stream/forum/dm without + // inference, then the creator. The same tags lead 39001 and 39002. + tags.extend(group_state_identity_tags(&channel.identity())?); + // Creation time, so readers can pick the oldest of two channels. The + // event's own created_at changes on every edit. + tags.push(buzz_core::channel::channel_created_at_tag( + channel.created_at.timestamp(), + )?); // Optional topic / purpose for richer client UX if let Some(ref topic) = channel.topic { if !topic.is_empty() { @@ -1319,6 +1347,7 @@ pub async fn emit_group_discovery_events( { let mut tags: Vec = vec![Tag::parse(["d", &group_id])?]; + tags.extend(group_state_identity_tags(&channel.identity())?); for m in members .iter() .filter(|m| m.role == "owner" || m.role == "admin") @@ -3991,8 +4020,19 @@ mod tests { }) .collect(); - let tags = group_members_tags(&channel_id.to_string(), &members).expect("build tags"); - assert_eq!(tags.len(), 1_502, "d tag plus every member p tag"); + let identity = buzz_db::channel::ChannelIdentity { + channel_type: "forum".to_string(), + created_by: vec![0xab; 32], + }; + let tags = group_members_tags(&channel_id.to_string(), Some(&identity), &members) + .expect("build tags"); + assert_eq!( + tags.len(), + 1_504, + "d, type and creator tags plus every member p tag" + ); + assert_eq!(tags[1].as_slice(), ["t", "forum"]); + assert_eq!(tags[2].as_slice(), ["P".to_string(), "ab".repeat(32)]); let late_pubkey = hex::encode(&members[1_500].pubkey); assert!(tags.iter().any(|tag| { diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md new file mode 100644 index 00000000000..f533d0b4e3f --- /dev/null +++ b/docs/nips/NIP-CL.md @@ -0,0 +1,101 @@ +NIP-CL +====== + +Channel Identity and Discovery +------------------------------ + +`draft` `optional` `relay` + +This NIP adds identity facts to the relay-signed NIP-29 channel-state events +(kinds 39000–39002). With these facts, a client can find channels with +ordinary NIP-01 filters. It needs no new filter key and no relay-specific API. + +## Motivation + +Plugins and agents often create a channel for their own use and must find it +again later, for example "the config channel that this agent created". With +plain NIP-29, a client cannot: + +- tell who created a channel; +- read the channel type from any event except kind:39000; or +- tell which of two channels is older. The `created_at` of a kind:39000 event + is the time of the last edit, not the time of creation. + +## Channel identity tags + +On every channel-state event that the relay signs, the relay MUST put these +tags in this order, before any other `t` or `P` tag: + +1. `["t", ]`, for example `stream`, `forum`, `dm`, `workflow`. +2. `["P", ]`. + +kind:39000 also carries `["created_at", ]`. + +Example kind:39000 (signing fields and unrelated tags omitted): + +```json +{ + "kind": 39000, + "pubkey": "", + "created_at": 1760025600, + "tags": [ + ["d", "9b353519-f4fe-4757-aef4-bec6cc0ae54c"], + ["name", "agent-config"], + ["t", "stream"], + ["P", ""], + ["created_at", "1759939200"] + ] +} +``` + +| Tag | Kinds | Meaning | +| --- | --- | --- | +| first `t` | 39000–39002 | Channel type. | +| `P` | 39000–39002 | Public key that signed the kind:9007 that created the channel. | +| `created_at` | 39000 | Time the channel was created, in unix seconds, as a decimal string. | + +**Channel type.** Clients MUST read the channel type from the first `t` tag. +A relay can add other `t` tags after it. + +**Creator.** The relay writes `P` from its own record of the kind:9007 that +created the channel. No client can set it. It does not change when ownership +moves to another member or when the channel is edited. + +Uppercase `P` follows NIP-22, NIP-34 and NIP-72, where `P` names the author of +the root object. On these kinds, lowercase `p` already lists members, admins +and DM participants. A filter matches tag values, not their roles, so `#p` +would mix creators with members. + +**Creation time.** The `created_at` tag does not change when the channel is +edited. Use it, not the event's `created_at`, to compare the age of channels. + +**Older events.** A relay adds these tags only when it publishes a +channel-state event. An event that a relay version without this NIP published +can lack them, including one published during an upgrade. Such an event stays +until the channel next changes, for example on an edit or a membership +change. A client MUST treat a missing tag as unknown. A missing `P` does not +mean that a key did not create the channel. + +## Finding channels + +Use ordinary NIP-01 filters on the channel-state kinds. + +| To find | Filter | +| --- | --- | +| Channels that a key created | `{"kinds": [39000], "#P": [""]}` | +| Channels of one type | `{"kinds": [39000], "#t": [""]}` | + +A `#P` filter does not return a channel whose events lack `P` (see +**Older events**). So it can miss channels that a key created before the relay +supported this NIP. + +## Trust + +A client that relies on these tags MUST check that the relay's key signed the +event, as for all NIP-29 group-state events. + +## Relation to other NIPs + +- NIP-29: the group-state events that this NIP adds tags to. +- NIP-01: the filters that find channels. +- NIP-22, NIP-34 and NIP-72: the convention for uppercase `P`. From d25384a4f3c3a940b5b999f26105091c58ab76d6 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Wed, 14 Oct 2026 10:58:12 -0400 Subject: [PATCH 003/144] buzz-db: admit every fenced-table writer in app code (#8219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This makes every serving writer of a community-write-fenced table take community admission in application code. That's the "Dual enforcement" step of the Supported Writer Contract in `ARCHITECTURE.md`. Admission means the shared community deletion lock plus the active/not-deleted check from `deletion::guard_community_write`. It's taken before the transaction's first tenant write or row lock and held until commit. The 32 `community_write_fence_*` triggers, the six fence SQL functions and `validate_serving_catalog` are unchanged. They stay authoritative. Removing them is a separate migration that will be checked table by table. ## Changes - **New admission entry point.** `begin_community_write_transaction` (`runtime/mod.rs`) builds an `AdmittedTx` for non-event writers. It sits next to the existing event-write entry points and shares their implementation. - **Writers moved onto it:** - `api_tokens`, `archived_identities`, `channels`, `channel_members` (including DM and admin paths), `community_bans`, `git_repo_names` - `moderation_actions` and `moderation_reports`, `personal_read_accounts` and `personal_read_frontiers`, `pubkey_allowlist` - `push_leases`, `push_match_queue`, `push_wake_outbox` - `relay_invites`, `relay_members` (including the new-community owner bootstrap and the startup `pubkey_allowlist` backfill), `join_policy_acceptances` - `thread_metadata`, `users`, `workflows`, `workflow_runs`, `workflow_approvals`, `scheduled_workflow_fires` - Personal-read helpers now require `&mut AdmittedTx`. - **`buzz-audit`.** An append now passes admission first, then takes a transaction-scoped audit-chain lock (`pg_advisory_xact_lock`). This replaces the session lock and the `catch_unwind` unlock cleanup. The relay audit worker retries a `lock_timeout` on either lock and keeps the entry; an admission refusal is terminal, as the trigger's refusal was. - **Fleet-wide statements.** These keep `community_write_allowed(...)` in the same SQL statement, because there's no single community to admit up front: - scheduled-fire prune - invite expiry - push matcher claim and exhausted-job cleanup - startup `d_tag` backfill - channel TTL cleanup - **Relay-member removal.** `remove_relay_member` and `remove_relay_member_if_role` run `delete_member_with_acceptances` inside their admitted transaction. The helper no longer opens its own transaction, so the member lock, the `join_policy_acceptances` delete and the member delete all hold admission until commit. `delete_workflow` and `delete_workflow_for_owner` are gone on `main`, and the coordinate deletion path they used to back was already admitted. - **Unchanged exceptions.** The deletion-executor generation exception and the serving-lease path work as before. - **Tables with no production writer.** `delivery_log` and `subscriptions` are only written by a historical cutover script and a test fixture. Their triggers stay. - **No cross-community moves.** No production `community_id` move exists on a fenced table. The only `SET community_id = NULL` is the deletion-executor purge. So no dual-community lock ordering was needed. ## What got simpler - Per-store ad-hoc writer helpers are gone: `begin_event_write_transaction` and `acquire_event_write_connection` in `channel.rs`, and `begin_operation_transaction` in `push.rs`. - Audit append drops the session advisory unlock, the `catch_unwind`/`resume_unwind` panic handling, `log_inner` and the `futures-util` dependency. ## Notes for reviewers - **New crate dependency.** `buzz-audit` now depends on `buzz-db` only to call the free function `buzz_db::deletion::guard_community_write`. `ARCHITECTURE.md` names this as the one sanctioned subsystem edge, and its audit section is updated to match. - **Hot paths.** `ensure_user` first checks whether the user exists and returns without admission when it does (no write happens). Only a real insert takes admission. `touch_api_token` has no production caller today. - **No-op writers stay read-only.** The startup allowlist backfill returns early on plain reads when the legacy table is gone, it has no rows for the community, or the community already has members, so a quiescing deployment community still starts. It re-checks under admission before inserting. - **Owner mutations.** When admission refuses a transfer or a provisioning and a deletion request exists, both report `DeletionPending`, as before. For a transfer, the operator API keeps its coded `deletion_lifecycle_conflict` 409. For provisioning, it keeps the existing uncoded 409, `owner_conflict: community deletion is pending`. - **Per-community worker loops.** `claim_due_push_wakes` and similar now return `AccessDenied` for a community that goes quiescing between the active-host scan and the claim. The worker logs it at `debug` and the next scan skips the community. - **Kick side effects.** `membership_removal_fence` admits before the membership lock to keep the lock order of `add_member`. A community that leaves `active` between the kick and the fence skips live subscription eviction; the membership cache is already invalidated and fan-out re-checks membership. This is documented on the function. ## Testing - **New refusal tests.** 19 tests: the 18 in `store::admission_postgres_tests`, plus `audit_append_requires_application_admission_with_triggers_bypassed`. Each runs with ordinary triggers disabled (`session_replication_role = replica`), checks that a raw fenced insert would succeed, and asserts that the writer is refused for both `quiescing` and `fenced` communities. Active writes still succeed. - **The tests catch a missing check.** Measured in review at `6fc073c3f`: with the guard in `AdmittedTx::admit` bypassed, 25 tests fail. That includes 16 of the 18 database admission tests. The audit test stays green there, because audit calls `guard_community_write` directly. - **Affected PG suites, with triggers on:** 183/183 passed across 16 store suites, push 18/18 run isolated, audit 7/7. The existing artifact, reaction and replaceable admission tests pass. - **Review fixes, each checked to fail without its fix:** - `allowlist_backfill_requires_application_admission` and `owner_mutations_report_pending_deletion_when_admission_refuses` fail with the old `relay_members.rs`. - `empty_allowlist_backfill_skips_admission` fails (`AccessDenied … write-fenced (quiescing)`) without the allowlist-rows early return. - `audit_worker_retries_admission_lock_timeout_until_original_entry_is_appended_once` holds the community deletion lock exclusive and fails without the admission retry arm. - The existing audit-chain retry test now looks for the transaction-scoped lock and passes. - The two owner-revoke mini-schema tests (`bridge.rs`, `ingest.rs`) now include `communities` and `community_deletion_lock_key`, which admission reads; both failed without that. - **Static checks:** `cargo fmt --check` and workspace `cargo clippy --all-targets -D warnings` pass. - **Not run locally:** the `mobile-check` step of `just ci` (the build machine couldn't reach pub.dev, and clean base fails the same way) and the full `just test` lane. CI on this PR covers both. `mesh_demo::demo_join_forwarded_arm_round_trips_echo` fails on the build machine at base `394d4a434` as well; it uses no database. Refs BUZZ-314. --------- Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> --- ARCHITECTURE.md | 4 +- Cargo.lock | 2 +- crates/buzz-audit/Cargo.toml | 2 +- crates/buzz-audit/src/error.rs | 7 + crates/buzz-audit/src/service.rs | 103 ++- crates/buzz-db/src/lib.rs | 5 +- crates/buzz-db/src/runtime/admitted_tx.rs | 8 +- crates/buzz-db/src/runtime/mod.rs | 24 +- .../src/store/admission_postgres_tests.rs | 796 ++++++++++++++++++ crates/buzz-db/src/store/allowlist.rs | 12 +- crates/buzz-db/src/store/api_token.rs | 48 +- .../buzz-db/src/store/archived_identities.rs | 14 +- crates/buzz-db/src/store/channel.rs | 131 +-- crates/buzz-db/src/store/channel_members.rs | 58 +- crates/buzz-db/src/store/community.rs | 6 + crates/buzz-db/src/store/deletion.rs | 49 +- crates/buzz-db/src/store/dm.rs | 33 +- crates/buzz-db/src/store/git_repo.rs | 16 +- crates/buzz-db/src/store/mod.rs | 3 + crates/buzz-db/src/store/moderation.rs | 85 +- .../src/store/personal_read/postgres_tests.rs | 8 +- .../buzz-db/src/store/personal_read/writes.rs | 34 +- crates/buzz-db/src/store/push.rs | 156 ++-- .../buzz-db/src/store/relay_admin_actions.rs | 138 ++- crates/buzz-db/src/store/relay_invite.rs | 25 +- crates/buzz-db/src/store/relay_members.rs | 199 +++-- crates/buzz-db/src/store/thread.rs | 34 +- crates/buzz-db/src/store/user.rs | 45 +- crates/buzz-db/src/store/workflow.rs | 119 ++- crates/buzz-db/tests/observability_source.rs | 21 +- crates/buzz-relay/src/api/bridge.rs | 20 +- crates/buzz-relay/src/handlers/ingest.rs | 15 +- crates/buzz-relay/src/push_runtime.rs | 7 +- crates/buzz-relay/src/state.rs | 86 +- 34 files changed, 1865 insertions(+), 448 deletions(-) create mode 100644 crates/buzz-db/src/store/admission_postgres_tests.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bab06526ed7..e0875618dd0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -145,7 +145,7 @@ buzz-admin (operator CLI: relay membership + key generation) buzz-test-client (integration test harness + manual CLI) ``` -**Key architectural principle:** The relay is the single source of truth. `buzz-relay` orchestrates all subsystems by calling them directly — it imports `buzz-db`, `buzz-auth`, `buzz-pubsub`, `buzz-search`, `buzz-audit`, and `buzz-workflow`. However, those subsystems are isolated from each other: `buzz-workflow` never calls `buzz-pubsub`, `buzz-search` never calls `buzz-db`, etc. Cross-subsystem coordination happens only through the relay. In multi-community mode, the relay also owns propagation of `TenantContext`; service crates should receive community-scoped inputs rather than independently deriving tenancy from client-controlled event tags. +**Key architectural principle:** The relay is the single source of truth. `buzz-relay` orchestrates all subsystems by calling them directly — it imports `buzz-db`, `buzz-auth`, `buzz-pubsub`, `buzz-search`, `buzz-audit`, and `buzz-workflow`. However, those subsystems are isolated from each other: `buzz-workflow` never calls `buzz-pubsub`, `buzz-search` never calls `buzz-db`, etc. The one sanctioned exception is `buzz-audit`, which calls `buzz_db::deletion::guard_community_write` to admit each audit append into its community before taking the chain lock. Cross-subsystem coordination happens only through the relay. In multi-community mode, the relay also owns propagation of `TenantContext`; service crates should receive community-scoped inputs rather than independently deriving tenancy from client-controlled event tags. --- @@ -596,7 +596,7 @@ Tamper-evident append-only log with SHA-256 hash chaining. **Hash covers:** seq (big-endian bytes), timestamp (RFC3339), event_id, event_kind (big-endian), actor_pubkey, action string, channel_id (16 bytes or 16 zero bytes if None), canonical metadata JSON (BTreeMap for deterministic key ordering), prev_hash. -**Single-writer guarantee:** `pg_advisory_lock` before each transaction. Lock released in all branches including panic (`catch_unwind`). +**Single-writer guarantee:** each append runs in one transaction that first passes community write admission (the shared community deletion lock plus the active/undeleted check, via `buzz-db`'s `DeletionStore::guard_transaction`), then takes a per-community `pg_advisory_xact_lock`. Both locks release on commit or rollback, including a dropped future. **10 audit actions:** `EventCreated`, `EventDeleted`, `ChannelCreated`, `ChannelUpdated`, `ChannelDeleted`, `MemberAdded`, `MemberRemoved`, `AuthSuccess`, `AuthFailure`, `RateLimitExceeded`. diff --git a/Cargo.lock b/Cargo.lock index 0a924f4e36f..27a35a4ae5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1058,8 +1058,8 @@ version = "0.1.0" dependencies = [ "buzz-core", "buzz-datastore-tracing", + "buzz-db", "chrono", - "futures-util", "hex", "metrics", "serde", diff --git a/crates/buzz-audit/Cargo.toml b/crates/buzz-audit/Cargo.toml index 766ade65050..a1af6aac0c0 100644 --- a/crates/buzz-audit/Cargo.toml +++ b/crates/buzz-audit/Cargo.toml @@ -9,6 +9,7 @@ description = "Hash-chain audit log for Buzz" [dependencies] buzz-core = { workspace = true } +buzz-db = { workspace = true } buzz-datastore-tracing = { workspace = true } sqlx = { workspace = true } tokio = { workspace = true } @@ -21,4 +22,3 @@ metrics = { workspace = true } thiserror = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } -futures-util = { workspace = true } diff --git a/crates/buzz-audit/src/error.rs b/crates/buzz-audit/src/error.rs index 6f28302b4b0..9d63e157252 100644 --- a/crates/buzz-audit/src/error.rs +++ b/crates/buzz-audit/src/error.rs @@ -14,6 +14,10 @@ pub enum AuditError { #[error("database error: {0}")] Database(#[from] sqlx::Error), + /// Community write admission failed before the audit chain was locked. + #[error("audit write admission failed")] + Admission(#[from] buzz_db::DbError), + /// The `prev_hash` of an entry does not match the hash of the preceding /// entry in the same community's chain. #[error( @@ -77,6 +81,9 @@ mod tests { AuditError::HashMismatch { seq: 42 }, AuditError::UnknownAction, AuditError::UnsupportedHashVersion { version: 99 }, + AuditError::Admission(buzz_db::DbError::AccessDenied(format!( + "community {community} is write-fenced" + ))), ]; for err in &domain_errors { diff --git a/crates/buzz-audit/src/service.rs b/crates/buzz-audit/src/service.rs index 518ae33cec5..3d2cb8d3648 100644 --- a/crates/buzz-audit/src/service.rs +++ b/crates/buzz-audit/src/service.rs @@ -1,6 +1,5 @@ use chrono::{DateTime, Utc}; -use futures_util::FutureExt as _; -use sqlx::{Acquire, PgPool, Row}; +use sqlx::{PgPool, Row}; use tracing::{debug, warn}; use uuid::Uuid; @@ -17,16 +16,14 @@ use crate::{ /// The `created_at` stamped on a new entry. /// /// Reduced to the precision Postgres round-trips before it is hashed — see -/// [`to_storage_precision`]. Split out from [`AuditService::log_inner`] so the +/// [`to_storage_precision`]. Split out from [`AuditService::log`] so the /// invariant is testable without a database. fn log_timestamp() -> DateTime { to_storage_precision(Utc::now()) } -/// Per-community advisory lock key. Derived in Postgres from the community UUID -/// so two communities never serialize each other's audit writes (which would be -/// both a throughput bottleneck and a cross-tenant timing oracle). The lock is -/// taken with `pg_advisory_lock(hashtextextended(...))` — see [`AuditService::log`]. +/// Per-community audit-chain advisory lock key. Admission takes the community +/// deletion lock first; this key then serializes appends within that community. const AUDIT_LOCK_NAMESPACE: &str = "buzz_audit:"; /// Append-only, per-community hash-chain audit log backed by Postgres. @@ -47,50 +44,22 @@ impl AuditService { /// Append a new entry to the calling community's chain. /// - /// Serialized per-community via `pg_advisory_lock`. Postgres advisory locks - /// are session-scoped, so we acquire before the transaction and release - /// after commit (or on any error path). + /// Community admission precedes the transaction-scoped audit-chain lock. + /// Both locks are released on commit or rollback, including a dropped future. #[datastore_span( name = "audit_log", system = "postgresql", fields(action = %entry.action) )] pub async fn log(&self, entry: NewAuditEntry) -> Result { - let mut conn = self.pool.acquire().await?; - - // Per-community advisory lock: hash the namespaced community id to an - // i64 lock key inside Postgres. Communities lock independently. + let mut tx = self.pool.begin().await?; + buzz_db::deletion::guard_community_write(&mut tx, entry.community_id).await?; let lock_key = format!("{AUDIT_LOCK_NAMESPACE}{}", entry.community_id); - sqlx::query("SELECT pg_advisory_lock(hashtextextended($1, 0))") + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") .bind(&lock_key) - .execute(&mut *conn) + .execute(&mut *tx) .await?; - // Run the chain append and release the lock regardless of outcome. - // catch_unwind so a panic still releases the lock before the connection - // returns to the pool. - let result = std::panic::AssertUnwindSafe(self.log_inner(&mut conn, entry)) - .catch_unwind() - .await; - - let _ = sqlx::query("SELECT pg_advisory_unlock(hashtextextended($1, 0))") - .bind(&lock_key) - .execute(&mut *conn) - .await; - - match result { - Ok(inner_result) => inner_result, - Err(panic_payload) => std::panic::resume_unwind(panic_payload), - } - } - - async fn log_inner( - &self, - conn: &mut sqlx::pool::PoolConnection, - entry: NewAuditEntry, - ) -> Result { - let mut tx = conn.begin().await?; - // The stored row keys on the raw UUID; the typed `CommunityId` on the // input is the provenance fence, dereferenced here at the DB boundary. let community_id = *entry.community_id.as_uuid(); @@ -321,6 +290,58 @@ mod postgres_tests { id } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn audit_append_requires_application_admission_with_triggers_bypassed() { + let _g = db_lock().lock().await; + let url = std::env::var("DATABASE_URL").expect("Postgres lane must provide DATABASE_URL"); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&url) + .await + .expect("connect test database"); + let active = make_community(&pool).await; + for state in ["quiescing", "fenced"] { + let id = make_community(&pool).await; + let mut tx = pool.begin().await.expect("begin lifecycle transition"); + sqlx::query( + "SELECT set_config('buzz.deletion_executor_community', $1, true), \ + set_config('buzz.deletion_fence_generation', \ + (SELECT deletion_fence_generation::text FROM communities WHERE id = $2), true)", + ) + .bind(id.to_string()) + .bind(id) + .execute(&mut *tx) + .await + .expect("authorize lifecycle transition"); + sqlx::query("UPDATE communities SET deletion_state = $2 WHERE id = $1") + .bind(id) + .bind(state) + .execute(&mut *tx) + .await + .expect("set lifecycle state"); + tx.commit().await.expect("commit lifecycle state"); + + sqlx::query("SET session_replication_role = replica") + .execute(&pool) + .await + .expect("disable ordinary triggers"); + let error = AuditService::new(pool.clone()) + .log(new_entry(id, AuditAction::EventCreated)) + .await + .expect_err("fenced audit append must reject before chain lock"); + assert!( + matches!(error, AuditError::Admission(buzz_db::DbError::AccessDenied(message)) if message.contains("write-fenced")), + "expected application admission failure" + ); + } + let entry = AuditService::new(pool.clone()) + .log(new_entry(active, AuditAction::EventCreated)) + .await + .expect("active audit append succeeds"); + assert_eq!(entry.seq, 1); + } + fn new_entry(community_id: Uuid, action: AuditAction) -> NewAuditEntry { NewAuditEntry { community_id: CommunityId::from_uuid(community_id), diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index f8adfefab75..2f306c35d08 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -60,8 +60,9 @@ pub const DB_CONNECTION_TERMINALS: [(DbPoolRole, DbConnectionStep, DbConnectionO pub const DB_CONNECTION_RAW_SERIES_PER_POD: usize = runtime::CONNECTION_RAW_SERIES_PER_POD; pub(crate) use runtime::{ begin_community_event_write_transaction, - begin_community_event_write_transaction_with_legacy_metrics, insert_mentions_in_transaction, - observability, route_proof, ReadSessionInner, RouteDecision, RoutePredicate, + begin_community_event_write_transaction_with_legacy_metrics, begin_community_write_transaction, + insert_mentions_in_transaction, observability, route_proof, ReadSessionInner, RouteDecision, + RoutePredicate, }; pub use store::{ admin_moderation, allowlist, api_token, archived_identities, artifact, channel, diff --git a/crates/buzz-db/src/runtime/admitted_tx.rs b/crates/buzz-db/src/runtime/admitted_tx.rs index fb0bb4f41fb..eb4607be257 100644 --- a/crates/buzz-db/src/runtime/admitted_tx.rs +++ b/crates/buzz-db/src/runtime/admitted_tx.rs @@ -1,4 +1,4 @@ -//! Community-admitted event-write transactions. +//! Community-admitted write transactions. use std::collections::BTreeSet; @@ -9,12 +9,12 @@ use uuid::Uuid; use crate::deletion::{DeletionStore, ServingWriteLease}; use crate::Result; -/// An event-write transaction that has passed community admission. +/// A transaction that has passed community write admission. /// /// Its only constructors take the shared community admission lock (or /// validate a serving write lease) on the transaction they wrap, for the /// community they record, so construction and admission are one step. -/// Event-write helpers take `&mut AdmittedTx` and read the community from it, +/// Write helpers take `&mut AdmittedTx` and read the community from it, /// so the compiler rejects a raw [`sqlx::Transaction`] or a transaction /// admitted for a different community. /// @@ -154,7 +154,7 @@ impl AdmittedTx { } /// Commit the transaction. This is the only commit path for admitted - /// event writes. + /// writes. /// /// Refreshes the TTL of every channel that received an event first, as /// the last statement before COMMIT. diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index 7be9402da40..266003f5a09 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -132,9 +132,9 @@ pub(crate) async fn insert_mentions_in_transaction( Ok(()) } -/// Start a tenant-local event-write transaction and take the shared community +/// Start a tenant-local write transaction and take the shared community /// deletion lock before any serving mutation. -async fn begin_community_event_write_transaction_with_metric_population( +async fn begin_community_write_transaction_with_metric_population( pool: &PgPool, community: CommunityId, operation: observability::WriterOperation, @@ -163,7 +163,7 @@ pub(crate) async fn begin_community_event_write_transaction( community: CommunityId, operation: observability::WriterOperation, ) -> Result { - begin_community_event_write_transaction_with_metric_population( + begin_community_write_transaction_with_metric_population( pool, community, operation, @@ -177,7 +177,7 @@ pub(crate) async fn begin_community_event_write_transaction_with_legacy_metrics( community: CommunityId, operation: observability::WriterOperation, ) -> Result { - begin_community_event_write_transaction_with_metric_population( + begin_community_write_transaction_with_metric_population( pool, community, operation, @@ -186,6 +186,22 @@ pub(crate) async fn begin_community_event_write_transaction_with_legacy_metrics( .await } +/// Begin a community write before any tenant mutation or row lock. The +/// admission lock is held by the returned transaction until commit or rollback. +pub(crate) async fn begin_community_write_transaction( + pool: &PgPool, + community: CommunityId, + operation: observability::WriterOperation, +) -> Result { + begin_community_write_transaction_with_metric_population( + pool, + community, + operation, + CommunityEventWriteMetricPopulation::TypedOnly, + ) + .await +} + /// Database handle. Clone is cheap (Arc-backed pool). #[derive(Clone, Debug)] pub struct Db { diff --git a/crates/buzz-db/src/store/admission_postgres_tests.rs b/crates/buzz-db/src/store/admission_postgres_tests.rs new file mode 100644 index 00000000000..6f1d8cab68e --- /dev/null +++ b/crates/buzz-db/src/store/admission_postgres_tests.rs @@ -0,0 +1,796 @@ +//! Live writer-admission regressions. A one-connection pool runs with ordinary +//! PostgreSQL triggers disabled, so a missing application check is observable. + +use crate::{Db, DbConfig, DbError}; +use buzz_core::CommunityId; +use uuid::Uuid; + +async fn fixture() -> (Db, [CommunityId; 3]) { + let config = DbConfig { + database_url: crate::test_support::database_url(), + max_connections: 1, + min_connections: 1, + ..DbConfig::default() + }; + let db = Db::new(&config).await.expect("connect test database"); + let pool = &db.pool; + let mut communities = [CommunityId::from_uuid(Uuid::nil()); 3]; + for community in &mut communities { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("admission-{id}.example")) + .execute(pool) + .await + .expect("create community"); + *community = CommunityId::from_uuid(id); + } + crate::test_support::set_deletion_state(pool, *communities[1].as_uuid(), "quiescing").await; + crate::test_support::set_deletion_state(pool, *communities[2].as_uuid(), "fenced").await; + + // One physical session serves every test API call. PostgreSQL's replica + // role skips the 32 community-write triggers as well as other ordinary + // triggers; the application precheck remains fully active. + sqlx::query("SET session_replication_role = replica") + .execute(pool) + .await + .expect("disable ordinary triggers on the test session"); + let role: String = sqlx::query_scalar("SHOW session_replication_role") + .fetch_one(pool) + .await + .expect("read replication role"); + assert_eq!(role, "replica"); + // Witness that the database backstop is truly bypassed: this raw fenced + // insert would be rejected with the community-write trigger enabled. + sqlx::query("INSERT INTO users (community_id, pubkey) VALUES ($1, $2)") + .bind(communities[2].as_uuid()) + .bind(vec![0xfe_u8; 32]) + .execute(pool) + .await + .expect("raw fenced insert succeeds with triggers disabled"); + (db, communities) +} + +fn assert_fenced(error: DbError) { + assert!( + matches!(&error, DbError::AccessDenied(message) if message.contains("write-fenced")), + "expected application admission rejection, got {error:?}" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn api_token_writers_require_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + super::api_token::create_api_token( + &db.pool, + *community.as_uuid(), + &[1; 32], + &[2; 32], + "test", + &[], + None, + None, + ) + .await + .expect_err("token create must reject"), + ); + assert_fenced( + super::api_token::revoke_all_tokens(&db.pool, *community.as_uuid(), &[2; 32], &[2; 32]) + .await + .expect_err("token revoke must reject"), + ); + } + super::api_token::create_api_token( + &db.pool, + *communities[0].as_uuid(), + &[3; 32], + &[2; 32], + "active", + &[], + None, + None, + ) + .await + .expect("active token create succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn archived_identity_writers_require_application_admission() { + let (db, communities) = fixture().await; + let pubkey = "a".repeat(64); + let actor = "b".repeat(64); + let event = "c".repeat(64); + for community in &communities[1..] { + assert_fenced( + super::archived_identities::archive( + &db.pool, *community, &pubkey, "self", &actor, None, None, &event, + ) + .await + .expect_err("archive must reject"), + ); + assert_fenced( + super::archived_identities::unarchive(&db.pool, *community, &pubkey) + .await + .expect_err("unarchive must reject"), + ); + } + assert!(super::archived_identities::archive( + &db.pool, + communities[0], + &pubkey, + "self", + &actor, + None, + None, + &event, + ) + .await + .expect("active archive succeeds")); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn allowlist_writers_require_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + db.add_to_allowlist(*community, &[1; 32], &[2; 32], None) + .await + .expect_err("allowlist add must reject"), + ); + assert_fenced( + db.remove_from_allowlist(*community, &[1; 32]) + .await + .expect_err("allowlist remove must reject"), + ); + } + assert!(db + .add_to_allowlist(communities[0], &[1; 32], &[2; 32], None) + .await + .expect("active allowlist add succeeds")); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn git_name_writers_require_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + super::git_repo::reserve_repo_name(&db.pool, *community, "repo", "owner") + .await + .expect_err("repo reserve must reject"), + ); + assert_fenced( + super::git_repo::release_repo_name(&db.pool, *community, "repo", "owner") + .await + .expect_err("repo release must reject"), + ); + } + assert_eq!( + super::git_repo::reserve_repo_name(&db.pool, communities[0], "repo", "owner") + .await + .expect("active repo reserve succeeds"), + super::git_repo::ReserveOutcome::Reserved + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn relay_member_writers_require_application_admission() { + let (db, communities) = fixture().await; + let pubkey = "d".repeat(64); + for community in &communities[1..] { + assert_fenced( + super::relay_members::add_relay_member(&db.pool, *community, &pubkey, "member", None) + .await + .expect_err("member add must reject"), + ); + assert_fenced( + super::relay_members::remove_relay_member(&db.pool, *community, &pubkey) + .await + .expect_err("member remove must reject"), + ); + } + assert!(super::relay_members::add_relay_member( + &db.pool, + communities[0], + &pubkey, + "member", + None, + ) + .await + .expect("active member add succeeds")); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn relay_invite_writers_require_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + super::relay_invite::mint_relay_invite( + &db.pool, + *community, + &"e".repeat(64), + 3600, + Some(1), + ) + .await + .expect_err("invite mint must reject"), + ); + assert_fenced( + super::relay_invite::claim_relay_invite( + &db.pool, + *community, + &[3; 32], + &"f".repeat(64), + None, + ) + .await + .expect_err("invite claim must reject before row lock"), + ); + } + super::relay_invite::mint_relay_invite( + &db.pool, + communities[0], + &"e".repeat(64), + 3600, + Some(1), + ) + .await + .expect("active invite mint succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn user_writers_require_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + super::user::ensure_user(&db.pool, *community, &[1; 32]) + .await + .expect_err("user ensure must reject"), + ); + assert_fenced( + super::user::update_user_profile( + &db.pool, + *community, + &[1; 32], + Some("name"), + None, + None, + None, + ) + .await + .expect_err("profile update must reject"), + ); + } + assert!(super::user::ensure_user(&db.pool, communities[0], &[1; 32]) + .await + .expect("active user ensure succeeds")); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn moderation_writers_require_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + super::moderation::insert_report( + &db.pool, + *community, + super::moderation::NewReport { + report_event_id: &[3; 32], + reporter_pubkey: &[1; 32], + target: super::moderation::ReportTarget::Pubkey(vec![2; 32]), + channel_id: None, + report_type: "spam", + note: None, + }, + ) + .await + .expect_err("moderation report must reject"), + ); + assert_fenced( + super::moderation::insert_action( + &db.pool, + *community, + super::moderation::NewAction { + actor_pubkey: &[1; 32], + action: "ban", + target_pubkey: Some(&[2; 32]), + target_event_id: None, + channel_id: None, + reason_code: None, + public_reason: None, + private_reason: None, + matched_principal: None, + actor_authority: None, + }, + ) + .await + .expect_err("moderation action must reject"), + ); + assert_fenced( + super::moderation::ban_member(&db.pool, *community, &[1; 32], &[2; 32], None, None) + .await + .expect_err("ban must reject"), + ); + assert_fenced( + super::moderation::unban_member(&db.pool, *community, &[1; 32], &[2; 32]) + .await + .expect_err("unban must reject"), + ); + } + super::moderation::ban_member(&db.pool, communities[0], &[1; 32], &[2; 32], None, None) + .await + .expect("active ban succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_writers_require_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + super::workflow::create_workflow( + &db.pool, *community, None, &[1; 32], "workflow", "{}", &[2; 32], + ) + .await + .expect_err("workflow create must reject"), + ); + assert_fenced( + super::workflow::create_workflow_run(&db.pool, *community, Uuid::new_v4(), None, None) + .await + .expect_err("workflow run create must reject"), + ); + assert_fenced( + super::workflow::claim_scheduled_workflow_fire( + &db.pool, + *community, + Uuid::new_v4(), + chrono::Utc::now(), + ) + .await + .expect_err("schedule claim must reject"), + ); + assert_fenced( + super::workflow::attach_scheduled_workflow_run( + &db.pool, + *community, + Uuid::new_v4(), + chrono::Utc::now(), + Uuid::new_v4(), + ) + .await + .expect_err("schedule attach must reject"), + ); + assert_fenced( + super::workflow::create_approval( + &db.pool, + super::workflow::CreateApprovalParams { + community_id: *community, + token: "fenced-approval", + workflow_id: Uuid::new_v4(), + run_id: Uuid::new_v4(), + step_id: "step", + step_index: 0, + approver_spec: "owner", + expires_at: chrono::Utc::now() + chrono::Duration::hours(1), + }, + ) + .await + .expect_err("approval create must reject"), + ); + assert_fenced( + super::workflow::update_approval_by_stored_hash( + &db.pool, + *community, + &[4; 32], + super::workflow::ApprovalStatus::Granted, + Some(&[1; 32]), + None, + ) + .await + .expect_err("approval update must reject"), + ); + } + super::workflow::create_workflow( + &db.pool, + communities[0], + None, + &[1; 32], + "active", + "{}", + &[2; 32], + ) + .await + .expect("active workflow create succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn push_writers_require_application_admission() { + let (db, communities) = fixture().await; + let version = super::push::LeaseVersion { + source_event_id: &[3; 32], + source_created_at: chrono::Utc::now().timestamp(), + generation: 1, + expires_at: chrono::Utc::now().timestamp() + 3600, + }; + for community in &communities[1..] { + assert_fenced( + super::push::revoke_lease(&db.pool, *community, &[1; 32], "install", version) + .await + .expect_err("lease revoke must reject"), + ); + assert_fenced( + super::push::complete_match_batch(&db.pool, *community, Uuid::new_v4(), &[vec![2; 32]]) + .await + .expect_err("matcher complete must reject"), + ); + assert_fenced( + super::push::complete_wake(&db.pool, *community, Uuid::new_v4(), Uuid::new_v4()) + .await + .expect_err("wake complete must reject"), + ); + } + super::push::revoke_lease(&db.pool, communities[0], &[1; 32], "install", version) + .await + .expect("active lease revoke succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn channel_and_membership_writers_require_application_admission() { + let (db, communities) = fixture().await; + let creator = [1_u8; 32]; + for community in &communities[1..] { + assert_fenced( + super::channel::create_channel( + &db.pool, + *community, + "fenced", + super::channel::ChannelType::Stream, + super::channel::ChannelVisibility::Open, + None, + &creator, + None, + ) + .await + .expect_err("channel create must reject"), + ); + assert_fenced( + super::channel::set_canvas(&db.pool, *community, Uuid::new_v4(), Some("text")) + .await + .expect_err("canvas update must reject"), + ); + assert_fenced( + super::channel_members::add_member( + &db.pool, + *community, + Uuid::new_v4(), + &[2; 32], + super::channel::MemberRole::Member, + None, + ) + .await + .expect_err("membership add must reject"), + ); + } + let channel = super::channel::create_channel( + &db.pool, + communities[0], + "active", + super::channel::ChannelType::Stream, + super::channel::ChannelVisibility::Open, + None, + &creator, + None, + ) + .await + .expect("active channel create succeeds"); + super::channel_members::add_member( + &db.pool, + communities[0], + channel.id, + &[2; 32], + super::channel::MemberRole::Member, + None, + ) + .await + .expect("active membership add succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn dm_writers_require_application_admission() { + let (db, communities) = fixture().await; + let participants: [&[u8]; 2] = [&[1; 32], &[2; 32]]; + for community in &communities[1..] { + assert_fenced( + super::dm::create_dm(&db.pool, *community, &participants, participants[0]) + .await + .expect_err("DM create must reject"), + ); + assert_fenced( + super::dm::hide_dm(&db.pool, *community, Uuid::new_v4(), participants[0]) + .await + .expect_err("DM hide must reject"), + ); + } + super::dm::create_dm(&db.pool, communities[0], &participants, participants[0]) + .await + .expect("active DM create succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn thread_metadata_writer_requires_application_admission() { + let (db, communities) = fixture().await; + let timestamp = chrono::Utc::now(); + for community in &communities[1..] { + assert_fenced( + super::thread::insert_thread_metadata( + &db.pool, + *community, + &[1; 32], + timestamp, + Uuid::new_v4(), + None, + None, + None, + None, + 0, + false, + ) + .await + .expect_err("thread metadata must reject"), + ); + } + let channel = super::channel::create_channel( + &db.pool, + communities[0], + "thread", + super::channel::ChannelType::Stream, + super::channel::ChannelVisibility::Open, + None, + &[1; 32], + None, + ) + .await + .expect("active channel"); + super::thread::insert_thread_metadata( + &db.pool, + communities[0], + &[1; 32], + timestamp, + channel.id, + None, + None, + None, + None, + 0, + false, + ) + .await + .expect("active thread metadata insert succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn relay_admin_domain_writer_requires_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + super::relay_admin_actions::deploy_kick_member( + &db.pool, + *community, + Uuid::new_v4(), + &[1; 32], + &[2; 32], + ) + .await + .expect_err("deployment kick must reject"), + ); + } + let channel = super::channel::create_channel( + &db.pool, + communities[0], + "kick", + super::channel::ChannelType::Stream, + super::channel::ChannelVisibility::Open, + None, + &[1; 32], + None, + ) + .await + .expect("active channel"); + assert!(matches!( + super::relay_admin_actions::deploy_kick_member( + &db.pool, + communities[0], + channel.id, + &[1; 32], + &[2; 32], + ) + .await + .expect("active kick succeeds"), + super::relay_admin_actions::KickResult::Removed + )); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_writer_requires_application_admission() { + use nostr::{EventBuilder, Keys, Kind}; + let (db, communities) = fixture().await; + let actor = Keys::generate(); + let target = super::personal_read::ReadTarget { + channel_id: Uuid::new_v4(), + root_id: None, + }; + let intent = super::personal_read::ReadIntent::MarkThrough { + target, + message_id: "a".repeat(64), + }; + for community in &communities[1..] { + assert_fenced( + db.apply_personal_read_intent(*community, &actor.public_key(), &intent) + .await + .expect_err("personal read must reject before account lock"), + ); + } + let channel = super::channel::create_channel( + &db.pool, + communities[0], + "reads", + super::channel::ChannelType::Stream, + super::channel::ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .expect("active channel"); + let event = EventBuilder::new(Kind::Custom(9), "read") + .sign_with_keys(&Keys::generate()) + .expect("sign event"); + db.insert_event(communities[0], &event, Some(channel.id)) + .await + .expect("insert active event"); + let intent = super::personal_read::ReadIntent::MarkThrough { + target: super::personal_read::ReadTarget { + channel_id: channel.id, + root_id: None, + }, + message_id: event.id.to_hex(), + }; + assert_eq!( + db.apply_personal_read_intent(communities[0], &actor.public_key(), &intent) + .await + .expect("active personal read succeeds"), + super::personal_read::IntentOutcome::Applied + ); +} + +async fn count_relay_members(db: &Db, community: CommunityId) -> i64 { + sqlx::query_scalar("SELECT count(*) FROM relay_members WHERE community_id = $1") + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("count relay members") +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn allowlist_backfill_requires_application_admission() { + let (db, communities) = fixture().await; + for community in &communities { + sqlx::query("INSERT INTO pubkey_allowlist (community_id, pubkey) VALUES ($1, $2)") + .bind(community.as_uuid()) + .bind(vec![0x5a_u8; 32]) + .execute(&db.pool) + .await + .expect("seed legacy allowlist row"); + } + for community in &communities[1..] { + assert_fenced( + super::relay_members::backfill_from_allowlist(&db.pool, *community) + .await + .expect_err("backfill into a write-fenced community must reject"), + ); + assert_eq!(count_relay_members(&db, *community).await, 0); + } + assert_eq!( + super::relay_members::backfill_from_allowlist(&db.pool, communities[0]) + .await + .expect("active backfill succeeds"), + 1 + ); + assert_eq!(count_relay_members(&db, communities[0]).await, 1); + + // A community that already has members needs no write, so startup does + // not fail on admission even while the community is quiescing. + sqlx::query("INSERT INTO relay_members (community_id, pubkey, role) VALUES ($1, $2, 'member')") + .bind(communities[1].as_uuid()) + .bind("e".repeat(64)) + .execute(&db.pool) + .await + .expect("seed existing member"); + assert_eq!( + super::relay_members::backfill_from_allowlist(&db.pool, communities[1]) + .await + .expect("no-op backfill does not need admission"), + 0 + ); + assert_eq!(count_relay_members(&db, communities[1]).await, 1); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn empty_allowlist_backfill_skips_admission() { + // No members and no legacy allowlist rows: there is nothing to write, so + // startup must not fail on admission while the community is fenced. + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_eq!( + super::relay_members::backfill_from_allowlist(&db.pool, *community) + .await + .expect("empty-allowlist backfill does not need admission"), + 0 + ); + assert_eq!(count_relay_members(&db, *community).await, 0); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn owner_mutations_report_pending_deletion_when_admission_refuses() { + let (db, communities) = fixture().await; + let quiescing = communities[1]; + sqlx::query( + "INSERT INTO community_deletion_requests (community_id, community_host, requested_by) \ + SELECT id, host, 'admission-test' FROM communities WHERE id = $1", + ) + .bind(quiescing.as_uuid()) + .execute(&db.pool) + .await + .expect("seed deletion request"); + let owner = "f".repeat(64); + let next_owner = "1".repeat(64); + + assert_eq!( + super::relay_members::transfer_ownership(&db.pool, quiescing, &next_owner, &owner) + .await + .expect("transfer returns a result"), + super::relay_members::TransferResult::DeletionPending + ); + assert_eq!( + db.provision_owner(quiescing, &owner) + .await + .expect("provision returns a result"), + super::relay_members::ProvisionOwnerResult::DeletionPending + ); + + // Without a deletion request the refusal stays a lifecycle conflict. + let fenced = communities[2]; + assert_eq!( + super::relay_members::transfer_ownership(&db.pool, fenced, &next_owner, &owner) + .await + .expect("transfer returns a result"), + super::relay_members::TransferResult::LifecycleConflict + ); + assert_eq!( + db.provision_owner(fenced, &owner) + .await + .expect("provision returns a result"), + super::relay_members::ProvisionOwnerResult::LifecycleConflict + ); + assert_eq!(count_relay_members(&db, quiescing).await, 0); + assert_eq!(count_relay_members(&db, fenced).await, 0); +} diff --git a/crates/buzz-db/src/store/allowlist.rs b/crates/buzz-db/src/store/allowlist.rs index 2b72780a621..c493cab22f5 100644 --- a/crates/buzz-db/src/store/allowlist.rs +++ b/crates/buzz-db/src/store/allowlist.rs @@ -70,8 +70,9 @@ impl Db { added_by: &[u8], note: Option<&str>, ) -> Result { - let mut connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( &self.pool, + community, crate::observability::WriterOperation::Authorization, ) .await?; @@ -83,8 +84,9 @@ impl Db { .bind(pubkey) .bind(added_by) .bind(note) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -95,8 +97,9 @@ impl Db { community: CommunityId, pubkey: &[u8], ) -> Result { - let mut connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( &self.pool, + community, crate::observability::WriterOperation::Authorization, ) .await?; @@ -104,8 +107,9 @@ impl Db { sqlx::query("DELETE FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } diff --git a/crates/buzz-db/src/store/api_token.rs b/crates/buzz-db/src/store/api_token.rs index 41d4dcbad29..6c0eab0bf25 100644 --- a/crates/buzz-db/src/store/api_token.rs +++ b/crates/buzz-db/src/store/api_token.rs @@ -38,6 +38,12 @@ pub async fn create_api_token( }) .transpose()?; + let mut tx = crate::begin_community_write_transaction( + pool, + CommunityId::from_uuid(community_id), + crate::observability::WriterOperation::Authentication, + ) + .await?; sqlx::query( r#" INSERT INTO api_tokens @@ -53,9 +59,9 @@ pub async fn create_api_token( .bind(&scopes_json) .bind(&channel_ids_json) .bind(expires_at) - .execute(pool) + .execute(tx.conn()) .await?; - + tx.commit().await?; Ok(id) } @@ -94,6 +100,12 @@ pub async fn create_api_token_if_under_limit( // Conditional INSERT: only inserts if active (non-revoked, non-expired) token count < 10 // **for this (community, owner) pair**. The subquery and insert execute atomically -- // no separate count + insert race. + let mut tx = crate::begin_community_write_transaction( + pool, + CommunityId::from_uuid(community_id), + crate::observability::WriterOperation::Authentication, + ) + .await?; let result = sqlx::query( r#" INSERT INTO api_tokens @@ -118,8 +130,9 @@ pub async fn create_api_token_if_under_limit( .bind(&channel_ids_json) .bind(expires_at) .bind(owner_pubkey) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; if result.rows_affected() == 0 { // Limit exceeded -- the WHERE clause prevented the INSERT. @@ -279,6 +292,12 @@ pub async fn revoke_token( owner_pubkey: &[u8], revoked_by: &[u8], ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + CommunityId::from_uuid(community_id), + crate::observability::WriterOperation::Authentication, + ) + .await?; let result = sqlx::query( r#" UPDATE api_tokens @@ -293,9 +312,9 @@ pub async fn revoke_token( .bind(community_id) .bind(id) .bind(owner_pubkey) - .execute(pool) + .execute(tx.conn()) .await?; - + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -309,6 +328,12 @@ pub async fn revoke_all_tokens( owner_pubkey: &[u8], revoked_by: &[u8], ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + CommunityId::from_uuid(community_id), + crate::observability::WriterOperation::Authentication, + ) + .await?; let result = sqlx::query( r#" UPDATE api_tokens @@ -321,9 +346,9 @@ pub async fn revoke_all_tokens( .bind(revoked_by) .bind(community_id) .bind(owner_pubkey) - .execute(pool) + .execute(tx.conn()) .await?; - + tx.commit().await?; Ok(result.rows_affected()) } @@ -504,13 +529,20 @@ impl Db { /// Record a token usage (update `last_used_at`), scoped to community. #[datastore_span(name = "touch_api_token", system = "postgresql")] pub async fn touch_api_token(&self, community_id: CommunityId, hash: &[u8]) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + &self.pool, + community_id, + crate::observability::WriterOperation::Authentication, + ) + .await?; sqlx::query( "UPDATE api_tokens SET last_used_at = NOW() WHERE community_id = $1 AND token_hash = $2", ) .bind(community_id.as_uuid()) .bind(hash) - .execute(&self.pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(()) } diff --git a/crates/buzz-db/src/store/archived_identities.rs b/crates/buzz-db/src/store/archived_identities.rs index b1636de31a3..986985bc464 100644 --- a/crates/buzz-db/src/store/archived_identities.rs +++ b/crates/buzz-db/src/store/archived_identities.rs @@ -64,8 +64,9 @@ pub async fn archive( replaced_by: Option<&str>, request_event_id: &str, ) -> Result { - let mut connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community_id, crate::observability::WriterOperation::EventWrite, ) .await?; @@ -82,9 +83,9 @@ pub async fn archive( .bind(reason) .bind(replaced_by) .bind(request_event_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; - + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -93,8 +94,9 @@ pub async fn archive( /// Returns `true` if a row was deleted, `false` if the identity was not archived /// in that community. pub async fn unarchive(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result { - let mut connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community_id, crate::observability::WriterOperation::EventWrite, ) .await?; @@ -102,9 +104,9 @@ pub async fn unarchive(pool: &PgPool, community_id: CommunityId, pubkey: &str) - sqlx::query("DELETE FROM archived_identities WHERE community_id = $1 AND pubkey = $2") .bind(community_id.as_uuid()) .bind(pubkey) - .execute(&mut *connection) + .execute(tx.conn()) .await?; - + tx.commit().await?; Ok(result.rows_affected() > 0) } diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index 46375595cf3..13a93263e9f 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -29,27 +29,6 @@ pub use crate::channel_members::{ ChannelIdentity, LargeChannelRoster, LockedMemberSnapshot, MemberRecord, UserRecord, }; -async fn begin_event_write_transaction( - pool: &PgPool, -) -> Result> { - let connection = crate::observability::acquire_writer( - pool, - crate::observability::WriterOperation::EventWrite, - ) - .await?; - Ok(sqlx::Transaction::begin(connection, None).await?) -} - -async fn acquire_event_write_connection( - pool: &PgPool, -) -> Result> { - Ok(crate::observability::acquire_writer( - pool, - crate::observability::WriterOperation::EventWrite, - ) - .await?) -} - /// A channel row as returned from the database. #[derive(Debug, Clone)] pub struct ChannelRecord { @@ -135,7 +114,12 @@ pub async fn create_channel( let id = Uuid::new_v4(); - let mut tx = begin_event_write_transaction(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; sqlx::query( r#" @@ -152,7 +136,7 @@ pub async fn create_channel( .bind(description) .bind(created_by) .bind(ttl_seconds) - .execute(&mut *tx) + .execute(tx.conn()) .await?; sqlx::query( @@ -169,7 +153,7 @@ pub async fn create_channel( .bind(id) .bind(created_by) .bind(created_by) - .execute(&mut *tx) + .execute(tx.conn()) .await?; let row = sqlx::query( @@ -186,7 +170,7 @@ pub async fn create_channel( ) .bind(community_id.as_uuid()) .bind(id) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; let record = row_to_channel_record(row)?; @@ -228,7 +212,12 @@ pub async fn create_channel_with_id( return Err(DbError::InvalidData("channel name is required".into())); } - let mut tx = begin_event_write_transaction(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let rows_affected = sqlx::query( r#" @@ -246,7 +235,7 @@ pub async fn create_channel_with_id( .bind(description) .bind(created_by) .bind(ttl_seconds) - .execute(&mut *tx) + .execute(tx.conn()) .await? .rows_affected(); @@ -268,7 +257,7 @@ pub async fn create_channel_with_id( .bind(channel_id) .bind(created_by) .bind(created_by) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } @@ -286,7 +275,7 @@ pub async fn create_channel_with_id( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; let record = row_to_channel_record(row)?; @@ -361,17 +350,24 @@ pub async fn set_canvas( channel_id: Uuid, canvas: Option<&str>, ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let rows = sqlx::query( "UPDATE channels SET canvas = $1 WHERE community_id = $2 AND id = $3 AND deleted_at IS NULL", ) .bind(canvas) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(pool) + .execute(tx.conn()) .await?; if rows.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); } + tx.commit().await?; Ok(()) } @@ -593,22 +589,33 @@ pub async fn update_channel( // this transition — whose own deadline reset is then the latest word. // Non-TTL updates don't touch the fast path and skip the lock. if updates.ttl_seconds.is_some() { - let mut tx = begin_event_write_transaction(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") .bind(channel_ttl_lock_key(community_id, channel_id)) - .execute(&mut *tx) + .execute(tx.conn()) .await?; - let result = q.execute(&mut *tx).await?; + let result = q.execute(tx.conn()).await?; if result.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); } tx.commit().await?; } else { - let mut connection = acquire_event_write_connection(pool).await?; - let result = q.execute(&mut *connection).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let result = q.execute(tx.conn()).await?; if result.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); } + tx.commit().await?; } get_channel_with_operation( @@ -628,7 +635,12 @@ pub async fn set_topic( topic: &str, set_by: &[u8], ) -> Result<()> { - let mut connection = acquire_event_write_connection(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( "UPDATE channels SET topic = $1, topic_set_by = $2, topic_set_at = NOW() \ WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL", @@ -637,11 +649,12 @@ pub async fn set_topic( .bind(set_by) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; if result.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); } + tx.commit().await?; Ok(()) } @@ -653,7 +666,12 @@ pub async fn set_purpose( purpose: &str, set_by: &[u8], ) -> Result<()> { - let mut connection = acquire_event_write_connection(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( "UPDATE channels SET purpose = $1, purpose_set_by = $2, purpose_set_at = NOW() \ WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL", @@ -662,11 +680,12 @@ pub async fn set_purpose( .bind(set_by) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; if result.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); } + tx.commit().await?; Ok(()) } @@ -679,14 +698,19 @@ pub async fn archive_channel( community_id: CommunityId, channel_id: Uuid, ) -> Result<()> { - let mut connection = acquire_event_write_connection(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; // First check: does the channel exist and what is its state? let row = sqlx::query( "SELECT archived_at FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_optional(&mut *connection) + .fetch_optional(tx.conn()) .await?; match row { @@ -707,9 +731,10 @@ pub async fn archive_channel( ) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(()) } @@ -722,14 +747,19 @@ pub async fn unarchive_channel( community_id: CommunityId, channel_id: Uuid, ) -> Result<()> { - let mut connection = acquire_event_write_connection(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; // First check: does the channel exist and what is its state? let row = sqlx::query( "SELECT archived_at FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_optional(&mut *connection) + .fetch_optional(tx.conn()) .await?; match row { @@ -752,9 +782,10 @@ pub async fn unarchive_channel( ) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(()) } @@ -767,15 +798,21 @@ pub async fn soft_delete_channel( community_id: CommunityId, channel_id: Uuid, ) -> Result { - let mut connection = acquire_event_write_connection(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( "UPDATE channels SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", ) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 7ff51467752..1994de14ed4 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -4,7 +4,7 @@ //! roster snapshots hold that same lock through replacement publication. use chrono::{DateTime, Utc}; -use sqlx::{PgConnection, PgPool, Postgres, Row, Transaction}; +use sqlx::{PgConnection, PgPool, Row}; use uuid::Uuid; use crate::channel::{row_to_channel_record, ChannelRecord}; @@ -539,18 +539,18 @@ pub async fn add_member( ))); } - let connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community_id, crate::observability::WriterOperation::EventWrite, ) .await?; - let mut tx = sqlx::Transaction::begin(connection, None).await?; // First statement: serialize the whole role-check / owner-count / upsert // sequence against concurrent membership writes on this channel. - acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; + acquire_channel_membership_lock(tx.conn(), community_id, channel_id).await?; - let channel = get_channel_tx(&mut tx, community_id, channel_id).await?; + let channel = get_channel_tx(tx.conn(), community_id, channel_id).await?; let effective_role = if channel.visibility == "private" { let inviter = invited_by.ok_or_else(|| { @@ -561,7 +561,7 @@ pub async fn add_member( let is_creator_bootstrap = inviter == pubkey && inviter == channel.created_by.as_slice(); if !is_creator_bootstrap { - let inviter_role_str = get_active_role_tx(&mut tx, community_id, channel_id, inviter) + let inviter_role_str = get_active_role_tx(tx.conn(), community_id, channel_id, inviter) .await? .ok_or_else(|| { DbError::AccessDenied("inviter is not an active member".to_string()) @@ -587,7 +587,7 @@ pub async fn add_member( // elevated roles. Self-join always gets Member. if role.is_elevated() { let granter_role = match invited_by { - Some(inv) => get_active_role_tx(&mut tx, community_id, channel_id, inv).await?, + Some(inv) => get_active_role_tx(tx.conn(), community_id, channel_id, inv).await?, None => None, }; match granter_role.as_deref() { @@ -619,10 +619,12 @@ pub async fn add_member( // current authority from a removed row would make soft-deleted ownership a // resurrection token: an owner removed by another owner could self-rejoin // via kind:9021 (`Member, None`) and silently regain ownership. - let current_role = get_active_role_tx(&mut tx, community_id, channel_id, pubkey).await?; + let current_role = get_active_role_tx(tx.conn(), community_id, channel_id, pubkey).await?; if let Some(current_role) = current_role.filter(|r| r != effective_role.as_str()) { let actor_role = match invited_by { - Some(inviter) => get_active_role_tx(&mut tx, community_id, channel_id, inviter).await?, + Some(inviter) => { + get_active_role_tx(tx.conn(), community_id, channel_id, inviter).await? + } None => None, }; let actor_role: Option = actor_role.and_then(|r| r.parse().ok()); @@ -642,7 +644,7 @@ pub async fn add_member( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; let owner_count: i64 = row.try_get("cnt")?; if owner_count <= 1 { @@ -668,7 +670,7 @@ pub async fn add_member( .bind(pubkey) .bind(effective_role.as_str()) .bind(invited_by) - .execute(&mut *tx) + .execute(tx.conn()) .await?; let row = sqlx::query( @@ -680,7 +682,7 @@ pub async fn add_member( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; let record = row_to_member_record(row)?; @@ -725,20 +727,20 @@ pub async fn remove_member( crate::user::is_agent_owner(pool, community_id, pubkey, actor_pubkey).await? }; - let connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community_id, crate::observability::WriterOperation::EventWrite, ) .await?; - let mut tx = sqlx::Transaction::begin(connection, None).await?; // First statement: serialize the actor-role check, the last-owner count and // the UPDATE against concurrent membership writes on this channel (same key // as `add_member`). - acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; + acquire_channel_membership_lock(tx.conn(), community_id, channel_id).await?; if !is_self_remove { - let actor_role_str = get_active_role_tx(&mut tx, community_id, channel_id, actor_pubkey) + let actor_role_str = get_active_role_tx(tx.conn(), community_id, channel_id, actor_pubkey) .await? .ok_or_else(|| DbError::AccessDenied("actor is not an active member".to_string()))?; let actor_role: MemberRole = actor_role_str.parse().map_err(|_| { @@ -754,7 +756,7 @@ pub async fn remove_member( // Defense-in-depth: prevent removing the last owner regardless of caller. // Callers (REST handlers, NIP-29 handlers) also check this, but the DB // layer enforces it as the final safety net. - let target_role = get_active_role_tx(&mut tx, community_id, channel_id, pubkey).await?; + let target_role = get_active_role_tx(tx.conn(), community_id, channel_id, pubkey).await?; if target_role.as_deref() == Some("owner") { let row = sqlx::query( "SELECT COUNT(*) as cnt FROM channel_members \ @@ -762,7 +764,7 @@ pub async fn remove_member( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; let owner_count: i64 = row.try_get("cnt")?; if owner_count <= 1 { @@ -783,7 +785,7 @@ pub async fn remove_member( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if result.rows_affected() == 0 { @@ -900,7 +902,7 @@ pub struct MembershipRemovalFence { pub still_removed: bool, // Holds the advisory transaction lock. The caller commits via // `commit_disabling_workflows`; on drop without commit the tx rolls back. - tx: Transaction<'static, Postgres>, + tx: AdmittedTx, } impl MembershipRemovalFence { @@ -950,19 +952,27 @@ impl MembershipRemovalFence { /// lock (the transaction is implicitly rolled back if not committed). To /// durably write the workflow-disable and release the lock in one step, call /// [`Self::commit_disabling_workflows`]. +/// +/// Write admission is taken up front, before the membership lock, so the +/// lock order matches [`add_member`]. Taking it lazily in +/// [`Self::commit_disabling_workflows`] would invert that order. As a result, +/// a community that leaves `active` between the kick commit and this call +/// refuses the fence, and the caller skips its live side effects. That is +/// intended: the membership cache is already invalidated and fan-out +/// re-checks membership before delivery. pub async fn membership_removal_fence( pool: &PgPool, community_id: CommunityId, channel_id: Uuid, pubkey: &[u8], ) -> Result { - let connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community_id, crate::observability::WriterOperation::Authorization, ) .await?; - let mut tx = sqlx::Transaction::begin(connection, None).await?; - acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; + acquire_channel_membership_lock(tx.conn(), community_id, channel_id).await?; let row = sqlx::query( "SELECT removed_at FROM channel_members \ @@ -971,7 +981,7 @@ pub async fn membership_removal_fence( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; // Row absent → never joined or hard-deleted; treat as removed (safe to diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index 24075359587..e084d8ed176 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -563,6 +563,12 @@ impl Db { return Ok(CreateCommunityWithOwnerResult::LimitReached); } + // The community row was just created in this transaction. Admit + // its first tenant row before inserting the initial owner. + self.deletion_store() + .guard_transaction(&mut tx, CommunityId::from_uuid(id)) + .await?; + sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES ($1, $2, 'owner', NULL)", ) diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index eef0ac8b0b9..d673c5d8d7e 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -2955,27 +2955,14 @@ impl DeletionStore { } /// Take the shared community deletion lock inside an existing transaction. + /// + /// See [`guard_community_write`]. pub async fn guard_transaction( &self, tx: &mut Transaction<'_, Postgres>, community: CommunityId, ) -> Result<()> { - lock_community_deletion_shared(tx, community).await?; - let state: Option = sqlx::query_scalar( - "SELECT deletion_state FROM communities WHERE id = $1 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .fetch_optional(&mut **tx) - .await?; - match state.as_deref() { - Some("active") => Ok(()), - Some(other) => Err(DbError::AccessDenied(format!( - "community {community} is write-fenced ({other})" - ))), - None => Err(DbError::AccessDenied(format!( - "community {community} is missing or tombstoned" - ))), - } + guard_community_write(tx, community).await } /// Take the shared community deletion lock inside an existing transaction @@ -3342,6 +3329,36 @@ async fn lock_community_deletion( Ok(()) } +/// Admit a community write inside an existing transaction. +/// +/// Takes the shared community deletion lock, then requires the community to +/// be `active` and not tombstoned. Refusal is [`DbError::AccessDenied`]; +/// a lock timeout surfaces as the underlying `55P03` database error. The +/// lock is transaction-scoped, so commit, rollback, or a dropped transaction +/// releases it. Crates outside `buzz-db` that own their own transactions +/// (the audit chain) admit through this function. +pub async fn guard_community_write( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, +) -> Result<()> { + lock_community_deletion_shared(tx, community).await?; + let state: Option = sqlx::query_scalar( + "SELECT deletion_state FROM communities WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .fetch_optional(&mut **tx) + .await?; + match state.as_deref() { + Some("active") => Ok(()), + Some(other) => Err(DbError::AccessDenied(format!( + "community {community} is write-fenced ({other})" + ))), + None => Err(DbError::AccessDenied(format!( + "community {community} is missing or tombstoned" + ))), + } +} + pub(crate) async fn lock_community_deletion_shared( tx: &mut Transaction<'_, Postgres>, community: CommunityId, diff --git a/crates/buzz-db/src/store/dm.rs b/crates/buzz-db/src/store/dm.rs index 89e4a0e5221..8d51866e852 100644 --- a/crates/buzz-db/src/store/dm.rs +++ b/crates/buzz-db/src/store/dm.rs @@ -127,7 +127,12 @@ pub async fn create_dm( let hash = compute_participant_hash(participants); - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; // Idempotency check inside the transaction. let existing = sqlx::query( @@ -148,7 +153,7 @@ pub async fn create_dm( ) .bind(community_id.as_uuid()) .bind(hash.as_slice()) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; if let Some(row) = existing { @@ -177,7 +182,7 @@ pub async fn create_dm( .bind(&name) .bind(created_by) .bind(hash.as_slice()) - .execute(&mut *tx) + .execute(tx.conn()) .await?; // Add all participants as members with role='member'. @@ -196,7 +201,7 @@ pub async fn create_dm( .bind(id) .bind(*pk) .bind(created_by) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } @@ -213,7 +218,7 @@ pub async fn create_dm( ) .bind(community_id.as_uuid()) .bind(id) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; let record = row_to_channel_record(row)?; @@ -402,6 +407,12 @@ pub async fn hide_dm( channel_id: Uuid, pubkey: &[u8], ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( r#" UPDATE channel_members @@ -412,7 +423,7 @@ pub async fn hide_dm( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .execute(pool) + .execute(tx.conn()) .await?; if result.rows_affected() == 0 { @@ -421,6 +432,7 @@ pub async fn hide_dm( ))); } + tx.commit().await?; Ok(()) } @@ -434,6 +446,12 @@ pub async fn unhide_dm( channel_id: Uuid, pubkey: &[u8], ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; sqlx::query( r#" UPDATE channel_members @@ -444,9 +462,10 @@ pub async fn unhide_dm( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(()) } diff --git a/crates/buzz-db/src/store/git_repo.rs b/crates/buzz-db/src/store/git_repo.rs index fac10c3f610..164b055a287 100644 --- a/crates/buzz-db/src/store/git_repo.rs +++ b/crates/buzz-db/src/store/git_repo.rs @@ -90,8 +90,9 @@ pub async fn reserve_repo_name( repo_id: &str, owner_pubkey: &str, ) -> Result { - let mut connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community, crate::observability::WriterOperation::Authorization, ) .await?; @@ -108,10 +109,11 @@ pub async fn reserve_repo_name( .bind(community.as_uuid()) .bind(repo_id) .bind(owner_pubkey) - .fetch_optional(&mut *connection) + .fetch_optional(tx.conn()) .await?; if inserted.is_some() { + tx.commit().await?; return Ok(ReserveOutcome::Reserved); } @@ -123,9 +125,9 @@ pub async fn reserve_repo_name( ) .bind(community.as_uuid()) .bind(repo_id) - .fetch_optional(&mut *connection) + .fetch_optional(tx.conn()) .await?; - + tx.commit().await?; match existing { Some(row) => { let holder: String = row @@ -183,8 +185,9 @@ pub async fn release_repo_name( repo_id: &str, owner_pubkey: &str, ) -> Result { - let mut connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community, crate::observability::WriterOperation::Authorization, ) .await?; @@ -195,8 +198,9 @@ pub async fn release_repo_name( .bind(community.as_uuid()) .bind(repo_id) .bind(owner_pubkey) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected()) } diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 91c4a6e282d..efa46f17485 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -78,3 +78,6 @@ mod artifact_query; #[cfg(test)] mod artifact_postgres_tests; + +#[cfg(test)] +mod admission_postgres_tests; diff --git a/crates/buzz-db/src/store/moderation.rs b/crates/buzz-db/src/store/moderation.rs index cdcc339fe5b..00bcb3af879 100644 --- a/crates/buzz-db/src/store/moderation.rs +++ b/crates/buzz-db/src/store/moderation.rs @@ -188,6 +188,12 @@ pub async fn insert_report( community: CommunityId, report: NewReport<'_>, ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; let (target_kind, target_event_id, target_pubkey, target_blob_sha256) = match &report.target { ReportTarget::Event(id) => ("event", Some(id.as_slice()), None, None), ReportTarget::Pubkey(pubkey) => ("pubkey", None, Some(pubkey.as_slice()), None), @@ -223,8 +229,9 @@ pub async fn insert_report( .bind(report.report_type) .bind(report.note) .bind(initial_status) - .fetch_one(pool) + .fetch_one(tx.conn()) .await?; + tx.commit().await?; Ok(row.try_get("id")?) } @@ -313,6 +320,12 @@ pub async fn resolve_report( resolved_by: &[u8], action_id: Option, ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( r#" UPDATE moderation_reports @@ -325,8 +338,9 @@ pub async fn resolve_report( .bind(status) .bind(resolved_by) .bind(action_id) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -340,6 +354,12 @@ pub async fn ban_member( reason: Option<&str>, expires_at: Option>, ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; sqlx::query( r#" INSERT INTO community_bans ( @@ -358,8 +378,9 @@ pub async fn ban_member( .bind(expires_at) .bind(reason) .bind(actor) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(()) } @@ -371,6 +392,12 @@ pub async fn unban_member( pubkey: &[u8], actor: &[u8], ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( r#" UPDATE community_bans @@ -383,8 +410,9 @@ pub async fn unban_member( .bind(community.as_uuid()) .bind(pubkey) .bind(actor) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -404,7 +432,12 @@ pub async fn unban_member_with_audit( actor: &[u8], actor_authority: &str, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( r#" @@ -418,7 +451,7 @@ pub async fn unban_member_with_audit( .bind(community.as_uuid()) .bind(pubkey) .bind(actor) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if result.rows_affected() == 0 { @@ -438,7 +471,7 @@ pub async fn unban_member_with_audit( .bind(actor) .bind(pubkey) .bind(actor_authority) - .execute(&mut *tx) + .execute(tx.conn()) .await?; tx.commit().await?; @@ -456,7 +489,12 @@ pub async fn untimeout_member_with_audit( actor: &[u8], actor_authority: &str, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( r#" @@ -469,7 +507,7 @@ pub async fn untimeout_member_with_audit( .bind(community.as_uuid()) .bind(pubkey) .bind(actor) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if result.rows_affected() == 0 { @@ -488,7 +526,7 @@ pub async fn untimeout_member_with_audit( .bind(actor) .bind(pubkey) .bind(actor_authority) - .execute(&mut *tx) + .execute(tx.conn()) .await?; tx.commit().await?; @@ -504,6 +542,12 @@ pub async fn timeout_member( muted_until: DateTime, reason: Option<&str>, ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; sqlx::query( r#" INSERT INTO community_bans ( @@ -521,8 +565,9 @@ pub async fn timeout_member( .bind(muted_until) .bind(reason) .bind(actor) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(()) } @@ -534,6 +579,12 @@ pub async fn untimeout_member( pubkey: &[u8], actor: &[u8], ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( r#" UPDATE community_bans @@ -545,8 +596,9 @@ pub async fn untimeout_member( .bind(community.as_uuid()) .bind(pubkey) .bind(actor) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -704,6 +756,12 @@ pub async fn insert_action( community: CommunityId, action: NewAction<'_>, ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( r#" INSERT INTO moderation_actions ( @@ -725,8 +783,9 @@ pub async fn insert_action( .bind(action.private_reason) .bind(action.matched_principal) .bind(action.actor_authority.unwrap_or("community")) - .fetch_one(pool) + .fetch_one(tx.conn()) .await?; + tx.commit().await?; Ok(row.try_get("id")?) } diff --git a/crates/buzz-db/src/store/personal_read/postgres_tests.rs b/crates/buzz-db/src/store/personal_read/postgres_tests.rs index 136eb89148d..cba0d992a47 100644 --- a/crates/buzz-db/src/store/personal_read/postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/postgres_tests.rs @@ -258,7 +258,13 @@ async fn personal_read_intent_does_not_lock_shared_conversation_rows() { .execute(&pool) .await .unwrap(); - let mut held = pool.begin().await.unwrap(); + let mut held = crate::begin_community_write_transaction( + &pool, + community, + crate::observability::WriterOperation::EventWrite, + ) + .await + .unwrap(); super::writes::lock_account(&mut held, community, &actor.public_key().to_bytes()) .await .unwrap(); diff --git a/crates/buzz-db/src/store/personal_read/writes.rs b/crates/buzz-db/src/store/personal_read/writes.rs index 9d4c4a6586c..77df951f595 100644 --- a/crates/buzz-db/src/store/personal_read/writes.rs +++ b/crates/buzz-db/src/store/personal_read/writes.rs @@ -1,10 +1,10 @@ use super::model::*; use buzz_core::CommunityId; use chrono::{DateTime, Utc}; -use sqlx::{Acquire, PgConnection, Row}; +use sqlx::{PgConnection, Row}; use uuid::Uuid; -use crate::{observability, Db, Result}; +use crate::{observability, AdmittedTx, Db, Result}; pub(super) fn event_id(value: &str) -> Option> { if value.len() != 64 || value.bytes().any(|b| !b.is_ascii_hexdigit()) { @@ -26,7 +26,7 @@ pub(super) async fn deadlines(conn: &mut PgConnection) -> Result<()> { /// Start the account if this is the actor's first read intent, then serialize /// private frontier writes, never shared conversation rows. pub(super) async fn lock_account( - conn: &mut PgConnection, + tx: &mut AdmittedTx, community: CommunityId, actor: &[u8], ) -> Result<()> { @@ -37,14 +37,14 @@ pub(super) async fn lock_account( ) .bind(community.as_uuid()) .bind(actor) - .execute(&mut *conn) + .execute(tx.conn()) .await?; sqlx::query( "SELECT actor FROM personal_read_accounts WHERE community_id=$1 AND actor=$2 FOR UPDATE", ) .bind(community.as_uuid()) .bind(actor) - .fetch_one(conn) + .fetch_one(tx.conn()) .await?; Ok(()) } @@ -161,7 +161,7 @@ pub(super) async fn valid_target( /// Advance a monotone frontier. The anchor ID follows the greatest arrival; /// an equal arrival keeps the existing anchor. async fn frontier( - conn: &mut PgConnection, + tx: &mut AdmittedTx, community: CommunityId, actor: &[u8], target: &ReadTarget, @@ -179,12 +179,12 @@ async fn frontier( THEN EXCLUDED.through_message_id ELSE personal_read_frontiers.through_message_id END", ).bind(community.as_uuid()).bind(actor).bind(target.channel_id).bind(root) .bind(msg.received_at).bind(&msg.id) - .execute(&mut *conn).await?; + .execute(tx.conn()).await?; Ok(()) } pub(super) async fn apply( - conn: &mut PgConnection, + tx: &mut AdmittedTx, community: CommunityId, actor: &[u8], intent: &ReadIntent, @@ -194,11 +194,11 @@ pub(super) async fn apply( let Some(id) = event_id(message_id) else { return Ok(IntentOutcome::Invalid); }; - let Some(root) = valid_target(conn, community, actor, target).await? else { + let Some(root) = valid_target(tx.conn(), community, actor, target).await? else { return Ok(IntentOutcome::Blocked); }; let Some(msg) = message( - conn, + tx.conn(), community, target.channel_id, &id, @@ -214,7 +214,7 @@ pub(super) async fn apply( { return Ok(IntentOutcome::Blocked); } - frontier(conn, community, actor, target, &root, &msg).await?; + frontier(tx, community, actor, target, &root, &msg).await?; } } Ok(IntentOutcome::Applied) @@ -230,11 +230,13 @@ impl Db { actor: &nostr::PublicKey, intent: &ReadIntent, ) -> Result { - let mut conn = - observability::acquire_writer(&self.pool, observability::WriterOperation::EventWrite) - .await?; - let mut tx = conn.begin().await?; - deadlines(&mut tx).await?; + let mut tx = crate::begin_community_write_transaction( + &self.pool, + community, + observability::WriterOperation::EventWrite, + ) + .await?; + deadlines(tx.conn()).await?; let actor = actor.to_bytes(); lock_account(&mut tx, community, &actor).await?; let outcome = apply(&mut tx, community, &actor, intent).await?; diff --git a/crates/buzz-db/src/store/push.rs b/crates/buzz-db/src/store/push.rs index 1a95aecf708..4269c3f7e6b 100644 --- a/crates/buzz-db/src/store/push.rs +++ b/crates/buzz-db/src/store/push.rs @@ -21,14 +21,6 @@ async fn acquire_operation_connection( Ok(crate::observability::acquire_writer(pool, operation).await?) } -async fn begin_operation_transaction( - pool: &PgPool, - operation: crate::observability::WriterOperation, -) -> Result> { - let connection = acquire_operation_connection(pool, operation).await?; - Ok(sqlx::Transaction::begin(connection, None).await?) -} - /// Namespace for the per-community push-gate advisory lock. The post-commit /// enqueue worker takes it SHARED, while lease transitions take it EXCLUSIVE. /// The worker checks receipt-time eligibility after obtaining the lock. Legacy @@ -485,11 +477,14 @@ async fn replace_lease( // lease" to "eligible"; serialize it against the event producer's shared // gate lock (gate → lease row, matching accept_lease_event's global order). // Revocations (is_active = false) never make eligibility true and skip it. - let mut tx = - begin_operation_transaction(pool, crate::observability::WriterOperation::EventWrite) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::EventWrite, + ) + .await?; if is_active { - acquire_push_gate_lock(&mut tx, community).await?; + acquire_push_gate_lock(tx.conn(), community).await?; } // The conflict predicate is the acceptance state machine. Keeping both @@ -537,7 +532,7 @@ async fn replace_lease( .bind(max_class) .bind(subscriptions) .bind(version.expires_at) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; if accepted.is_some() { @@ -553,7 +548,7 @@ async fn replace_lease( .bind(community.as_uuid()) .bind(author) .bind(installation_id) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; tx.commit().await?; let current_created_at: i64 = current.try_get("source_created_at")?; @@ -635,9 +630,12 @@ pub async fn enqueue_wakes( if requests.is_empty() { return Ok(Vec::new()); } - let mut tx = - begin_operation_transaction(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; // 1. Lock and read the current lease row for every distinct requested // (author, installation), in deterministic order. @@ -666,7 +664,7 @@ pub async fn enqueue_wakes( .bind(community.as_uuid()) .bind(&lock_authors) .bind(&lock_installs) - .fetch_all(&mut *tx) + .fetch_all(tx.conn()) .await?; let mut leases: std::collections::HashMap<(Vec, String), (i64, Vec)> = std::collections::HashMap::with_capacity(lease_rows.len()); @@ -739,7 +737,7 @@ pub async fn enqueue_wakes( .bind(&ins_events) .bind(&ins_classes) .bind(&ins_expires) - .fetch_all(&mut *tx) + .fetch_all(tx.conn()) .await?; for row in inserted { let key: (Vec, Vec) = (row.try_get("endpoint_hash")?, row.try_get("event_id")?); @@ -762,7 +760,7 @@ pub async fn enqueue_wakes( .bind(community.as_uuid()) .bind(&ins_endpoints) .bind(&ins_events) - .fetch_all(&mut *tx) + .fetch_all(tx.conn()) .await?; for row in dup_rows { let key: (Vec, Vec) = @@ -932,9 +930,12 @@ where // recoverable after their claim lease expires. let gone: Vec> = attempts.into_keys().collect(); if !gone.is_empty() { - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; sqlx::query( "DELETE FROM push_match_queue \ WHERE community_id=$1 AND claim_id=$2 AND state='matching' AND event_id = ANY($3)", @@ -942,8 +943,9 @@ where .bind(community.as_uuid()) .bind(claim_id) .bind(&gone) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; } if jobs.is_empty() { return Ok(None); @@ -1014,19 +1016,24 @@ pub async fn complete_match_batch( if event_ids.is_empty() { return Ok(0); } - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; - Ok(sqlx::query( + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; + let affected = sqlx::query( "DELETE FROM push_match_queue \ WHERE community_id=$1 AND claim_id=$2 AND state='matching' AND event_id = ANY($3)", ) .bind(community.as_uuid()) .bind(claim_id) .bind(event_ids) - .execute(&mut *connection) + .execute(tx.conn()) .await? - .rows_affected()) + .rows_affected(); + tx.commit().await?; + Ok(affected) } /// Release fenced matcher claims from one batch for retry at the supplied @@ -1041,10 +1048,13 @@ pub async fn retry_match_batch( if event_ids.is_empty() { return Ok(0); } - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; - Ok(sqlx::query( + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; + let affected = sqlx::query( "UPDATE push_match_queue \ SET state='pending', claim_id=NULL, lease_until=NULL, next_attempt_at=$4 \ WHERE community_id=$1 AND claim_id=$2 AND state='matching' AND event_id = ANY($3)", @@ -1053,9 +1063,11 @@ pub async fn retry_match_batch( .bind(claim_id) .bind(event_ids) .bind(next) - .execute(&mut *connection) + .execute(tx.conn()) .await? - .rows_affected()) + .rows_affected(); + tx.commit().await?; + Ok(affected) } /// Claim due jobs for one community, recovering expired worker leases. @@ -1069,9 +1081,12 @@ pub async fn claim_due_wakes( lease_until: DateTime, ) -> Result> { let claim_id = Uuid::new_v4(); - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let rows = sqlx::query( r#" WITH candidates AS ( @@ -1119,8 +1134,9 @@ pub async fn claim_due_wakes( .bind(limit) .bind(claim_id) .bind(lease_until) - .fetch_all(&mut *connection) + .fetch_all(tx.conn()) .await?; + tx.commit().await?; rows.into_iter().map(row_to_claimed_wake).collect() } @@ -1187,9 +1203,12 @@ pub async fn complete_wake( id: Uuid, claim_id: Uuid, ) -> Result { - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let result = sqlx::query( "UPDATE push_wake_outbox \ SET state = 'delivered', claim_id = NULL, lease_until = NULL \ @@ -1198,8 +1217,9 @@ pub async fn complete_wake( .bind(community.as_uuid()) .bind(id) .bind(claim_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() == 1) } @@ -1211,9 +1231,12 @@ pub async fn retry_wake( claim_id: Uuid, next_attempt_at: DateTime, ) -> Result { - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let result = sqlx::query( "UPDATE push_wake_outbox \ SET state = 'pending', next_attempt_at = $4, claim_id = NULL, lease_until = NULL \ @@ -1223,8 +1246,9 @@ pub async fn retry_wake( .bind(id) .bind(claim_id) .bind(next_attempt_at) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() == 1) } @@ -1235,9 +1259,12 @@ pub async fn fail_wake( id: Uuid, claim_id: Uuid, ) -> Result { - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let result = sqlx::query( "UPDATE push_wake_outbox \ SET state = 'failed', claim_id = NULL, lease_until = NULL \ @@ -1246,8 +1273,9 @@ pub async fn fail_wake( .bind(community.as_uuid()) .bind(id) .bind(claim_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() == 1) } @@ -1262,9 +1290,12 @@ pub async fn disable_endpoint_generation( installation_id: &str, generation: i64, ) -> Result { - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let result = sqlx::query( "UPDATE push_leases SET endpoint_enabled = false, updated_at = now() \ WHERE community_id = $1 AND author = $2 AND installation_id = $3 \ @@ -1274,8 +1305,9 @@ pub async fn disable_endpoint_generation( .bind(author) .bind(installation_id) .bind(generation) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() == 1) } @@ -1289,9 +1321,12 @@ pub async fn prune_wake_outbox( community: CommunityId, before: DateTime, ) -> Result { - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let result = sqlx::query( "DELETE FROM push_wake_outbox o \ WHERE o.community_id = $1 AND o.created_at < $2 \ @@ -1304,8 +1339,9 @@ pub async fn prune_wake_outbox( ) .bind(community.as_uuid()) .bind(before) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected()) } diff --git a/crates/buzz-db/src/store/relay_admin_actions.rs b/crates/buzz-db/src/store/relay_admin_actions.rs index 0a876beb2ae..e1cf4ca4c54 100644 --- a/crates/buzz-db/src/store/relay_admin_actions.rs +++ b/crates/buzz-db/src/store/relay_admin_actions.rs @@ -143,7 +143,12 @@ pub async fn resolve_report_decision_atomic( channel_id: Option, reason: Option<&str>, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; // CAS: open → terminal. The update count tells us whether the report was open. let updated = sqlx::query( @@ -157,7 +162,7 @@ pub async fn resolve_report_decision_atomic( .bind(report_id) .bind(terminal_status) .bind(actor_pubkey) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if updated.rows_affected() == 0 { @@ -182,7 +187,7 @@ pub async fn resolve_report_decision_atomic( .bind(channel_id) .bind(reason) .bind(actor_authority) - .execute(&mut *tx) + .execute(tx.conn()) .await?; tx.commit().await?; @@ -221,7 +226,12 @@ pub async fn claim_report( target_event_id: Option<&[u8]>, channel_id: Option, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; // Lock the report row to serialize concurrent claims on the same report. let report_row = sqlx::query( @@ -234,7 +244,7 @@ pub async fn claim_report( ) .bind(community_id.as_uuid()) .bind(report_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; let Some(report_row) = report_row else { @@ -258,7 +268,7 @@ pub async fn claim_report( .bind(community_id.as_uuid()) .bind(report_id) .bind(request_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; if let Some(row) = existing { @@ -296,7 +306,7 @@ pub async fn claim_report( .bind(timeout_until) .bind(target_pubkey) .bind(channel_id) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; let action_id: Uuid = action_row.try_get("id")?; @@ -318,7 +328,7 @@ pub async fn claim_report( .bind(channel_id) .bind(reason) .bind(actor_authority) - .execute(&mut *tx) + .execute(tx.conn()) .await?; // CAS: set report to processing with active_action_id. @@ -332,7 +342,7 @@ pub async fn claim_report( .bind(community_id.as_uuid()) .bind(report_id) .bind(action_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if updated.rows_affected() == 0 { @@ -457,7 +467,12 @@ pub async fn execute_ban_with_marker( actor_pubkey: &[u8], reason: Option<&str>, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; // Verify lease ownership first — abort without touching domain rows if the // lease is already gone. This prevents the commit entirely on a stale worker. @@ -474,7 +489,7 @@ pub async fn execute_ban_with_marker( ) .bind(action_id) .bind(lease_token) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; if !owned { @@ -488,7 +503,7 @@ pub async fn execute_ban_with_marker( // under the lock, not before it. sqlx::query("SELECT id FROM relay_admin_actions WHERE id = $1 FOR UPDATE") .bind(action_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; sqlx::query( @@ -505,7 +520,7 @@ pub async fn execute_ban_with_marker( .bind(target_pubkey) .bind(actor_pubkey) .bind(reason) - .execute(&mut *tx) + .execute(tx.conn()) .await?; let marker = sqlx::query( @@ -521,7 +536,7 @@ pub async fn execute_ban_with_marker( ) .bind(action_id) .bind(lease_token) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if marker.rows_affected() == 0 { @@ -548,7 +563,12 @@ pub async fn execute_timeout_with_marker( until: DateTime, reason: Option<&str>, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; let owned: bool = sqlx::query_scalar( r#" @@ -563,7 +583,7 @@ pub async fn execute_timeout_with_marker( ) .bind(action_id) .bind(lease_token) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; if !owned { @@ -574,7 +594,7 @@ pub async fn execute_timeout_with_marker( // Acquire the action-row lock before evaluating the wall-clock expiry check. sqlx::query("SELECT id FROM relay_admin_actions WHERE id = $1 FOR UPDATE") .bind(action_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; sqlx::query( @@ -593,7 +613,7 @@ pub async fn execute_timeout_with_marker( .bind(until) .bind(actor_pubkey) .bind(reason) - .execute(&mut *tx) + .execute(tx.conn()) .await?; let marker = sqlx::query( @@ -609,7 +629,7 @@ pub async fn execute_timeout_with_marker( ) .bind(action_id) .bind(lease_token) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if marker.rows_affected() == 0 { @@ -648,7 +668,12 @@ pub async fn execute_kick_with_marker( target_pubkey: &[u8], actor_pubkey: &[u8], ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; let owned: bool = sqlx::query_scalar( r#" @@ -663,7 +688,7 @@ pub async fn execute_kick_with_marker( ) .bind(action_id) .bind(lease_token) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; if !owned { @@ -676,7 +701,7 @@ pub async fn execute_kick_with_marker( // Acquire the action-row lock before evaluating the wall-clock expiry check. sqlx::query("SELECT id FROM relay_admin_actions WHERE id = $1 FOR UPDATE") .bind(action_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; let kick = sqlx::query( @@ -690,7 +715,7 @@ pub async fn execute_kick_with_marker( .bind(community_id.as_uuid()) .bind(channel_id) .bind(target_pubkey) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if kick.rows_affected() == 0 { @@ -711,7 +736,7 @@ pub async fn execute_kick_with_marker( ) .bind(action_id) .bind(lease_token) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if marker.rows_affected() == 0 { @@ -855,7 +880,12 @@ pub async fn finalize_success( reason: Option<&str>, timeout_until: Option>, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; // Require step_marker = 'mutation_committed' to prevent premature finalization. let updated_action = sqlx::query( @@ -866,7 +896,7 @@ pub async fn finalize_success( "#, ) .bind(action_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if updated_action.rows_affected() == 0 { @@ -893,7 +923,7 @@ pub async fn finalize_success( .bind(terminal_status) .bind(actor_pubkey) .bind(action_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if updated_report.rows_affected() == 0 { @@ -930,7 +960,7 @@ pub async fn finalize_success( .bind(action_id) .bind(payload) .bind(format!("tombstone:{action_str}")) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } } @@ -953,7 +983,7 @@ pub async fn finalize_success( .bind(action_id) .bind(payload) .bind(format!("system_message:{action_str}")) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } } @@ -975,7 +1005,7 @@ pub async fn finalize_success( .bind(action_id) .bind(notice_payload) .bind(format!("reporter_notice:{action_str}")) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } @@ -1024,7 +1054,7 @@ pub async fn finalize_success( .bind(action_id) .bind(affected_payload) .bind(format!("affected_user_notice:{action_str}")) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } @@ -1124,7 +1154,12 @@ pub async fn cancel_action( report_id: Uuid, cancelled_by: &[u8], ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; // Cancel only a pre-mutation `failed` action that BELONGS to the path // report and community. Fencing on report_id + report_community_id is what @@ -1146,7 +1181,7 @@ pub async fn cancel_action( .bind(report_id) .bind(community_id.as_uuid()) .bind(cancelled_by) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if updated.rows_affected() != 1 { @@ -1171,7 +1206,7 @@ pub async fn cancel_action( .bind(community_id.as_uuid()) .bind(report_id) .bind(action_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if reopened.rows_affected() != 1 { @@ -1219,7 +1254,12 @@ pub async fn reopen_report( actor_role: &str, reason: Option<&str>, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; // Lock the report row to serialize concurrent reopen/resolve on it. let report_row = sqlx::query( @@ -1232,7 +1272,7 @@ pub async fn reopen_report( ) .bind(community_id.as_uuid()) .bind(report_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; let Some(report_row) = report_row else { @@ -1252,7 +1292,7 @@ pub async fn reopen_report( .bind(community_id.as_uuid()) .bind(report_id) .bind(request_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; if existing.is_some() { @@ -1280,7 +1320,7 @@ pub async fn reopen_report( ) .bind(community_id.as_uuid()) .bind(report_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; // Durable audit row. Inserted as 'succeeded' so the recovery worker never @@ -1299,7 +1339,7 @@ pub async fn reopen_report( .bind(actor_pubkey) .bind(actor_role) .bind(reason) - .execute(&mut *tx) + .execute(tx.conn()) .await?; tx.commit().await?; @@ -1643,6 +1683,12 @@ pub async fn deploy_kick_member( target_pubkey: &[u8], actor_pubkey: &[u8], ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; // Use a direct UPDATE to avoid the tenant ownership check in channel::remove_member. // This is the deployment-authority primitive: no actor role check. let result = sqlx::query( @@ -1656,8 +1702,9 @@ pub async fn deploy_kick_member( .bind(community_id.as_uuid()) .bind(channel_id) .bind(target_pubkey) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; if result.rows_affected() > 0 { Ok(KickResult::Removed) @@ -1765,7 +1812,12 @@ pub async fn claim_direct_action( pool: &PgPool, input: &DirectActionInput<'_>, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + input.community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; let inserted = sqlx::query( r#" INSERT INTO relay_admin_actions ( @@ -1788,7 +1840,7 @@ pub async fn claim_direct_action( .bind(input.target_pubkey) .bind(input.target_event_id) .bind(input.channel_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; let Some(row) = inserted else { @@ -1818,7 +1870,7 @@ pub async fn claim_direct_action( .bind(input.channel_id) .bind(input.reason) .bind(input.actor_authority) - .execute(&mut *tx) + .execute(tx.conn()) .await?; let rec = row_to_action(row)?; tx.commit().await?; diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 4bb48e121ca..64876961511 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -116,15 +116,12 @@ pub async fn mint_relay_invite( // community-scoped database write. The trigger remains the final backstop, // but this typed guard keeps a quiescing community from surfacing as an // opaque SQLSTATE/HTTP 500 at the API boundary. - let connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community, crate::observability::WriterOperation::Authorization, ) .await?; - let mut tx = sqlx::Transaction::begin(connection, None).await?; - crate::deletion::DeletionStore::new(pool.clone()) - .guard_transaction(&mut tx, community) - .await?; let row = sqlx::query( "INSERT INTO relay_invites (community_id, token_hash, max_uses, expires_at, created_by) \ VALUES ($1, $2, $3, $4, $5) \ @@ -135,7 +132,7 @@ pub async fn mint_relay_invite( .bind(max_uses) .bind(expires_at) .bind(created_by) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; tx.commit().await?; @@ -226,12 +223,12 @@ pub async fn claim_relay_invite( claimer_pubkey: &str, policy_version: Option<&str>, ) -> Result { - let connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community, crate::observability::WriterOperation::Authorization, ) .await?; - let mut tx = sqlx::Transaction::begin(connection, None).await?; // 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn. let row = sqlx::query( @@ -242,7 +239,7 @@ pub async fn claim_relay_invite( ) .bind(community.as_uuid()) .bind(token_hash) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; // 3. No matching invite. @@ -279,7 +276,7 @@ pub async fn claim_relay_invite( sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(claimer_pubkey) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; if existing.is_some() { @@ -292,7 +289,7 @@ pub async fn claim_relay_invite( .bind(community.as_uuid()) .bind(claimer_pubkey) .bind(version) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } tx.commit().await?; @@ -334,7 +331,7 @@ pub async fn claim_relay_invite( ) .bind(community.as_uuid()) .bind(claimer_pubkey) - .execute(&mut *tx) + .execute(tx.conn()) .await? .rows_affected() > 0; @@ -349,7 +346,7 @@ pub async fn claim_relay_invite( .bind(community.as_uuid()) .bind(claimer_pubkey) .bind(version) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } @@ -374,7 +371,7 @@ pub async fn claim_relay_invite( .bind(new_use_count) .bind(community.as_uuid()) .bind(invite_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; // 11. Commit. diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index 742e9173f2a..6b21c7ce01a 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -150,8 +150,12 @@ pub async fn add_relay_member( role: &str, added_by: Option<&str>, ) -> Result { - let mut connection = - observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ VALUES ($1, $2, $3, $4) ON CONFLICT (community_id, pubkey) DO NOTHING", @@ -160,8 +164,9 @@ pub async fn add_relay_member( .bind(pubkey) .bind(role) .bind(added_by) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -177,9 +182,12 @@ pub async fn claim_relay_membership( role: &str, policy_version: Option<&str>, ) -> Result { - let connection = - observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; - let mut tx = sqlx::Transaction::begin(connection, None).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + observability::WriterOperation::Authorization, + ) + .await?; let inserted = sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ VALUES ($1, $2, $3, 'invite') \ @@ -188,7 +196,7 @@ pub async fn claim_relay_membership( .bind(community.as_uuid()) .bind(pubkey) .bind(role) - .execute(&mut *tx) + .execute(tx.conn()) .await? .rows_affected() > 0; @@ -201,7 +209,7 @@ pub async fn claim_relay_membership( .bind(community.as_uuid()) .bind(pubkey) .bind(version) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } @@ -243,20 +251,21 @@ pub enum RemoveResult { RoleMismatch, } -/// Deletes a membership and its join-policy acceptances in one transaction. +/// Deletes a membership and its join-policy acceptances inside the caller's +/// admitted transaction. /// /// With `expected_role`, only a member holding exactly that role is removed; /// without it, any non-owner is. The member row is locked under that predicate /// first, so a concurrent acceptance insert (which must lock the member row for /// its foreign key) either commits before the child delete sees it or fails. -/// Children go before the parent so no foreign-key action is needed. +/// Children go before the parent so no foreign-key action is needed. The +/// caller commits. async fn delete_member_with_acceptances( - connection: &mut sqlx::PgConnection, - community: CommunityId, + tx: &mut crate::AdmittedTx, pubkey: &str, expected_role: Option<&str>, ) -> Result { - let mut tx = sqlx::Connection::begin(connection).await?; + let community = tx.community(); let locked = sqlx::query( "SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2 \ AND CASE WHEN $3::text IS NULL THEN role <> 'owner' ELSE role = $3 END \ @@ -265,7 +274,7 @@ async fn delete_member_with_acceptances( .bind(community.as_uuid()) .bind(pubkey) .bind(expected_role) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; if locked.is_none() { return Ok(false); @@ -273,14 +282,13 @@ async fn delete_member_with_acceptances( sqlx::query("DELETE FROM join_policy_acceptances WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .execute(&mut *tx) + .execute(tx.conn()) .await?; sqlx::query("DELETE FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .execute(&mut *tx) + .execute(tx.conn()) .await?; - tx.commit().await?; Ok(true) } @@ -293,9 +301,14 @@ pub async fn remove_relay_member( community: CommunityId, pubkey: &str, ) -> Result { - let mut connection = - observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; - if delete_member_with_acceptances(&mut connection, community, pubkey, None).await? { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + observability::WriterOperation::Authorization, + ) + .await?; + if delete_member_with_acceptances(&mut tx, pubkey, None).await? { + tx.commit().await?; return Ok(RemoveResult::Removed); } @@ -304,9 +317,10 @@ pub async fn remove_relay_member( let exists = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .fetch_optional(&mut *connection) + .fetch_optional(tx.conn()) .await?; + tx.commit().await?; if exists.is_some() { Ok(RemoveResult::IsOwner) } else { @@ -332,11 +346,14 @@ pub async fn remove_relay_member_if_role( pubkey: &str, expected_role: &str, ) -> Result { - let mut connection = - observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; - if delete_member_with_acceptances(&mut connection, community, pubkey, Some(expected_role)) - .await? - { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + observability::WriterOperation::Authorization, + ) + .await?; + if delete_member_with_acceptances(&mut tx, pubkey, Some(expected_role)).await? { + tx.commit().await?; return Ok(RemoveResult::Removed); } @@ -345,9 +362,10 @@ pub async fn remove_relay_member_if_role( let row = sqlx::query("SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .fetch_optional(&mut *connection) + .fetch_optional(tx.conn()) .await?; + tx.commit().await?; match row { None => Ok(RemoveResult::NotFound), Some(r) => { @@ -372,8 +390,12 @@ pub async fn update_relay_member_role( pubkey: &str, new_role: &str, ) -> Result { - let mut connection = - observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( "UPDATE relay_members SET role = $1, updated_at = now() \ WHERE community_id = $2 AND pubkey = $3 AND role <> 'owner'", @@ -381,8 +403,9 @@ pub async fn update_relay_member_role( .bind(new_role) .bind(community.as_uuid()) .bind(pubkey) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -476,14 +499,7 @@ async fn lock_owner_mutation_admission( return Ok(OwnerMutationAdmission::Allowed(existing_owners)); } - let deletion_pending: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM community_deletion_requests \ - WHERE community_id = $1 AND stage <> 'aborted')", - ) - .bind(community.as_uuid()) - .fetch_one(&mut **tx) - .await?; - if deletion_pending { + if deletion_pending(tx, community).await? { return Ok(OwnerMutationAdmission::DeletionPending); } @@ -497,6 +513,21 @@ async fn lock_owner_mutation_admission( Ok(OwnerMutationAdmission::Allowed(existing_owners)) } +/// Whether `community` has a non-aborted deletion request. +/// +/// A quiescing or fenced community always has one, so owner mutations that +/// admission refuses report `DeletionPending` rather than a generic lifecycle +/// conflict. +async fn deletion_pending(conn: &mut sqlx::PgConnection, community: CommunityId) -> Result { + Ok(sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM community_deletion_requests \ + WHERE community_id = $1 AND stage <> 'aborted')", + ) + .bind(community.as_uuid()) + .fetch_one(conn) + .await?) +} + async fn bootstrap_owner_with_operation( pool: &PgPool, community: CommunityId, @@ -507,6 +538,16 @@ async fn bootstrap_owner_with_operation( let connection = observability::acquire_writer(pool, operation).await?; let mut tx = sqlx::Transaction::begin(connection, None).await?; + if let Err(error) = crate::deletion::guard_community_write(&mut tx, community).await { + return match error { + DbError::AccessDenied(_) if deletion_pending(&mut tx, community).await? => { + Ok(ProvisionOwnerResult::DeletionPending) + } + DbError::AccessDenied(_) => Ok(ProvisionOwnerResult::LifecycleConflict), + other => Err(other), + }; + } + match lock_owner_mutation_admission(&mut tx, community, &pubkey, OwnerMutationMode::Converge) .await? { @@ -730,6 +771,27 @@ pub async fn transfer_ownership( observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; let mut tx = sqlx::Transaction::begin(connection, None).await?; + if let Err(error) = crate::deletion::guard_community_write(&mut tx, community).await { + return match error { + DbError::AccessDenied(_) if deletion_pending(&mut tx, community).await? => { + Ok(TransferResult::DeletionPending) + } + DbError::AccessDenied(_) => { + let exists: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM communities WHERE id = $1)") + .bind(community.as_uuid()) + .fetch_one(&mut *tx) + .await?; + if exists { + Ok(TransferResult::LifecycleConflict) + } else { + Ok(TransferResult::NoOwner) + } + } + other => Err(other), + }; + } + // 1. Serialize on the transferee so concurrent transfers to the same // recipient cannot both pass the ownership count check. crate::observability::observe_advisory_lock( @@ -825,40 +887,52 @@ pub async fn transfer_ownership( /// /// Converts BYTEA pubkeys to lowercase hex text and inserts them as members of /// `community`. Returns the number of rows inserted, or 0 if: -/// - the `pubkey_allowlist` table doesn't exist, or +/// - the `pubkey_allowlist` table doesn't exist, +/// - `pubkey_allowlist` has no rows for this community, or /// - `relay_members` already has rows for this community (migration ran in a /// prior startup). /// /// The empty-table guard prevents re-adding members that were intentionally /// removed by an admin after the initial backfill. pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> Result { + // The common startup case writes nothing: the legacy table is gone, it + // has no rows for this community, or the community already has members. + // Answer those with plain reads so a startup with nothing to backfill + // never needs write admission, and a quiescing deployment community keeps + // starting as it did before. let mut connection = observability::acquire_writer(pool, observability::WriterOperation::Bootstrap).await?; - // Check if pubkey_allowlist table exists. let exists: bool = sqlx::query_scalar( "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ WHERE table_schema = 'public' AND table_name = 'pubkey_allowlist')", ) .fetch_one(&mut *connection) .await?; - - if !exists { + if !exists || community_has_members(&mut connection, community).await? { return Ok(0); } - - // Only backfill if this community's relay_members is empty — once it has - // rows (from a previous backfill or manual admin commands), we must not - // re-add members that were intentionally removed. - let has_members: bool = - sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM relay_members WHERE community_id = $1)") - .bind(community.as_uuid()) - .fetch_one(&mut *connection) - .await?; - - if has_members { + let has_allowlist_rows: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pubkey_allowlist WHERE community_id = $1)", + ) + .bind(community.as_uuid()) + .fetch_one(&mut *connection) + .await?; + if !has_allowlist_rows { return Ok(0); } + drop(connection); + let mut tx = crate::begin_community_write_transaction( + pool, + community, + observability::WriterOperation::Bootstrap, + ) + .await?; + // Re-check under admission: another pod may have backfilled meanwhile. + if community_has_members(tx.conn(), community).await? { + tx.commit().await?; + return Ok(0); + } let result = sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by, created_at) \ SELECT $1, encode(pubkey, 'hex'), 'member', NULL, added_at \ @@ -867,12 +941,27 @@ pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> R ON CONFLICT (community_id, pubkey) DO NOTHING", ) .bind(community.as_uuid()) - .execute(&mut *connection) + .execute(tx.conn()) .await?; - + tx.commit().await?; Ok(result.rows_affected()) } +/// Whether `community` has any `relay_members` row. Once it does (from a +/// previous backfill or admin commands), the backfill must not re-add members +/// that were intentionally removed. +async fn community_has_members( + conn: &mut sqlx::PgConnection, + community: CommunityId, +) -> Result { + Ok( + sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM relay_members WHERE community_id = $1)") + .bind(community.as_uuid()) + .fetch_one(conn) + .await?, + ) +} + impl Db { /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. /// diff --git a/crates/buzz-db/src/store/thread.rs b/crates/buzz-db/src/store/thread.rs index 5b30f5df83c..719861241c5 100644 --- a/crates/buzz-db/src/store/thread.rs +++ b/crates/buzz-db/src/store/thread.rs @@ -11,23 +11,6 @@ use uuid::Uuid; use buzz_datastore_tracing::datastore_span; -async fn acquire_event_write_connection( - pool: &PgPool, -) -> Result> { - Ok(crate::observability::acquire_writer( - pool, - crate::observability::WriterOperation::EventWrite, - ) - .await?) -} - -async fn begin_event_write_transaction( - pool: &PgPool, -) -> Result> { - let connection = acquire_event_write_connection(pool).await?; - Ok(sqlx::Transaction::begin(connection, None).await?) -} - use buzz_core::CommunityId; use crate::{ @@ -148,7 +131,12 @@ pub async fn insert_thread_metadata( depth: i32, broadcast: bool, ) -> Result<()> { - let mut tx = begin_event_write_transaction(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( r#" @@ -171,7 +159,7 @@ pub async fn insert_thread_metadata( .bind(root_event_created_at) .bind(depth) .bind(broadcast) - .execute(&mut *tx) + .execute(tx.conn()) .await?; // Only bump reply counts if the row was actually inserted (not a duplicate). @@ -197,7 +185,7 @@ pub async fn insert_thread_metadata( .bind(parent_ts) .bind(pid) .bind(channel_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; // Ensure the root also has a row (may differ from parent for nested replies). @@ -219,7 +207,7 @@ pub async fn insert_thread_metadata( .bind(root_ts) .bind(root_id) .bind(channel_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } } @@ -235,7 +223,7 @@ pub async fn insert_thread_metadata( ) .bind(community_id.as_uuid()) .bind(pid) - .execute(&mut *tx) + .execute(tx.conn()) .await?; // Increment root's total descendant count. @@ -249,7 +237,7 @@ pub async fn insert_thread_metadata( ) .bind(community_id.as_uuid()) .bind(root_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } } diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index ec4b897a617..d767b56c756 100644 --- a/crates/buzz-db/src/store/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -57,7 +57,23 @@ async fn ensure_user_with_operation( pubkey: &[u8], operation: crate::observability::WriterOperation, ) -> Result { + // Almost every call finds the user already registered. Answer that with + // one read, so the hot path skips the admission round trips; no write + // happens, so the fence contract is unaffected. let mut connection = crate::observability::acquire_writer(pool, operation).await?; + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM users WHERE community_id = $1 AND pubkey = $2)", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_one(&mut *connection) + .await?; + if exists { + return Ok(false); + } + drop(connection); + + let mut tx = crate::begin_community_write_transaction(pool, community_id, operation).await?; let result = sqlx::query( r#" INSERT INTO users (community_id, pubkey) @@ -67,8 +83,9 @@ async fn ensure_user_with_operation( ) .bind(community_id.as_uuid()) .bind(pubkey) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() == 1) } @@ -178,7 +195,14 @@ pub async fn update_user_profile( } query = query.bind(community_id.as_uuid()); query = query.bind(pubkey); - query.execute(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + query.execute(tx.conn()).await?; + tx.commit().await?; Ok(()) } @@ -329,7 +353,7 @@ async fn set_agent_owner_with_operation( owner_pubkey: &[u8], operation: crate::observability::WriterOperation, ) -> Result { - let mut connection = crate::observability::acquire_writer(pool, operation).await?; + let mut tx = crate::begin_community_write_transaction(pool, community_id, operation).await?; // Conditional UPDATE: only set owner if currently NULL. This makes // "first mint wins" atomic — no TOCTOU race between concurrent mints. let result = sqlx::query( @@ -338,7 +362,7 @@ async fn set_agent_owner_with_operation( .bind(owner_pubkey) .bind(community_id.as_uuid()) .bind(agent_pubkey) - .execute(&mut *connection) + .execute(tx.conn()) .await?; if result.rows_affected() == 0 { @@ -347,7 +371,7 @@ async fn set_agent_owner_with_operation( let exists = sqlx::query(r#"SELECT 1 FROM users WHERE community_id = $1 AND pubkey = $2"#) .bind(community_id.as_uuid()) .bind(agent_pubkey) - .fetch_optional(&mut *connection) + .fetch_optional(tx.conn()) .await?; if exists.is_none() { return Err(crate::error::DbError::NotFound( @@ -355,8 +379,10 @@ async fn set_agent_owner_with_operation( )); } // Row exists but owner already set — return false (not an error). + tx.commit().await?; return Ok(false); } + tx.commit().await?; Ok(true) } @@ -461,14 +487,21 @@ pub async fn set_channel_add_policy( "invalid channel_add_policy: {policy}" ))); } + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( r#"UPDATE users SET channel_add_policy = $1::channel_add_policy WHERE community_id = $2 AND pubkey = $3"#, ) .bind(policy) .bind(community_id.as_uuid()) .bind(pubkey) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; if result.rows_affected() == 0 { return Err(crate::error::DbError::NotFound( "pubkey not found in users table".into(), diff --git a/crates/buzz-db/src/store/workflow.rs b/crates/buzz-db/src/store/workflow.rs index c89bf0cbf06..8f63d85555e 100644 --- a/crates/buzz-db/src/store/workflow.rs +++ b/crates/buzz-db/src/store/workflow.rs @@ -15,7 +15,7 @@ use std::str::FromStr; use chrono::{DateTime, Utc}; use sha2::{Digest, Sha256}; -use sqlx::{PgConnection, PgPool, Row}; +use sqlx::{PgPool, Row}; use uuid::Uuid; use buzz_core::CommunityId; @@ -290,6 +290,12 @@ pub async fn create_workflow( definition_json: &str, definition_hash: &[u8], ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let id = Uuid::new_v4(); sqlx::query( @@ -306,8 +312,9 @@ pub async fn create_workflow( .bind(channel_id) .bind(definition_json) .bind(definition_hash) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(id) } @@ -328,6 +335,12 @@ pub async fn upsert_workflow( definition_json: &str, definition_hash: &[u8], ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let row = sqlx::query( r#" INSERT INTO workflows @@ -350,8 +363,9 @@ pub async fn upsert_workflow( .bind(channel_id) .bind(definition_json) .bind(definition_hash) - .fetch_optional(pool) + .fetch_optional(tx.conn()) .await?; + tx.commit().await?; if row.is_none() { return Err(DbError::AccessDenied(format!( @@ -507,6 +521,12 @@ pub async fn claim_scheduled_workflow_fire( workflow_id: Uuid, scheduled_for: DateTime, ) -> Result> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let row = sqlx::query( r#" INSERT INTO scheduled_workflow_fires (community_id, workflow_id, scheduled_for) @@ -520,8 +540,9 @@ pub async fn claim_scheduled_workflow_fire( .bind(community_id.as_uuid()) .bind(workflow_id) .bind(scheduled_for) - .fetch_optional(pool) + .fetch_optional(tx.conn()) .await?; + tx.commit().await?; row.map(|row| { let community_id: Uuid = row.try_get("community_id")?; @@ -575,6 +596,12 @@ pub async fn attach_scheduled_workflow_run( scheduled_for: DateTime, workflow_run_id: Uuid, ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( r#" UPDATE scheduled_workflow_fires @@ -589,8 +616,9 @@ pub async fn attach_scheduled_workflow_run( .bind(workflow_id) .bind(scheduled_for) .bind(workflow_run_id) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() == 1) } @@ -634,6 +662,12 @@ pub async fn update_workflow( definition_json: &str, definition_hash: &[u8], ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let affected = sqlx::query( r#" UPDATE workflows @@ -646,9 +680,10 @@ pub async fn update_workflow( .bind(definition_hash) .bind(community_id.as_uuid()) .bind(id) - .execute(pool) + .execute(tx.conn()) .await? .rows_affected(); + tx.commit().await?; if affected == 0 { return Err(DbError::NotFound(format!("workflow {id}"))); @@ -666,6 +701,12 @@ pub async fn update_workflow_status( id: Uuid, status: WorkflowStatus, ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let affected = sqlx::query( r#" UPDATE workflows @@ -676,9 +717,10 @@ pub async fn update_workflow_status( .bind(status.to_string()) .bind(community_id.as_uuid()) .bind(id) - .execute(pool) + .execute(tx.conn()) .await? .rows_affected(); + tx.commit().await?; if affected == 0 { return Err(DbError::NotFound(format!("workflow {id}"))); @@ -696,6 +738,12 @@ pub async fn set_workflow_enabled( id: Uuid, enabled: bool, ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let affected = sqlx::query( r#" UPDATE workflows @@ -706,9 +754,10 @@ pub async fn set_workflow_enabled( .bind(enabled) .bind(community_id.as_uuid()) .bind(id) - .execute(pool) + .execute(tx.conn()) .await? .rows_affected(); + tx.commit().await?; if affected == 0 { return Err(DbError::NotFound(format!("workflow {id}"))); @@ -729,6 +778,12 @@ pub async fn disable_workflows_for_owner_in_channel( channel_id: Uuid, owner_pubkey: &[u8], ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let affected = sqlx::query( r#" UPDATE workflows @@ -739,9 +794,10 @@ pub async fn disable_workflows_for_owner_in_channel( .bind(community_id.as_uuid()) .bind(channel_id) .bind(owner_pubkey) - .execute(pool) + .execute(tx.conn()) .await? .rows_affected(); + tx.commit().await?; Ok(affected) } @@ -753,11 +809,16 @@ pub async fn disable_workflows_for_owner_in_channel( /// so the disable executes on the fence's own connection, avoiding a second /// pool acquisition while the fence holds one connection. pub async fn disable_workflows_for_owner_in_channel_on_conn( - conn: &mut PgConnection, + tx: &mut crate::AdmittedTx, community_id: CommunityId, channel_id: Uuid, owner_pubkey: &[u8], ) -> Result { + if tx.community() != community_id { + return Err(DbError::AccessDenied( + "workflow write community mismatch".into(), + )); + } let affected = sqlx::query( r#" UPDATE workflows @@ -768,7 +829,7 @@ pub async fn disable_workflows_for_owner_in_channel_on_conn( .bind(community_id.as_uuid()) .bind(channel_id) .bind(owner_pubkey) - .execute(&mut *conn) + .execute(tx.conn()) .await? .rows_affected(); @@ -789,6 +850,12 @@ pub async fn create_workflow_run( trigger_event_id: Option<&[u8]>, trigger_context: Option<&serde_json::Value>, ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let id = Uuid::new_v4(); sqlx::query( @@ -803,8 +870,9 @@ pub async fn create_workflow_run( .bind(workflow_id) .bind(trigger_event_id) .bind(trigger_context) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(id) } @@ -907,6 +975,12 @@ pub async fn update_workflow_run( trace: &serde_json::Value, failure: Option>, ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let status_str = status.to_string(); let (error_code, error) = failure .map(|failure| (Some(failure.code), Some(failure.message))) @@ -935,9 +1009,10 @@ pub async fn update_workflow_run( .bind(&status_str) // for completed_at CASE .bind(community_id.as_uuid()) .bind(id) - .execute(pool) + .execute(tx.conn()) .await? .rows_affected(); + tx.commit().await?; if affected == 0 { return Err(DbError::NotFound(format!("workflow_run {id}"))); @@ -983,6 +1058,12 @@ pub async fn create_approval(pool: &PgPool, params: CreateApprovalParams<'_>) -> expires_at, } = params; let token_hash = hash_approval_token(token); + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; sqlx::query( r#" @@ -999,8 +1080,9 @@ pub async fn create_approval(pool: &PgPool, params: CreateApprovalParams<'_>) -> .bind(step_index) .bind(approver_spec) .bind(expires_at) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(()) } @@ -1120,6 +1202,12 @@ pub async fn update_approval_by_stored_hash( approver_pubkey: Option<&[u8]>, note: Option<&str>, ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let status_str = status.to_string(); let affected = sqlx::query( r#" @@ -1139,9 +1227,10 @@ pub async fn update_approval_by_stored_hash( .bind(&status_str) // for denied_at CASE .bind(community_id.as_uuid()) .bind(token_hash) - .execute(pool) + .execute(tx.conn()) .await? .rows_affected(); + tx.commit().await?; Ok(affected > 0) } diff --git a/crates/buzz-db/tests/observability_source.rs b/crates/buzz-db/tests/observability_source.rs index 1608779952b..4d4edcf8e98 100644 --- a/crates/buzz-db/tests/observability_source.rs +++ b/crates/buzz-db/tests/observability_source.rs @@ -207,48 +207,47 @@ fn p0_pool_acquisitions_use_typed_operation_pairs_without_other() { assert!(!thread_metadata.contains("fetch_optional(pool)")); let channel = include_str!("../src/store/channel.rs"); - assert!(channel.contains("async fn begin_event_write_transaction(")); - assert!(channel.contains("async fn acquire_event_write_connection(")); + assert!(channel.contains("begin_community_write_transaction(")); for (start, end, expected) in [ ( "pub async fn create_channel(\n", "/// Creates a channel with a client-supplied UUID", - "begin_event_write_transaction(pool)", + "begin_community_write_transaction(", ), ( "pub async fn create_channel_with_id(\n", "/// Fetches a channel record by `(community_id, id)`", - "begin_event_write_transaction(pool)", + "begin_community_write_transaction(", ), ( "pub async fn update_channel(\n", "/// Sets the topic for a channel", - "begin_event_write_transaction(pool)", + "begin_community_write_transaction(", ), ( "pub async fn set_topic(\n", "/// Sets the purpose for a channel", - "acquire_event_write_connection(pool)", + "begin_community_write_transaction(", ), ( "pub async fn set_purpose(\n", "/// Archives a channel", - "acquire_event_write_connection(pool)", + "begin_community_write_transaction(", ), ( "pub async fn archive_channel(\n", "/// Unarchives a channel", - "acquire_event_write_connection(pool)", + "begin_community_write_transaction(", ), ( "pub async fn unarchive_channel(\n", "/// Soft-delete a channel", - "acquire_event_write_connection(pool)", + "begin_community_write_transaction(", ), ( "pub async fn soft_delete_channel(\n", "/// Archive ephemeral channels", - "acquire_event_write_connection(pool)", + "begin_community_write_transaction(", ), ] { let function = channel @@ -903,11 +902,13 @@ const GUARDED_TABLE_WRITE_MARKERS: [&str; 9] = [ ]; const COMMUNITY_CHOKEPOINT_MARKER: &str = "begin_community_event_write_transaction("; +const GENERAL_COMMUNITY_CHOKEPOINT_MARKER: &str = "begin_community_write_transaction("; const COMMUNITY_CHOKEPOINT_LEGACY_MARKER: &str = "begin_community_event_write_transaction_with_legacy_metrics("; fn has_any_tenant_local_chokepoint(source: &str) -> bool { source.contains(COMMUNITY_CHOKEPOINT_MARKER) + || source.contains(GENERAL_COMMUNITY_CHOKEPOINT_MARKER) || source.contains(COMMUNITY_CHOKEPOINT_LEGACY_MARKER) } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index b762af391d1..be6743599b6 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -8396,7 +8396,9 @@ pub(crate) mod postgres_tests { .await .expect("local Postgres and Redis"); // A schema whose `users` table can be taken away after the link, - // so only the owner-to-agent lookup fails at revoke time. + // so only the owner-to-agent lookup fails at revoke time. It + // carries `communities` and the deletion lock key so writes pass + // community write admission. let db_url = crate::test_support::database_url(); let schema = format!("late_owner_{}", uuid::Uuid::new_v4().simple()); sqlx::raw_sql(sqlx::AssertSqlSafe(format!( @@ -8405,7 +8407,10 @@ pub(crate) mod postgres_tests { CREATE TABLE {schema}.community_bans (LIKE public.community_bans INCLUDING ALL); \ CREATE TABLE {schema}.relay_members (LIKE public.relay_members INCLUDING ALL); \ CREATE TABLE {schema}.join_policy_acceptances \ - (LIKE public.join_policy_acceptances INCLUDING ALL);" + (LIKE public.join_policy_acceptances INCLUDING ALL); \ + CREATE TABLE {schema}.communities (LIKE public.communities INCLUDING ALL); \ + CREATE FUNCTION {schema}.community_deletion_lock_key(target UUID) RETURNS BIGINT \ + LANGUAGE SQL IMMUTABLE STRICT AS 'SELECT public.community_deletion_lock_key(target)';" ))) .execute(state.db.pool()) .await @@ -8423,6 +8428,17 @@ pub(crate) mod postgres_tests { let tenant = fresh_tenant("late-owner.test"); let other = fresh_tenant("late-owner-other.test"); + // Both tenants pass community write admission in the schema. + for community in [&tenant, &other] { + sqlx::query(sqlx::AssertSqlSafe(format!( + "INSERT INTO {schema}.communities (id, host) VALUES ($1, $2)" + ))) + .bind(community.community().as_uuid()) + .bind(community.host()) + .execute(&admin) + .await + .expect("seed admitted community"); + } let (owner, agent) = (Keys::generate(), Keys::generate()); let agent_bytes = agent.public_key().to_bytes(); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 5aad9689aa8..0324d6f2cc5 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -7150,7 +7150,8 @@ mod postgres_tests { use tokio_util::sync::CancellationToken; // A schema with bans but no `users` table: the ban commits, and only - // the owned-agent lookup fails. + // the owned-agent lookup fails. It carries `communities` and the + // deletion lock key so the ban passes community write admission. let db_url = crate::test_support::database_url(); let admin = sqlx::PgPool::connect(&db_url) .await @@ -7158,7 +7159,10 @@ mod postgres_tests { let schema = format!("revoke_owner_{}", Uuid::new_v4().simple()); sqlx::raw_sql(sqlx::AssertSqlSafe(format!( "CREATE SCHEMA {schema}; \ - CREATE TABLE {schema}.community_bans (LIKE public.community_bans INCLUDING ALL);" + CREATE TABLE {schema}.community_bans (LIKE public.community_bans INCLUDING ALL); \ + CREATE TABLE {schema}.communities (LIKE public.communities INCLUDING ALL); \ + CREATE FUNCTION {schema}.community_deletion_lock_key(target UUID) RETURNS BIGINT \ + LANGUAGE SQL IMMUTABLE STRICT AS 'SELECT public.community_deletion_lock_key(target)';" ))) .execute(&admin) .await @@ -7173,6 +7177,13 @@ mod postgres_tests { let state = build_canvas_ingest_state(&db_url, &pool).await; let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); let tenant = TenantContext::resolved(community, "revoke-owner.test".to_string()); + sqlx::query(sqlx::AssertSqlSafe(format!( + "INSERT INTO {schema}.communities (id, host) VALUES ($1, 'revoke-owner.test')" + ))) + .bind(community.as_uuid()) + .execute(&admin) + .await + .expect("seed admitted community"); let (owner, agent, bystander) = ( nostr::Keys::generate(), nostr::Keys::generate(), diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 44183a8f1c8..42930f6c1f6 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -9,7 +9,7 @@ use buzz_core::filter::{filters_match, reader_authorized_for_event}; use chrono::{TimeDelta, Utc}; use nostr::Filter; use serde::{Deserialize, Serialize}; -use tracing::{error, warn}; +use tracing::{debug, error, warn}; use crate::{handlers::push_lease::Subscription, nip98::nip98_header, state::AppState}; @@ -398,6 +398,11 @@ async fn delivery_worker_loop(state: &AppState) { deliver_one(state, &http, wake).await; } } + // The community left `active` after the host scan; the + // next scan skips it, so this is not worth a warning. + Err(e @ buzz_db::DbError::AccessDenied(_)) => { + debug!(%community, "push wake claim refused: {e}") + } Err(e) => warn!(%community, "push wake claim failed: {e}"), } } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 7e015a5dc89..1e88eb8652e 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -2401,9 +2401,7 @@ async fn log_audit_entry(audit: &buzz_audit::AuditService, entry: buzz_audit::Ne metrics::histogram!("buzz_audit_log_seconds").record(t.elapsed().as_secs_f64()); return; } - Err(buzz_audit::AuditError::Database(sqlx::Error::Database(database_error))) - if database_error.code().as_deref() == Some("55P03") => - { + Err(error) if is_lock_timeout(&error) => { retries += 1; metrics::counter!("buzz_audit_log_lock_retries_total").increment(1); tracing::warn!( @@ -2423,6 +2421,20 @@ async fn log_audit_entry(audit: &buzz_audit::AuditService, entry: buzz_audit::Ne } } +/// Whether an audit append failed on `lock_timeout` (`55P03`), either on the +/// audit-chain lock or on the community admission lock taken before it. Both +/// are transient; the entry is kept and retried. Admission refusal +/// (`AccessDenied`) is terminal: a quiescing or fenced community refuses for +/// good. +fn is_lock_timeout(error: &buzz_audit::AuditError) -> bool { + let database_error = match error { + buzz_audit::AuditError::Database(sqlx::Error::Database(e)) + | buzz_audit::AuditError::Admission(buzz_db::DbError::Sqlx(sqlx::Error::Database(e))) => e, + _ => return false, + }; + database_error.code().as_deref() == Some("55P03") +} + impl std::fmt::Debug for AppState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AppState") @@ -2929,7 +2941,25 @@ pub(crate) mod tests { Arc::new(state) } + /// The lock an audit-retry test holds while the worker times out on it. + #[derive(Clone, Copy)] + enum HeldAuditLock { + /// The per-community audit-chain lock (`buzz_audit:`). + AuditChain, + /// The community deletion lock, held EXCLUSIVE as `begin_quiescing`, + /// `fence` and `abort` do; audit admission takes it SHARED first. + CommunityDeletion, + } + async fn audit_worker_retries_lock_timeout_until_original_entry_is_appended_once() { + assert_audit_worker_retries_lock_timeout(HeldAuditLock::AuditChain).await; + } + + async fn audit_worker_retries_admission_lock_timeout_until_original_entry_is_appended_once() { + assert_audit_worker_retries_lock_timeout(HeldAuditLock::CommunityDeletion).await; + } + + async fn assert_audit_worker_retries_lock_timeout(held: HeldAuditLock) { let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL"); let observer = sqlx::PgPool::connect(&database_url) .await @@ -2974,11 +3004,21 @@ pub(crate) mod tests { // Mirrors buzz_audit::service::AUDIT_LOCK_NAMESPACE. let lock_key = format!("buzz_audit:{community_id}"); let mut holder = observer.acquire().await.expect("acquire lock holder"); - sqlx::query("SELECT pg_advisory_lock(hashtextextended($1, 0))") - .bind(&lock_key) - .execute(&mut *holder) - .await - .expect("hold community audit lock"); + match held { + HeldAuditLock::AuditChain => { + sqlx::query("SELECT pg_advisory_lock(hashtextextended($1, 0))") + .bind(&lock_key) + .execute(&mut *holder) + .await + } + HeldAuditLock::CommunityDeletion => { + sqlx::query("SELECT pg_advisory_lock(community_deletion_lock_key($1))") + .bind(community_id) + .execute(&mut *holder) + .await + } + } + .expect("hold lock"); let audit = Arc::new(AuditService::new(audit_pool)); let worker = tokio::spawn({ @@ -2997,7 +3037,7 @@ pub(crate) mod tests { "SELECT EXISTS (\ SELECT 1 FROM pg_stat_activity \ WHERE application_name = $1 \ - AND query LIKE 'SELECT pg_advisory_lock%' \ + AND query LIKE 'SELECT pg_advisory_xact_lock%' \ AND wait_event = 'advisory'\ )", ) @@ -3019,11 +3059,21 @@ pub(crate) mod tests { .await .expect("audit worker never retried after lock_timeout"); - sqlx::query("SELECT pg_advisory_unlock(hashtextextended($1, 0))") - .bind(&lock_key) - .execute(&mut *holder) - .await - .expect("release community audit lock"); + match held { + HeldAuditLock::AuditChain => { + sqlx::query("SELECT pg_advisory_unlock(hashtextextended($1, 0))") + .bind(&lock_key) + .execute(&mut *holder) + .await + } + HeldAuditLock::CommunityDeletion => { + sqlx::query("SELECT pg_advisory_unlock(community_deletion_lock_key($1))") + .bind(community_id) + .execute(&mut *holder) + .await + } + } + .expect("release lock"); tokio::time::timeout(std::time::Duration::from_secs(3), worker) .await .expect("audit worker did not finish after lock release") @@ -3058,6 +3108,14 @@ pub(crate) mod tests { async fn audit_worker_retries_lock_timeout_until_original_entry_is_appended_once() { super::audit_worker_retries_lock_timeout_until_original_entry_is_appended_once().await; } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn audit_worker_retries_admission_lock_timeout_until_original_entry_is_appended_once() + { + super::audit_worker_retries_admission_lock_timeout_until_original_entry_is_appended_once() + .await; + } } #[test] From 67c4fe5e17c5e7b25bf6f5ddbe0b4aeeb0531c2f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 14 Oct 2026 11:16:47 -0400 Subject: [PATCH 004/144] fix(deps): bump rustls to 0.23.46 for RUSTSEC-2026-0346 and -0347 (#8319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary The required Security check now fails on every branch, including main. The dependency audit (`cargo-deny`) reports two new advisories for rustls 0.23.45, the TLS library behind the relay, CLI and desktop connections: - **RUSTSEC-2026-0346:** printing a TLS client session value with `Debug` (for example, in a log line) shows its TLS secret. - **RUSTSEC-2026-0347:** when a server rejects Encrypted Client Hello (ECH, which hides the server name a client asks for), data the client buffered can go to the outer server. rustls 0.23.46 fixes both. This PR moves the root and desktop lockfiles to it, so the Security check passes again. ### Related issue None found. #8277 fixed an earlier advisory in the same way. ### Testing - `cargo-deny check` passes: "advisories ok, bans ok, licenses ok, sources ok". - `cargo tree --locked` resolves both lockfiles without more changes. Only the rustls version and checksum change. A plain `cargo update -p rustls` also moved unrelated `syn` and `getrandom` edges, so this PR leaves those out. Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- Cargo.lock | 4 ++-- desktop/src-tauri/Cargo.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 27a35a4ae5f..798825deafa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8814,9 +8814,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 7ba160f8c8c..2b622d7c6c1 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -9071,9 +9071,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", From e68977f2708bf5666ae2e4946b6a860225bde49e Mon Sep 17 00:00:00 2001 From: Diem Nguyen Date: Wed, 14 Oct 2026 10:40:32 -0700 Subject: [PATCH 005/144] docs: link the Buzz feedback community and @buzzdotxyz on X (#8324) ## Summary Adds a **Community** section to the README so people know where to connect: the [Buzz feedback community](https://feedback.communities.buzz.xyz/invite/v2.yC8ZZIz5vwWCZYo2RRheuY1a0IKZRTXvxTmK7vIFIIY), our main public discussion forum, and [@buzzdotxyz on X](https://x.com/buzzdotxyz). There's no official Buzz Discord yet, so this PR leaves Discord out on purpose. ### Related issue N/A. Companion PR: block/buzz-app#1091. ### Testing Docs-only change. Both URLs return HTTP 200, and the diff is the README alone. --------- Signed-off-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz> Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz> --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 56439f00bc1..bdf218e57db 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,13 @@ just reset # ⚠️ Wipe data + recreate --- +## Community + +- **[Buzz feedback community](https://feedback.communities.buzz.xyz/invite/v2.yC8ZZIz5vwWCZYo2RRheuY1a0IKZRTXvxTmK7vIFIIY)** — ask questions, share feedback, and connect with other Buzz users +- **[@buzzdotxyz on X](https://x.com/buzzdotxyz)** — news and updates + +--- + ## What it is not - Not blockchain. Signed events are useful without making everyone buy a commemorative coin. From 99c5fc8bba2c4037ac1d72a82f7afea18905d66d Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Wed, 14 Oct 2026 14:36:00 -0400 Subject: [PATCH 006/144] ci(docker): publish qualified, sortable push gateway main tags (#8305) ## Summary This PR makes two changes to the push gateway image job (`push-gateway-merge` in `docker.yml`). Together they let staging auto-promote the gateway the same way it already auto-promotes the relay: 1. **Publish only after same-SHA CI qualification.** `push-gateway-merge` now needs `qualify` as well as `push-gateway-build`. This is the same gate the relay's `merge` job already uses: a successful `ci.yml` push run on the exact source SHA. Before this change, the gateway image was published even when main CI was red. That was safe while staging pinned the gateway by hand. It isn't safe once the newest main image deploys automatically. 2. **Publish the sortable `:main--` tag on main pushes.** It's the same `type=raw` line the relay matrix gained in #8240. It uses the same `enable=` guard, so it applies only to `push` on `refs/heads/main`. A Lexical selector on `^main-\d{14}-[0-9a-f]{7}$` can then pick the newest gateway build from the tag list alone. `:sha-<7>` has no ordering. `:main`, `:sha-<7>`, and the release semver tags don't change. The gateway has no debug variant, so no `debug-main-*` tag is published. **Tradeoff:** a gateway image now appears only after `qualify` finishes, which takes as long as main CI. If main CI fails, no gateway image is published for that commit, and that includes `:main` and `:sha-<7>`. The relay already behaves this way, and it's the behavior we want for anything that auto-deploys. **What got simpler:** the relay and gateway images now share the same publication gate and the same tag set. Downstream tooling no longer needs a special case for the gateway's unqualified, unordered tags. **What stays different:** the relay's `merge` job also attests the `deployment-eligibility/v1` predicate (`docs/deployment-identity.md`); the gateway publishes no such attestation. That's intentional for now: the staging promotion guard selects the gateway by package and `main-*` tag and does not verify attestations, so the gateway warehouse must not be wired to require one. If promotion ever starts verifying eligibility, the gateway needs the same attestation first. ### Related issue N/A (staging rollout tooling). The downstream promotion-guard and warehouse changes land separately. The warehouse switches on only after this tag has been published. ### Testing - `scripts/test-relay-image-eligibility-workflow.sh` now pins both properties of the `push-gateway-merge` job (it runs in CI under "Relay image eligibility contract"). Both checks match whole active lines, so a commented-out copy can't satisfy them. - At `9b8c49600` it passes. - With the live `needs` line replaced by `needs: [push-gateway-build]` and a commented-out `# needs: [push-gateway-build, qualify]` left behind, it fails. - With the gateway `main-*` line commented out, it fails. - With `needs` reverted to `push-gateway-build`, it fails with `push gateway image must publish only after same-SHA CI qualification`. - With the `main-*` tag line removed, it fails with `push gateway image must publish the sortable main--<7> tag on main pushes`. - `scripts/test-release-ref-contract.sh` now counts the sortable `main-*` tag line on all three image legs (relay, relay-debug, gateway) instead of two. It passes at `9b8c49600`. - `bin/actionlint .github/workflows/docker.yml` is clean at `f6c03b6b5`. - PR runs don't push images. The first real `main-*` gateway tag appears on the first main push after merge, and I'll check the registry for it on that manifest. --------- Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> --- .github/workflows/docker.yml | 9 ++++++++- scripts/test-relay-image-eligibility-workflow.sh | 14 ++++++++++++++ scripts/test-release-ref-contract.sh | 9 +++++---- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 596c247eeee..233943268ce 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -34,6 +34,10 @@ name: Docker image # intact) and the bare version flows straight to the semver parser. On a # real push event value= is empty and the match strips relay-v from the ref. # +# ghcr.io/block/buzz-push-gateway carries the same :main, :sha-<7> and +# :main--<7> tags on main pushes, and publishes only after the +# relay's `qualify` job accepts the same source SHA. +# # :main--<7> names the same main build as :sha-<7>, prefixed # with its UTC commit time so the tag sorts by build order. Kargo's Lexical # strategy picks the newest from the tag list alone; :sha-<7> cannot be @@ -614,7 +618,9 @@ jobs: name: Publish public push gateway image if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 - needs: push-gateway-build + # Staging promotes every published main gateway image automatically, so the + # gateway publishes only from a source the relay's same-SHA CI qualified. + needs: [push-gateway-build, qualify] timeout-minutes: 15 permissions: contents: read @@ -646,6 +652,7 @@ jobs: type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }} + type=raw,value=main-{{commit_date 'YYYYMMDDHHmmss'}}-{{sha}},enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - name: Merge and publish manifest id: manifest working-directory: /tmp/gateway-digests diff --git a/scripts/test-relay-image-eligibility-workflow.sh b/scripts/test-relay-image-eligibility-workflow.sh index 40745a8e887..f6adc4c6f30 100755 --- a/scripts/test-relay-image-eligibility-workflow.sh +++ b/scripts/test-relay-image-eligibility-workflow.sh @@ -44,6 +44,20 @@ grep -Fq "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10" <<<"$merge_ exit 1 } +# Staging auto-promotes the newest main--<7> gateway image, so the gateway +# must publish that tag and only from a source the relay CI qualified. Match +# whole lines (-x) so a commented-out or otherwise inactive copy cannot satisfy +# the contract after the live line regresses. +gateway_merge_job=$(awk '/^ push-gateway-merge:/{f=1} f&&/^ [a-z][a-z_-]*:$/&&!/^ push-gateway-merge:/{exit} f' "$workflow") +grep -Fxq " needs: [push-gateway-build, qualify]" <<<"$gateway_merge_job" || { + echo "push gateway image must publish only after same-SHA CI qualification" >&2 + exit 1 +} +grep -Fxq " type=raw,value=main-{{commit_date 'YYYYMMDDHHmmss'}}-{{sha}},enable=\${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}" <<<"$gateway_merge_job" || { + echo "push gateway image must publish the sortable main--<7> tag on main pushes" >&2 + exit 1 +} + select_run() { jq -r --arg source_sha aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -f "$selector" | jq -r '.id // empty' } diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 5b11a710875..e5903cb46c7 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -52,12 +52,13 @@ fi grep -q 'verify-release-ref\.sh' "$repo_root/.github/workflows/release.yml" grep -q 'verify-release-ref\.sh' "$repo_root/.github/workflows/docker.yml" -# Kargo's buzz-image warehouse selects Lexical over ^main-[0-9]{14}-[0-9a-f]{7}$; -# a drifted format stops Freight discovery silently, so pin both image legs. +# Kargo's buzz-image and buzz-push-gateway-image warehouses select Lexical over +# ^main-[0-9]{14}-[0-9a-f]{7}$; a drifted format stops Freight discovery +# silently, so pin all three image legs (relay, relay-debug, push gateway). sortable_main_tag=" type=raw,value=main-{{commit_date 'YYYYMMDDHHmmss'}}-{{sha}},enable=\${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}" sortable_count="$(grep -Fxc "$sortable_main_tag" "$repo_root/.github/workflows/docker.yml" || true)" -if [ "$sortable_count" != 2 ]; then - echo "docker.yml must publish the sortable main-- tag on both image legs (found $sortable_count)" >&2 +if [ "$sortable_count" != 3 ]; then + echo "docker.yml must publish the sortable main-- tag on all three image legs (found $sortable_count)" >&2 exit 1 fi grep -q 'test-release-ref-contract\.sh' "$repo_root/.github/workflows/ci.yml" From 2270b66e147c8955edbfc445aed4b9633f9dff6e Mon Sep 17 00:00:00 2001 From: Ian Oberst Date: Wed, 14 Oct 2026 11:36:26 -0700 Subject: [PATCH 007/144] test(buzz-db): drop partition scratch schemas on panic (#8293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The partition Postgres tests dropped their scratch schema with a `drop_schema` call at the end of each test body. When a test panicked, that call never ran, so the schema leaked into the shared database, along with any `{schema}_*` sibling schemas or reader role the test created. A reviewer on #7993 hit this locally: the leaked schema plus a database-wide catalog query made later runs fail. CI hides it because the test wrapper gives each process its own database. `scratch_pool` / `scratch_pool_with_max_connections` now return `(pool, ScratchSchema)`. The guard owns the admin pool and schema name and cleans up on `Drop`: - **Fresh thread and connection.** `Drop` runs on the test's runtime thread, which can't drive the test's pools while it blocks, so cleanup runs on its own thread with its own runtime and connection. - **Terminates the test's backends first.** Both pools record each connection's backend pid. A test that panics mid-transaction keeps its locks, because the rollback would only run on the blocked runtime. Without this step `DROP SCHEMA` waits on those locks. - **Drops `{name}` plus every schema and role named `{name}_*`.** This replaces the per-test sibling-schema and `DROP ROLE` cleanup. The reader role is now named `{schema}_reader`. - **Bounded.** `lock_timeout = '10s'`. Failures print a warning and never panic inside `Drop`. The 52 call sites are migrated mechanically. Test-only change. ### Related issue Follow-up from review on #7993. No existing issue found. ### Testing - New `scratch_schema_is_dropped_when_a_test_panics_holding_a_lock`: the test body creates a sibling schema, takes `ACCESS EXCLUSIVE` on a scratch table in an open transaction, and panics. The test then asserts no `{name}` / `{name}_*` schema remains. - Proved it can fail: with the backend-termination step disabled, cleanup hits the lock timeout and the test fails, reporting both leaked schemas. - Partition Postgres lane: `scripts/postgres-test-run.sh -p buzz-db --lib -E 'test(partition)'`, 53/53 passed. - The same filter run directly against the shared `buzz` database: 53/53 passed, then 0 `partition_audit_test_%` schemas and 0 `partition_audit_%` roles remained. - `cargo clippy -p buzz-db --tests` clean. Pre-push hooks passed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Ian Oberst Co-authored-by: Ian Oberst Co-authored-by: Claude Opus 5.5 --- crates/buzz-db/src/store/partition.rs | 418 +++++++++++++----- .../src/store/partition/maintenance_tests.rs | 83 ++-- 2 files changed, 341 insertions(+), 160 deletions(-) diff --git a/crates/buzz-db/src/store/partition.rs b/crates/buzz-db/src/store/partition.rs index 8322313de3c..cd5a5cd6b50 100644 --- a/crates/buzz-db/src/store/partition.rs +++ b/crates/buzz-db/src/store/partition.rs @@ -2201,49 +2201,207 @@ mod tests { } pub(super) mod postgres_tests { + use std::sync::{Arc, Mutex}; + use sqlx::postgres::PgPoolOptions; + use sqlx::Connection; use uuid::Uuid; use super::*; - pub(in crate::store::partition) async fn scratch_pool() -> (PgPool, PgPool, String) { + /// A uniquely named scratch schema that is dropped when the guard goes + /// out of scope, including when the test panics. + /// + /// Cleanup also drops every schema and role named `{name}_*`, so a test + /// that needs extra schemas or roles derives their names from `name`. + /// + /// Bind the guard to a named variable such as `_scratch` for the whole + /// test: `let (pool, _) = scratch_pool().await` drops it, and the + /// schema with it, immediately. + pub(in crate::store::partition) struct ScratchSchema { + pub(in crate::store::partition) admin: PgPool, + pub(in crate::store::partition) name: String, + url: String, + backends: Arc>>, + } + + impl Drop for ScratchSchema { + fn drop(&mut self) { + // Drop runs on the test's runtime thread, which cannot drive the + // test's pools while it blocks here, so clean up over a fresh + // connection on a thread with its own runtime. + let url = std::mem::take(&mut self.url); + let name = self.name.clone(); + let backends = std::mem::take( + &mut *self + .backends + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + let cleanup = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| error.to_string())? + .block_on(drop_scratch_objects(&url, &name, &backends)) + }) + .join(); + let error = match cleanup { + Ok(Ok(())) => return, + Ok(Err(error)) => error, + Err(_) => "cleanup thread panicked".to_owned(), + }; + eprintln!( + "warning: failed to drop scratch schema {}: {error}", + self.name + ); + } + } + + async fn drop_scratch_objects( + url: &str, + name: &str, + backends: &[i32], + ) -> std::result::Result<(), String> { + let mut connection = PgConnection::connect(url) + .await + .map_err(|error| error.to_string())?; + // Every step is idempotent, so keep going after a failure and + // report all of them rather than skipping the remaining objects. + let mut errors = Vec::new(); + let mut record = |step: &str, result: sqlx::Result<()>| { + if let Err(error) = result { + errors.push(format!("{step}: {error}")); + } + }; + let result = sqlx::query("SET lock_timeout = '10s'") + .execute(&mut connection) + .await + .map(drop); + record("set lock_timeout", result); + // A panicking test can leave a transaction holding locks in the + // schema; its rollback would only run on the blocked test runtime. + let result = sqlx::query( + "SELECT pg_terminate_backend(pid, 5000) FROM pg_stat_activity \ + WHERE pid = ANY($1) AND datname = current_database()", + ) + .bind(backends) + .execute(&mut connection) + .await + .map(drop); + record("terminate test backends", result); + let schemas: Vec = sqlx::query_scalar( + "SELECT nspname::text FROM pg_namespace \ + WHERE nspname = $1 OR starts_with(nspname, $1 || '_')", + ) + .bind(name) + .fetch_all(&mut connection) + .await + .unwrap_or_else(|error| { + record("list schemas", Err(error)); + Vec::new() + }); + for schema in schemas { + let result = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP SCHEMA IF EXISTS {} CASCADE", + quote_identifier(&schema) + ))) + .execute(&mut connection) + .await + .map(drop); + record(&format!("drop schema {schema}"), result); + } + let roles: Vec = sqlx::query_scalar( + "SELECT rolname::text FROM pg_roles WHERE starts_with(rolname, $1 || '_')", + ) + .bind(name) + .fetch_all(&mut connection) + .await + .unwrap_or_else(|error| { + record("list roles", Err(error)); + Vec::new() + }); + for role in roles { + let result = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP ROLE IF EXISTS {}", + quote_identifier(&role) + ))) + .execute(&mut connection) + .await + .map(drop); + record(&format!("drop role {role}"), result); + } + record("close connection", connection.close().await); + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } + } + + /// Pool options that record each connection's backend pid in `backends` + /// and point its `search_path` at `search_path`, when given. + fn tracked_pool_options( + search_path: Option, + backends: Arc>>, + ) -> PgPoolOptions { + PgPoolOptions::new().after_connect(move |connection, _| { + let search_path = search_path.clone(); + let backends = Arc::clone(&backends); + Box::pin(async move { + if let Some(schema) = search_path { + sqlx::query(sqlx::AssertSqlSafe(format!("SET search_path TO {schema}"))) + .execute(&mut *connection) + .await?; + } + let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *connection) + .await?; + backends + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(pid); + Ok(()) + }) + }) + } + + /// A pool whose `search_path` is a fresh scratch schema, plus the guard + /// that drops it. Bind the guard to a named variable, never `_`. + pub(in crate::store::partition) async fn scratch_pool() -> (PgPool, ScratchSchema) { scratch_pool_with_max_connections(4).await } pub(in crate::store::partition) async fn scratch_pool_with_max_connections( max_connections: u32, - ) -> (PgPool, PgPool, String) { + ) -> (PgPool, ScratchSchema) { let url = crate::test_support::database_url(); - let schema = format!("partition_audit_test_{}", Uuid::new_v4().simple()); - let admin = PgPool::connect(&url).await.expect("connect admin pool"); - sqlx::query(sqlx::AssertSqlSafe(format!("CREATE SCHEMA {schema}"))) - .execute(&admin) - .await - .expect("create scratch schema"); - let search_path_schema = schema.clone(); - let pool = PgPoolOptions::new() - .max_connections(max_connections) - .after_connect(move |connection, _| { - let schema = search_path_schema.clone(); - Box::pin(async move { - sqlx::query(sqlx::AssertSqlSafe(format!("SET search_path TO {schema}"))) - .execute(connection) - .await?; - Ok(()) - }) - }) + let name = format!("partition_audit_test_{}", Uuid::new_v4().simple()); + let backends = Arc::new(Mutex::new(Vec::new())); + let admin = tracked_pool_options(None, Arc::clone(&backends)) .connect(&url) .await - .expect("connect scratch pool"); - (pool, admin, schema) - } - - pub(in crate::store::partition) async fn drop_schema(admin: &PgPool, schema: &str) { - let _ = sqlx::query(sqlx::AssertSqlSafe(format!( - "DROP SCHEMA IF EXISTS {schema} CASCADE" + .expect("connect admin pool"); + let scratch = ScratchSchema { + admin, + name, + url, + backends, + }; + sqlx::query(sqlx::AssertSqlSafe(format!( + "CREATE SCHEMA {}", + scratch.name ))) - .execute(admin) - .await; + .execute(&scratch.admin) + .await + .expect("create scratch schema"); + let pool = + tracked_pool_options(Some(scratch.name.clone()), Arc::clone(&scratch.backends)) + .max_connections(max_connections) + .connect(&scratch.url) + .await + .expect("connect scratch pool"); + (pool, scratch) } pub(in crate::store::partition) async fn seed_parents(pool: &PgPool) { @@ -2412,10 +2570,80 @@ mod tests { } } + #[test] + #[ignore = "requires Postgres"] + fn scratch_schema_is_dropped_when_a_test_panics_holding_a_lock() { + let runtime = || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build test runtime") + }; + let (name_sender, name_receiver) = std::sync::mpsc::channel(); + let outcome = std::thread::spawn(move || { + runtime().block_on(async move { + let (pool, scratch) = scratch_pool().await; + seed_parents(&pool).await; + sqlx::query(sqlx::AssertSqlSafe(format!( + "CREATE SCHEMA {}_children", + scratch.name + ))) + .execute(&scratch.admin) + .await + .expect("create sibling schema"); + for statement in [ + format!("CREATE ROLE {}_reader NOLOGIN", scratch.name), + format!("GRANT USAGE ON SCHEMA {0} TO {0}_reader", scratch.name), + ] { + sqlx::query(sqlx::AssertSqlSafe(statement)) + .execute(&scratch.admin) + .await + .expect("create scratch role"); + } + let mut holder = pool.begin().await.expect("begin lock holder"); + sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *holder) + .await + .expect("lock scratch table"); + name_sender + .send(scratch.name.clone()) + .expect("send scratch schema name"); + panic!("deliberate panic while holding a scratch schema lock"); + }) + }) + .join(); + assert!(outcome.is_err(), "the scratch test body must panic"); + let name = name_receiver.recv().expect("scratch schema name"); + + let (schemas, roles): (Vec, Vec) = runtime().block_on(async { + let mut connection = PgConnection::connect(&crate::test_support::database_url()) + .await + .expect("connect leftover check"); + let schemas = sqlx::query_scalar( + "SELECT nspname::text FROM pg_namespace \ + WHERE nspname = $1 OR starts_with(nspname, $1 || '_')", + ) + .bind(&name) + .fetch_all(&mut connection) + .await + .expect("list leftover schemas"); + let roles = sqlx::query_scalar( + "SELECT rolname::text FROM pg_roles WHERE starts_with(rolname, $1 || '_')", + ) + .bind(&name) + .fetch_all(&mut connection) + .await + .expect("list leftover roles"); + (schemas, roles) + }); + assert!(schemas.is_empty(), "leaked scratch schemas: {schemas:?}"); + assert!(roles.is_empty(), "leaked scratch roles: {roles:?}"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn fresh_layout_is_serving_safe_and_audit_is_read_only() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_fresh_layout(&pool).await; let before = catalog_snapshot(&pool).await; let audit = audit_partition_catalog_at(&pool, 3, fixed_now()) @@ -2431,13 +2659,12 @@ mod tests { .all(|month| month.kind == MonthCoverageKind::CoveredByCatchAll) })); assert_eq!(catalog_snapshot(&pool).await, before); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn wrong_root_partition_key_never_proves_coverage() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents_with_keys(&pool, "alternate_at", "delivered_at").await; for table in PARTITIONED_TABLES { create_child( @@ -2473,13 +2700,13 @@ mod tests { .expect("delivery_log audit"); assert!(delivery_log.partition_key_valid); assert!(delivery_log.serving_safe); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn occupied_catch_all_is_reported_as_degraded() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_fresh_layout(&pool).await; sqlx::query( "INSERT INTO events (created_at, alternate_at) \ @@ -2519,17 +2746,17 @@ mod tests { assert!(delivery_log.children.iter().any(|child| { child.name == "delivery_log_p_future" && child.catch_all_nonempty == Some(false) })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn occupied_cross_schema_catch_all_is_reported_as_degraded() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_fresh_layout(&pool).await; let child_schema = format!("{schema}_children"); sqlx::query(sqlx::AssertSqlSafe(format!("CREATE SCHEMA {child_schema}"))) - .execute(&admin) + .execute(&scratch.admin) .await .expect("create child schema"); sqlx::query(sqlx::AssertSqlSafe(format!( @@ -2567,20 +2794,19 @@ mod tests { && leaf.name == "events_p_future" && leaf.root_child_schema == child_schema })); - drop_schema(&admin, &schema).await; - drop_schema(&admin, &child_schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn same_named_cross_schema_children_keep_distinct_trigger_metadata() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_parents(&pool).await; let first_schema = format!("{schema}_first"); let second_schema = format!("{schema}_second"); for child_schema in [&first_schema, &second_schema] { sqlx::query(sqlx::AssertSqlSafe(format!("CREATE SCHEMA {child_schema}"))) - .execute(&admin) + .execute(&scratch.admin) .await .expect("create child schema"); } @@ -2676,19 +2902,17 @@ mod tests { .expect("serialized coverage leaves") .iter() .any(|leaf| leaf["schema"] == second_schema)); - drop_schema(&admin, &schema).await; - drop_schema(&admin, &first_schema).await; - drop_schema(&admin, &second_schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn report_retains_successful_table_when_another_table_errors() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_fresh_layout(&pool).await; - let role = format!("partition_audit_reader_{}", Uuid::new_v4().simple()); + let role = format!("{schema}_reader"); sqlx::query(sqlx::AssertSqlSafe(format!("CREATE ROLE {role} NOLOGIN"))) - .execute(&admin) + .execute(&scratch.admin) .await .expect("create restricted audit role"); for statement in [ @@ -2696,12 +2920,12 @@ mod tests { format!("GRANT SELECT ON {schema}.events_p_future TO {role}"), ] { sqlx::query(sqlx::AssertSqlSafe(statement)) - .execute(&admin) + .execute(&scratch.admin) .await .expect("grant restricted audit access"); } let url = crate::test_support::database_url(); - let role_schema = schema.clone(); + let role_schema = schema.to_owned(); let role_name = role.clone(); let restricted = PgPoolOptions::new() .max_connections(1) @@ -2731,17 +2955,12 @@ mod tests { assert!(!report.serving_safe()); restricted.close().await; - drop_schema(&admin, &schema).await; - sqlx::query(sqlx::AssertSqlSafe(format!("DROP ROLE {role}"))) - .execute(&admin) - .await - .expect("drop restricted audit role"); } #[tokio::test] #[ignore = "requires Postgres"] async fn pending_detach_never_proves_coverage() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_fresh_layout(&pool).await; let mut blocker = pool.acquire().await.expect("acquire blocker"); @@ -2824,13 +3043,12 @@ mod tests { && child.pending_detach && child.kind == PartitionChildKind::Anomalous })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn repaired_layout_recognizes_bounds_not_catch_all_name() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -2907,13 +3125,12 @@ mod tests { assert!(august.tables.iter().all(PartitionTableAudit::degraded)); assert_eq!(august.outcome(), PartitionAuditOutcome::Degraded); assert_eq!(catalog_snapshot(&pool).await, before); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn uncovered_months_are_created_with_trigger_parity() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -2937,13 +3154,12 @@ mod tests { .iter() .all(|month| month.kind == MonthCoverageKind::CoveredByMonthly) && table.missing_trigger_count() == 0)); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn default_partitions_report_occupancy_without_mutation() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_uncovered_layout(&pool).await; for table in PARTITIONED_TABLES { sqlx::query(sqlx::AssertSqlSafe(format!( @@ -3037,13 +3253,12 @@ mod tests { .await .expect("check canonical relation")); } - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn parent_default_does_not_mask_gap_below_explicit_sibling() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for (table, column) in [("events", "created_at"), ("delivery_log", "delivered_at")] { create_child( @@ -3116,13 +3331,13 @@ mod tests { .execute(&pool) .await .is_err()); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn total_audit_timeout_bounds_blocked_occupancy_probes() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_uncovered_layout(&pool).await; for table in PARTITIONED_TABLES { sqlx::query(sqlx::AssertSqlSafe(format!( @@ -3132,7 +3347,11 @@ mod tests { .await .expect("create default partition"); } - let mut blocker = admin.begin().await.expect("begin blocking transaction"); + let mut blocker = scratch + .admin + .begin() + .await + .expect("begin blocking transaction"); sqlx::query(sqlx::AssertSqlSafe(format!( "LOCK TABLE {schema}.events_default IN ACCESS EXCLUSIVE MODE" ))) @@ -3157,13 +3376,12 @@ mod tests { .all(|error| error.error.contains("exceeded total timeout of 100ms"))); blocker.rollback().await.expect("release blocking lock"); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn create_pins_non_iso_date_style() { - let (pool, admin, schema) = scratch_pool_with_max_connections(1).await; + let (pool, _scratch) = scratch_pool_with_max_connections(1).await; seed_uncovered_layout(&pool).await; sqlx::query("SET DateStyle TO 'SQL, DMY'") .execute(&pool) @@ -3186,13 +3404,12 @@ mod tests { .await .expect("show DateStyle"); assert_eq!(date_style, "SQL, DMY"); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn create_pins_non_utc_time_zone() { - let (pool, admin, schema) = scratch_pool_with_max_connections(1).await; + let (pool, _scratch) = scratch_pool_with_max_connections(1).await; seed_uncovered_layout(&pool).await; sqlx::query("SET TimeZone TO 'America/Los_Angeles'") .execute(&pool) @@ -3215,13 +3432,12 @@ mod tests { .await .expect("show TimeZone"); assert_eq!(time_zone, "America/Los_Angeles"); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn disabled_child_trigger_degrades_parity() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_fresh_layout(&pool).await; sqlx::query("ALTER TABLE events_p_future DISABLE TRIGGER partition_probe") .execute(&pool) @@ -3240,13 +3456,12 @@ mod tests { assert!(events.children.iter().any(|child| { child.name == "events_p_future" && child.missing_triggers == ["partition_probe"] })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn always_enabled_parent_trigger_preserves_parity() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_fresh_layout(&pool).await; sqlx::query("ALTER TABLE events ENABLE ALWAYS TRIGGER partition_probe") .execute(&pool) @@ -3266,13 +3481,13 @@ mod tests { .children .iter() .all(|child| child.missing_triggers.is_empty())); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn disabled_nested_leaf_trigger_degrades_parity() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_parents(&pool).await; for (table, column) in [("events", "created_at"), ("delivery_log", "delivered_at")] { let nested = format!("{table}_nested"); @@ -3339,18 +3554,18 @@ mod tests { assert_eq!(events.missing_trigger_count(), 1); let expected = format!( "{}:partition_probe", - qualified_relation_name(&schema, "events_nested_first") + qualified_relation_name(schema, "events_nested_first") ); assert!(events.children.iter().any(|child| { child.name == "events_nested" && child.missing_triggers == [expected.as_str()] })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn child_only_nested_leaf_trigger_degrades_parity() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_parents(&pool).await; create_nested_child( &pool, @@ -3396,31 +3611,29 @@ mod tests { assert_eq!(events.extra_trigger_count(), 1); let expected = format!( "{}:child_only_probe", - qualified_relation_name(&schema, "events_nested_first") + qualified_relation_name(schema, "events_nested_first") ); assert!(events.children.iter().any(|child| { child.name == "events_nested" && child.extra_triggers == [expected.as_str()] })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn catch_all_skips_create_without_catalog_mutation() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_fresh_layout(&pool).await; let before = catalog_snapshot(&pool).await; ensure_future_partitions_at(&pool, 3, true, fixed_now()) .await .expect("covered no-op"); assert_eq!(catalog_snapshot(&pool).await, before); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn kill_switch_audits_but_does_not_create() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -3445,13 +3658,12 @@ mod tests { .months .iter() .all(|month| month.kind == MonthCoverageKind::Uncovered))); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn nested_children_without_leaves_do_not_prove_coverage() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for (table, column) in [("events", "created_at"), ("delivery_log", "delivered_at")] { create_nested_child( @@ -3477,13 +3689,12 @@ mod tests { && table.months[0].kind == MonthCoverageKind::Uncovered })); assert_eq!(catalog_snapshot(&pool).await, before); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn nested_descendant_leaves_prove_coverage_but_remain_degraded() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for (table, column) in [("events", "created_at"), ("delivery_log", "delivered_at")] { let nested = format!("{table}_nested"); @@ -3519,13 +3730,12 @@ mod tests { && table.months[0].kind == MonthCoverageKind::CoveredByMonthly })); assert_eq!(catalog_snapshot(&pool).await, before); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn occupied_nested_catch_all_is_reported_on_leaf_and_root_child() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for (table, column) in [("events", "created_at"), ("delivery_log", "delivered_at")] { let nested = format!("{table}_nested"); @@ -3584,13 +3794,12 @@ mod tests { assert!(delivery_log.coverage_leaves.iter().any(|leaf| { leaf.name == "delivery_log_nested_future" && leaf.catch_all_nonempty == Some(false) })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn nested_different_partition_key_does_not_prove_coverage() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { let nested = format!("{table}_nested"); @@ -3623,13 +3832,12 @@ mod tests { && table.coverage_leaves.is_empty() && table.months[0].kind == MonthCoverageKind::Uncovered })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn audit_pins_non_iso_session_rendering() { - let (pool, admin, schema) = scratch_pool_with_max_connections(1).await; + let (pool, _scratch) = scratch_pool_with_max_connections(1).await; seed_fresh_layout(&pool).await; sqlx::query("SET DateStyle TO 'SQL, DMY'") .execute(&pool) @@ -3658,13 +3866,12 @@ mod tests { .expect("show TimeZone"); assert_eq!(date_style, "SQL, DMY"); assert_eq!(time_zone, "America/Los_Angeles"); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn audit_transaction_applies_and_restores_statement_timeout() { - let (pool, admin, schema) = scratch_pool_with_max_connections(1).await; + let (pool, _scratch) = scratch_pool_with_max_connections(1).await; sqlx::query("SET statement_timeout TO '37s'") .execute(&pool) .await @@ -3692,13 +3899,12 @@ mod tests { .await .expect("show restored statement timeout"); assert_eq!(restored, "37s"); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn statement_triggers_are_not_part_of_leaf_parity() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { sqlx::query(sqlx::AssertSqlSafe(format!( @@ -3734,13 +3940,12 @@ mod tests { && table.missing_trigger_count() == 0 && table.extra_trigger_count() == 0 })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn canonical_name_with_wrong_bounds_is_a_real_creation_error() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -3776,13 +3981,13 @@ mod tests { .find(|table| table.table == "events") .expect("events audit"); assert_eq!(events.months[0].kind, MonthCoverageKind::Uncovered); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn canonical_name_attached_to_wrong_parent_is_a_real_creation_error() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -3833,18 +4038,17 @@ mod tests { ) "#, ) - .bind(&schema) + .bind(schema) .fetch_one(&pool) .await .expect("check attachment"); assert!(!attached_to_events); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn unrelated_canonical_relation_is_a_real_creation_error() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -3869,13 +4073,12 @@ mod tests { assert!(relation_name_exists(&pool, "events_p2026_09") .await .expect("query unrelated relation")); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn anomalous_child_and_trigger_mismatch_degrade_without_aborting() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -3923,7 +4126,6 @@ mod tests { .iter() .any(|child| child.extra_triggers == ["child_only_probe"]) })); - drop_schema(&admin, &schema).await; } } } diff --git a/crates/buzz-db/src/store/partition/maintenance_tests.rs b/crates/buzz-db/src/store/partition/maintenance_tests.rs index 99a1684814e..35091f0d1e8 100644 --- a/crates/buzz-db/src/store/partition/maintenance_tests.rs +++ b/crates/buzz-db/src/store/partition/maintenance_tests.rs @@ -328,8 +328,8 @@ mod postgres_tests { use super::*; use crate::store::partition::tests::postgres_tests::{ - catalog_snapshot, create_child, drop_schema, fixed_now, scratch_pool, - scratch_pool_with_max_connections, seed_parents, + catalog_snapshot, create_child, fixed_now, scratch_pool, scratch_pool_with_max_connections, + seed_parents, }; /// The repaired incident layout for both managed parents, optionally with @@ -441,7 +441,7 @@ mod postgres_tests { #[tokio::test] #[ignore = "requires Postgres"] async fn empty_catch_all_advances_to_canonical_layout_and_is_idempotent() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, true).await; let audit = maintain(&pool).await.expect("advance"); @@ -474,13 +474,12 @@ mod postgres_tests { let before = catalog_snapshot(&pool).await; maintain(&pool).await.expect("idempotent rerun"); assert_eq!(catalog_snapshot(&pool).await, before); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn canonical_catch_all_name_is_replaced_in_one_transaction() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -506,13 +505,12 @@ mod postgres_tests { .children .iter() .any(|child| child.name == format!("{}_p2026_09", table.table)))); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn populated_catch_all_is_refused_before_any_lock() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; sqlx::query("INSERT INTO events (created_at, alternate_at) VALUES ('2027-02-01', now())") .execute(&pool) @@ -544,13 +542,12 @@ mod postgres_tests { .collect::>() }; assert_eq!(events_only(&after), events_only(&before)); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn counterpart_contention_fails_fast_and_rolls_back() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, true).await; let before = catalog_snapshot(&pool).await; let holder = hold_lock( @@ -575,13 +572,12 @@ mod postgres_tests { assert_eq!(catalog_snapshot(&pool).await, before); assert_advanced(&maintain(&pool).await.expect("advance after release")); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn counterpart_reader_does_not_block_advancement() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, true).await; let holder = hold_lock(&pool, "SELECT count(*) FROM communities").await; @@ -589,13 +585,12 @@ mod postgres_tests { drop(holder); assert_advanced(&result.expect("advance beside a counterpart reader")); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn writer_checking_a_foreign_key_behind_maintenance_does_not_deadlock() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; seed_repaired_layout(&pool, true).await; // An in-flight insert: the parent first, then the foreign-key check. let mut writer = hold_lock(&pool, "LOCK TABLE ONLY events IN ROW EXCLUSIVE MODE").await; @@ -615,7 +610,7 @@ mod postgres_tests { WHERE $1 = ANY(pg_blocking_pids(pid)))", ) .bind(writer_pid) - .fetch_one(&admin) + .fetch_one(&scratch.admin) .await .expect("check queued maintenance"); if queued { @@ -650,7 +645,6 @@ mod postgres_tests { .expect("maintenance task") .expect("advance after the writer commits"), ); - drop_schema(&admin, &schema).await; } async fn wait_until_blocked_by(admin: &PgPool, pid: i32, waiters: i64) { @@ -673,7 +667,7 @@ mod postgres_tests { #[tokio::test] #[ignore = "requires Postgres"] async fn foreign_key_committed_while_waiting_on_the_parent_is_locked_nowait() { - let (pool, admin, schema) = scratch_pool_with_max_connections(6).await; + let (pool, scratch) = scratch_pool_with_max_connections(6).await; seed_repaired_layout(&pool, true).await; sqlx::query("CREATE TABLE reviewers (id UUID PRIMARY KEY)") .execute(&pool) @@ -708,12 +702,12 @@ mod postgres_tests { .expect("roll back counterpart writer"); } }); - wait_until_blocked_by(&admin, migration_pid, 1).await; + wait_until_blocked_by(&scratch.admin, migration_pid, 1).await; let maintenance = tokio::spawn({ let pool = pool.clone(); async move { maintain(&pool).await } }); - wait_until_blocked_by(&admin, migration_pid, 2).await; + wait_until_blocked_by(&scratch.admin, migration_pid, 2).await; migration.commit().await.expect("commit migration"); let result = maintenance.await.expect("maintenance task"); @@ -733,7 +727,6 @@ mod postgres_tests { release_writer.send(()).expect("release counterpart writer"); writer.await.expect("counterpart writer task"); assert_advanced(&maintain(&pool).await.expect("advance after release")); - drop_schema(&admin, &schema).await; } async fn backend_pid(connection: &mut PgConnection) -> i32 { @@ -753,7 +746,7 @@ mod postgres_tests { #[tokio::test] #[ignore = "requires Postgres"] async fn foreign_key_referencing_the_parent_committed_while_waiting_is_refused() { - let (pool, admin, schema) = scratch_pool_with_max_connections(6).await; + let (pool, scratch) = scratch_pool_with_max_connections(6).await; seed_repaired_layout(&pool, true).await; sqlx::query( "CREATE TABLE event_refs \ @@ -775,7 +768,7 @@ mod postgres_tests { let pool = pool.clone(); async move { maintain(&pool).await } }); - wait_until_blocked_by(&admin, migration_pid, 1).await; + wait_until_blocked_by(&scratch.admin, migration_pid, 1).await; migration.commit().await.expect("commit migration"); let result = maintenance.await.expect("maintenance task"); @@ -783,13 +776,12 @@ mod postgres_tests { refused_events_only(&result, "events is referenced by a foreign key"), "{result:?}" ); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn catch_all_foreign_keys_outside_the_parent_are_refused() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, true).await; sqlx::query("CREATE TABLE reviewers (id UUID PRIMARY KEY)") .execute(&pool) @@ -842,13 +834,12 @@ mod postgres_tests { } drop(holder); assert_advanced(&maintain(&pool).await.expect("advance without the keys")); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn catch_all_foreign_key_committed_while_waiting_on_the_parent_is_refused() { - let (pool, admin, schema) = scratch_pool_with_max_connections(6).await; + let (pool, scratch) = scratch_pool_with_max_connections(6).await; seed_repaired_layout(&pool, true).await; sqlx::query("CREATE TABLE reviewers (id UUID PRIMARY KEY)") .execute(&pool) @@ -868,7 +859,7 @@ mod postgres_tests { let pool = pool.clone(); async move { maintain(&pool).await } }); - wait_until_blocked_by(&admin, reader_pid, 1).await; + wait_until_blocked_by(&scratch.admin, reader_pid, 1).await; migration.commit().await.expect("commit migration"); reader.commit().await.expect("release parent"); @@ -885,13 +876,12 @@ mod postgres_tests { .await .expect("count catch-all key"); assert_eq!(kept, 1); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn deadlock_victim_is_classified_as_a_lock_timeout() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; let mut first = hold_lock(&pool, "LOCK TABLE ONLY events IN ACCESS EXCLUSIVE MODE").await; let mut second = hold_lock( @@ -925,13 +915,12 @@ mod postgres_tests { PartitionMaintenanceOutcome::LockTimeout ); drop((first, second)); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn parent_contention_times_out_without_blocking_the_other_table() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, true).await; let holder = hold_lock(&pool, "LOCK TABLE ONLY events IN ACCESS SHARE MODE").await; @@ -961,13 +950,12 @@ mod postgres_tests { audited_at: audit.audited_at, tables: delivery_log, }); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn held_maintenance_lock_skips_without_ddl() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; let before = catalog_snapshot(&pool).await; let holder = hold_lock( @@ -982,13 +970,12 @@ mod postgres_tests { .expect("skipped_locked is not a failure"); assert_eq!(catalog_snapshot(&pool).await, before); drop(holder); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn concurrent_runners_converge_on_one_layout() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, true).await; let (first, second) = tokio::join!(maintain(&pool), maintain(&pool)); @@ -1012,13 +999,12 @@ mod postgres_tests { } // Whatever a fail-fast loser left undone, the next pass converges. assert_advanced(&maintain(&pool).await.expect("follow-up pass")); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn client_deadline_rolls_back_and_discards_the_session() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; let before = catalog_snapshot(&pool).await; // The parent lock wait (2s) outlasts the 300ms client deadline. @@ -1050,7 +1036,7 @@ mod postgres_tests { "SELECT pid FROM pg_stat_activity WHERE $1 = ANY(pg_blocking_pids(pid))", ) .bind(holder_pid) - .fetch_all(&admin) + .fetch_all(&scratch.admin) .await .expect("sessions blocked by the holder"); let mut gone = blocked.is_empty(); @@ -1063,7 +1049,7 @@ mod postgres_tests { "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE pid = ANY($1))", ) .bind(&blocked) - .fetch_one(&admin) + .fetch_one(&scratch.admin) .await .expect("check abandoned backend"); gone = !alive; @@ -1086,13 +1072,12 @@ mod postgres_tests { .collect::>() }; assert_eq!(events_only(&after), events_only(&before)); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn catch_all_with_child_only_trigger_is_refused() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; sqlx::query( "CREATE TRIGGER catch_all_only BEFORE INSERT ON events_p_future_next \ @@ -1120,13 +1105,12 @@ mod postgres_tests { .collect::>() }; assert_eq!(events_only(&after), events_only(&before)); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn stale_audit_after_a_concurrent_advance_is_a_noop() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; // A runner audits, then loses the race to a runner that commits first. let stale = audit_partition_catalog_at(&pool, PARTITION_MANAGER_MONTHS_AHEAD, fixed_now()) @@ -1167,13 +1151,12 @@ mod postgres_tests { .await .expect("losing runner"); assert_advanced(&reported); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn replan_that_finds_only_disabled_work_is_skipped_disabled() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; sqlx::query("DROP TABLE events_p_future_next") .execute(&pool) @@ -1210,13 +1193,12 @@ mod postgres_tests { (PartitionMaintenanceOutcome::SkippedDisabled, None) ); assert_eq!(catalog_snapshot(&pool).await, before); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn audit_retries_when_a_listed_partition_is_dropped() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; let mut dropper = pool.begin().await.expect("begin dropper"); let dropper_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") @@ -1242,7 +1224,7 @@ mod postgres_tests { WHERE $1 = ANY(pg_blocking_pids(pid)))", ) .bind(dropper_pid) - .fetch_one(&admin) + .fetch_one(&scratch.admin) .await .expect("check audit queue"); if queued { @@ -1263,13 +1245,12 @@ mod postgres_tests { .children .iter() .all(|child| child.name != "events_p_future_next")); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn unrelated_catch_all_name_is_an_operator_error() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; sqlx::query("CREATE TABLE events_p_future (unrelated BOOLEAN)") .execute(&pool) @@ -1297,13 +1278,12 @@ mod postgres_tests { .filter(|(name, ..)| name.starts_with("events_")) .count() ); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn incoming_foreign_key_is_refused_under_lock() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; sqlx::query( "CREATE TABLE event_refs (created_at TIMESTAMPTZ, alternate_at TIMESTAMPTZ, id BIGINT, \ @@ -1325,6 +1305,5 @@ mod postgres_tests { .iter() .any(|(name, ..)| name == "events_p_future_next")); assert_ne!(after, before, "delivery_log still advances"); - drop_schema(&admin, &schema).await; } } From ea24a3ee3d74679c8c489b83d351409e1b8d503e Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 14 Oct 2026 15:31:05 -0400 Subject: [PATCH 008/144] fix(ci): remove Detect Changed Paths timeout and deletion test race (#8300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary This PR removes two CI flakes that failed relay-stack runs on 2026-10-13. Both passed when rerun. - **Detect Changed Paths timeout.** This first CI job had a 2-minute limit. Its steps take about 75 seconds, but GitHub sometimes finishes the job minutes after the last step. In three runs, every step had finished, but GitHub still cancelled the job at the limit, and all later jobs failed. The limit is now 10 minutes. - **PostgreSQL deletion test.** `approval_boundary_blocks_claim_until_exact_inventory_is_approved` sometimes could not claim an approved deletion request. This was a race in the test, not a product bug. How the test race happens: - The test sends an UPDATE that the database must reject. Then it claims the same row. PostgreSQL sends the error to the client before it rolls back the statement and releases its row lock. sqlx returns as soon as it gets the error. - The claim runs on another pooled connection with `FOR UPDATE ... SKIP LOCKED`. It can skip the row while the lock is still held, and return no claim. - The test now runs the rejected UPDATE on its own connection. Then it sends `SELECT 1` on that connection before it claims. That round trip completes only after PostgreSQL has finished the rollback. Production code does not change. A claim that skips a row locked by another transaction is correct behavior. I checked the other tests in this file. Only this test has a rejected write on the request row immediately before a `SKIP LOCKED` claim. In the other tests, a blocking `approve` call runs before the claim. ### Related issue N/A. No existing PR found. Failing runs: [timeout #8174](https://github.com/block/buzz/actions/runs/38468285648/job/115540525751), [timeout #8176](https://github.com/block/buzz/actions/runs/38468321774/job/115541550557), [timeout #8182](https://github.com/block/buzz/actions/runs/38468329504/job/115541197994), [deletion test #8182](https://github.com/block/buzz/actions/runs/38468329504/job/115550003726). ### Testing - I confirmed the order in the PostgreSQL 17 source: `EmitErrorReport()` (which flushes to the client) runs before `AbortCurrentTransaction()` in `tcop/postgres.c`. A BEFORE UPDATE trigger locks the row first (`GetTupleForTrigger` in `commands/trigger.c`). - I could not reproduce the failure locally: 0 failures in 410 runs, including runs on a CPU-limited PostgreSQL container. The fixed test passes against PostgreSQL 17. Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- .github/workflows/ci.yml | 4 +++- crates/buzz-db/src/store/deletion.rs | 12 +++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1ff1c47fe6..0b7b2a7cd28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,9 @@ jobs: changes: name: Detect Changed Paths runs-on: ubuntu-latest - timeout-minutes: 2 + # The steps take about 75 seconds, but GitHub can finish the job minutes + # after the last step. A 2-minute limit cancelled finished runs. + timeout-minutes: 10 permissions: contents: read pull-requests: read diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index d673c5d8d7e..2196bb2a833 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -6304,17 +6304,27 @@ mod postgres_tests { mismatched_approval.is_err(), "approval digest must remain database-bound to the frozen request digest" ); + // PostgreSQL sends this rejection before it rolls the statement back + // and releases the request row lock, and sqlx returns on the error. + // The claim below uses SKIP LOCKED on another pooled connection, so + // finish a round trip on this connection before claiming. + let mut tamper_conn = db.pool.acquire().await.expect("acquire tamper connection"); let mismatched_request = sqlx::query( "UPDATE community_deletion_requests SET inventory_digest = $2 WHERE id = $1", ) .bind(request.id) .bind(vec![1_u8; 32]) - .execute(&db.pool) + .execute(&mut *tamper_conn) .await; assert!( mismatched_request.is_err(), "the frozen request digest must remain bound to its approval" ); + sqlx::query("SELECT 1") + .execute(&mut *tamper_conn) + .await + .expect("rejected update has rolled back"); + drop(tamper_conn); assert!(store .claim_specific(request.id, "executor-a", DEFAULT_LEASE_DURATION) .await From 7b9551aba830019a875b6fc24b5a25d74eceeb65 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 14 Oct 2026 12:35:58 -0700 Subject: [PATCH 009/144] fix(ios): open push replies in their containing thread (#8294) Tapping a push notification for a thread reply currently opens the channel at its parent. Preserve the verified event's direct-parent thread ID through the iOS notification payload and Flutter bridge so the tap opens the containing thread and highlights that exact reply, even when newer replies have arrived. This matches Inbox's destination selection for nested replies; broadcast replies still open in the channel. Closest related work: https://github.com/block/buzz/pull/6159 (original push-tap routing). ### Validation - Follow-up [5b70e1a](https://github.com/block/buzz/pull/8294/changes/5b70e1a5cb58104b5e17b48493bbde2bb6a6f207) selects the last valid 64-character ASCII-hex reply marker, matching relay ingest. Regression tests failed before the fix; all 80 XCTest and 47 Swift Testing tests passed afterward with Xcode 26.5. [Current-head CI](https://github.com/block/buzz/actions/runs/38607128406) passed, including Flutter tests, Android build, native package tests, complete iOS simulator and unsigned release builds, and native accessibility tests. The [current-head security review](https://github.com/block/buzz/pull/8294#issuecomment-6174205374) reports no findings. Physical evidence below predates this validation-only follow-up. - [Baseline CI on f465f91](https://github.com/block/buzz/actions/runs/38436936153) passed, including Flutter and native package tests, Android build, complete iOS simulator and unsigned release builds, and native accessibility tests. - Full local validation passed on [f465f91](https://github.com/block/buzz/commit/f465f9196fdb6054e302c7ad31ea9de91381c32b), including 3,215 Flutter tests (four skipped) and three unconfigured-push tests. The 49 focused navigation tests also passed after the baseline merge. - Added coverage for cold/warm bridge delivery, exact reply highlighting with newer arrivals, direct-parent and broadcast destinations, and malformed optional thread IDs. The Codex finding for an empty reply marker is fixed and resolved. - Physical acceptance passed on the managed iPhone 12 mini using signed Buzz Dev 10140, built from f465f91 with SDK 26.5. Real push taps and Inbox opened the same containing thread and highlighted the exact reply. An older alert retained its target after a newer reply arrived; a retained alert also worked after terminating the app. Top-level push navigation reached the channel. - Installed in place with the existing identity, app data and pairing retained. No key reset or rotation was performed. Notifications were restored to off and device control released. Xcode 27 hits an unchanged dependency compiler error; the matching Xcode 26.5 toolchain built and signed this tested candidate successfully. ### Physical acceptance | Warm push: older reply A | Inbox: reply B | Cold push: reply B | | --- | --- | --- | | ![Older reply A highlighted with newer B visible](https://raw.githubusercontent.com/block/buzz/87eb00d209cf0b0dd3f4146e3219b15884133b9d/pr-8294--warm-older-reply.png) | ![Inbox highlights reply B](https://raw.githubusercontent.com/block/buzz/87eb00d209cf0b0dd3f4146e3219b15884133b9d/pr-8294--inbox-exact-reply.png) | ![Cold push highlights reply B](https://raw.githubusercontent.com/block/buzz/87eb00d209cf0b0dd3f4146e3219b15884133b9d/pr-8294--cold-push-exact-reply.png) | [Physical test details](https://github.com/block/buzz/pull/8294#issuecomment-6151991840). ### Before and after Synthetic Flutter widget captures with a newer reply already present. | Before: channel at outer parent | After: exact reply in containing thread | | --- | --- | | ![Before](https://raw.githubusercontent.com/block/buzz/d9a0ff6c7a5ea481b04cb9c61d301553b4c23c10/pr-8294--before.png) | ![After](https://raw.githubusercontent.com/block/buzz/d9a0ff6c7a5ea481b04cb9c61d301553b4c23c10/pr-8294--after.png) | After merge, repeat warm/cold push versus Inbox in the integrated mobile candidate, including older alerts, top-level and broadcast notifications. --------- Signed-off-by: Tom Brow --- .../BuzzPushNavigationTarget.swift | 37 +++++++- .../BuzzPushNotificationResolver.swift | 3 +- .../BuzzPushNavigationTargetTests.swift | 62 +++++++++++++ .../BuzzPushNotificationResolverTests.swift | 31 +++++++ mobile/ios/Runner/AppDelegate.swift | 4 +- mobile/lib/shared/push/push_bridge.dart | 6 +- .../channels/channel_detail_page_test.dart | 86 +++++++++++++++++++ mobile/test/shared/push/push_bridge_test.dart | 53 ++++++++++++ 8 files changed, 275 insertions(+), 7 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift index 9218655021a..c6cb813648c 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift @@ -9,18 +9,43 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { public let communityID: String public let channelID: String - public init(eventID: String, communityID: String, channelID: String) { + /// Direct parent whose thread contains the target reply, matching Inbox. + public let threadRootID: String? + + public init( + eventID: String, communityID: String, channelID: String, threadRootID: String? = nil + ) { self.eventID = eventID self.communityID = communityID self.channelID = channelID + self.threadRootID = threadRootID } public var userInfoValue: [String: String] { - [ + var value = [ "event_id": eventID, "community_id": communityID, "channel_id": channelID, ] + value["thread_root_id"] = threadRootID + return value + } + + static func threadRootID(in tags: [[String]]) -> String? { + // Inbox opens broadcast replies in the channel and other replies in their + // direct parent's thread, even when the NIP-10 root names an outer thread. + if tags.contains(where: { $0.count >= 2 && $0[0] == "broadcast" && $0[1] == "1" }) { + return nil + } + // Match relay ingest: the last valid 64-character ASCII-hex reply ID wins. + // A later malformed marker must not hide an earlier valid parent. + return tags.last(where: { tag in + guard tag.count >= 4, tag[0] == "e", tag[3] == "reply" else { return false } + let bytes = tag[1].utf8 + return bytes.count == 64 && bytes.allSatisfy { + (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) + } + })?[1] } /// Decodes a target without trusting other fields from the APNs payload. @@ -28,7 +53,7 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { from userInfo: [AnyHashable: Any] ) -> BuzzPushNavigationTarget? { guard let raw = userInfo[userInfoKey] as? [String: Any], - raw.count == 3, + Set(raw.keys).isSubset(of: ["event_id", "community_id", "channel_id", "thread_root_id"]), let eventID = raw["event_id"] as? String, let communityID = raw["community_id"] as? String, let channelID = raw["channel_id"] as? String, @@ -38,10 +63,14 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { else { return nil } + if let rawThread = raw["thread_root_id"] { + guard let thread = rawThread as? String, !thread.isEmpty else { return nil } + } return BuzzPushNavigationTarget( eventID: eventID, communityID: communityID, - channelID: channelID + channelID: channelID, + threadRootID: raw["thread_root_id"] as? String ) } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index 7836385a773..d1957480210 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -462,7 +462,8 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { BuzzPushNavigationTarget( eventID: event.id, communityID: community.id, - channelID: $0 + channelID: $0, + threadRootID: BuzzPushNavigationTarget.threadRootID(in: event.tags) ) }, senderPubkey: event.pubkey.lowercased(), diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift index 414e9446a2e..c2bba98b7d3 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift @@ -75,3 +75,65 @@ import Testing #expect(buffer.take() == first) #expect(buffer.take() == nil) } + +@Test func `Reply target preserves containing thread through user info and cold-start buffer`() { + let target = BuzzPushNavigationTarget( + eventID: "reply", communityID: "community", channelID: "channel", threadRootID: "parent" + ) + let decoded = BuzzPushNavigationTarget.decodeIfPresent( + from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue] + ) + #expect(decoded == target) + let buffer = BuzzPushNavigationBuffer() + buffer.record(target) + #expect(buffer.take() == target) +} + +@Test func `Reject malformed optional thread and unknown navigation fields`() { + let cases: [[String: Any]] = [ + ["thread_root_id": ""], ["thread_root_id": 42], ["unknown": "value"], + ] + for extra in cases { + var raw: [String: Any] = [ + "event_id": "reply", "community_id": "community", "channel_id": "channel", + ] + raw.merge(extra) { _, new in new } + #expect(BuzzPushNavigationTarget.decodeIfPresent( + from: [BuzzPushNavigationTarget.userInfoKey: raw] + ) == nil) + } +} + +@Test func `Empty reply marker preserves channel navigation`() { + let thread = BuzzPushNavigationTarget.threadRootID(in: [["e", "", "", "reply"]]) + #expect(thread == nil) + let target = BuzzPushNavigationTarget( + eventID: "message", communityID: "community", channelID: "channel", threadRootID: thread + ) + #expect(BuzzPushNavigationTarget.decodeIfPresent( + from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue] + ) == target) +} + +@Test func `Thread destination keeps direct parent and broadcast semantics`() { + let parent = String(repeating: "a", count: 64) + let tags = [["e", "outer-root", "", "root"], ["e", parent, "", "reply"]] + #expect(BuzzPushNavigationTarget.threadRootID(in: tags) == parent) + #expect(BuzzPushNavigationTarget.threadRootID(in: tags + [["broadcast", "1"]]) == nil) + #expect(BuzzPushNavigationTarget.threadRootID(in: [["e", "outer-root", "", "root"]]) == nil) +} + +@Test func `Last valid reply marker wins despite trailing malformed markers`() { + let first = String(repeating: "a", count: 64) + let last = String(repeating: "B", count: 64) + let invalidIDs = ["", "bad", String(repeating: "a", count: 63), + String(repeating: "a", count: 65), String(repeating: "g", count: 64), + String(repeating: "é", count: 64)] + for invalid in invalidIDs { + let invalidTag = ["e", invalid, "", "reply"] + #expect(BuzzPushNavigationTarget.threadRootID(in: [invalidTag]) == nil) + #expect(BuzzPushNavigationTarget.threadRootID(in: [ + ["e", first, "", "reply"], ["e", last, "", "reply"], invalidTag, + ]) == last) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift index 6620f193699..8d8de632be7 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -162,6 +162,37 @@ final class BuzzPushNotificationResolverTests: XCTestCase { } } + func testReplyNavigationUsesDirectParentAndKeepsExactEvent() throws { + let parent = String(repeating: "a", count: 64) + let reply = event(id: "reply-id", content: "Reply preview", tags: [ + ["h", Self.channelID], + ["e", "outer-root", "", "root"], + ["e", parent, "", "reply"], + ["e", "bad", "", "reply"], + ["e", "", "", "reply"], + ]) + let resolution = try XCTUnwrap(BuzzPushNotificationResolver.decodeResolution( + events: [reply], community: community() + )?.0) + let target = try XCTUnwrap(resolution.navigationTarget) + XCTAssertEqual(target.eventID, "reply-id") + XCTAssertEqual(target.userInfoValue["thread_root_id"], parent) + } + + func testNavigationKeepsTopLevelAndBroadcastMessagesInChannel() throws { + for tags in [ + [["h", Self.channelID]], + [["h", Self.channelID], ["e", "mention", "", "mention"]], + [["h", Self.channelID], ["e", "root", "", "root"]], + [["h", Self.channelID], ["e", "parent", "", "reply"], ["broadcast", "1"]], + ] { + let resolution = try XCTUnwrap(BuzzPushNotificationResolver.decodeResolution( + events: [event(content: "Preview", tags: tags)], community: community() + )?.0) + XCTAssertNil(resolution.navigationTarget?.threadRootID) + } + } + func testResolveSucceedsAndMutatesGatewayContent() throws { let event = try JSONDecoder().decode( VerifiedNostrEvent.self, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 4d1b95c3868..ce7dc6e5431 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1074,10 +1074,12 @@ import os.log extension BuzzPushNavigationTarget { fileprivate var flutterArguments: [String: String] { - [ + var arguments = [ "eventId": eventID, "communityId": communityID, "channelId": channelID, ] + arguments["threadRootId"] = threadRootID + return arguments } } diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 49d2c96cde4..dc11f0e5f71 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -106,18 +106,22 @@ MessageDeepLink? _pushNotificationLink(Object? arguments) { final eventId = arguments['eventId']; final communityId = arguments['communityId']; final channelId = arguments['channelId']; + final threadRootId = arguments['threadRootId']; if (eventId is! String || eventId.isEmpty || communityId is! String || communityId.isEmpty || channelId is! String || - channelId.isEmpty) { + channelId.isEmpty || + (threadRootId != null && + (threadRootId is! String || threadRootId.isEmpty))) { return null; } return MessageDeepLink( communityId: communityId, channelId: channelId, messageId: eventId, + threadRootId: threadRootId as String?, ); } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index f7685643911..cf746acde5c 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:buzz/shared/push/push_bridge.dart'; import 'package:buzz/features/channels/channel_actions_sheet.dart'; import '../profile/presence_snapshot_test.dart' @@ -11415,6 +11416,91 @@ void main() { }); }); + testWidgets('push tap highlights its exact reply after newer arrivals', ( + tester, + ) async { + pendingPushNotificationLink.value = null; + installBuzzPushMethodHandler(); + addTearDown(() { + pendingPushNotificationLink.value = null; + }); + const channel = MethodChannel('buzz/push'); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall( + const MethodCall('notificationOpened', { + 'eventId': 'target', + 'communityId': 'community', + 'channelId': _channelId, + 'threadRootId': 'parent', + }), + ), + (_) {}, + ); + final link = pendingPushNotificationLink.value!; + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Outer root', + createdAt: 1000, + ); + final parent = _textMsg( + id: 'parent', + pubkey: 'alice', + content: 'Containing thread', + createdAt: 1100, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final target = _textMsg( + id: 'target', + pubkey: 'alice', + content: 'Notification reply', + createdAt: 1200, + extraTags: const [ + ['e', 'root', '', 'root'], + ['e', 'parent', '', 'reply'], + ], + ); + final newer = _textMsg( + id: 'newer', + pubkey: 'alice', + content: 'Newer reply', + createdAt: 1300, + extraTags: const [ + ['e', 'root', '', 'root'], + ['e', 'parent', '', 'reply'], + ], + ); + await tester.pumpWidget( + _buildTestable( + messages: [root, parent, target, newer], + initialMessageId: link.messageId, + initialThreadRootId: link.threadRootId, + threadReplies: { + 'root': [parent, target, newer], + }, + ), + ); + await tester.pumpAndSettle(); + final page = tester.widget( + find.byType(ThreadDetailPage), + ); + expect(page.threadHead.id, 'parent'); + expect(page.initialMessageId, 'target'); + final decoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(decoration.color!.a, closeTo(0.12, 0.001)); + expect(find.text('Newer reply'), findsOneWidget); + }); + testWidgets('opens a nested reply in its direct-parent thread', ( tester, ) async { diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index 1c76b9a69be..926cb48b5df 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -340,6 +340,57 @@ void main() { ); }); + test( + 'preserves the exact reply and containing thread on a warm tap', + () async { + final response = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + const MethodCall('notificationOpened', { + 'eventId': 'older-reply', + 'communityId': 'community-id', + 'channelId': 'channel-id', + 'threadRootId': 'direct-parent', + }), + ), + response.complete, + ); + expect( + _channel.codec.decodeEnvelope((await response.future)!), + 'handled', + ); + expect( + pendingPushNotificationLink.value, + const MessageDeepLink( + communityId: 'community-id', + channelId: 'channel-id', + messageId: 'older-reply', + threadRootId: 'direct-parent', + ), + ); + }, + ); + + for (final invalidThread in ['', 42]) { + test('rejects malformed thread ID $invalidThread', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _channel, + (_) async => { + 'eventId': 'reply', + 'communityId': 'community', + 'channelId': 'channel', + 'threadRootId': invalidThread, + }, + ); + await syncPendingBuzzPushNotificationResponse(); + expect(pendingPushNotificationLink.value, isNull); + }); + } + test('rejects a notification response with an empty channel ID', () async { final response = Completer(); await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -412,6 +463,7 @@ void main() { expect(call.method, 'takePendingNotificationResponse'); return { 'eventId': 'b' * 64, + 'threadRootId': 'a' * 64, 'communityId': 'community-id', 'channelId': '123e4567-e89b-42d3-a456-426614174000', }; @@ -425,6 +477,7 @@ void main() { communityId: 'community-id', channelId: '123e4567-e89b-42d3-a456-426614174000', messageId: 'b' * 64, + threadRootId: 'a' * 64, ), ); }); From 231a469de5652ce425782e98fcd011881fb24fe8 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 14 Oct 2026 16:35:26 -0400 Subject: [PATCH 010/144] Refresh mobile message bubbles, reactions, and activity cards (#8281) ## Summary Refresh mobile conversations with left-aligned grouped bubbles without tails, tighter consecutive-message spacing, distinct sender colors, theme-aware incoming backgrounds, and compact overlapping reaction trays. Attach reply counts beside reactions with up to three participant avatars; keep outgoing mentions and links readable in both themes. Coordinate a 500 ms composer-to-message transition with spring contraction and an anchored avatar. Message ownership follows the signing identity so profile reloads cannot recolor outgoing bubbles. Center channel activities without timestamps or icons, keep names tappable, and show two affected members plus a **See all** sheet. Hide legacy purpose activities, match media corners to bubble groups, and add iOS status-bar scroll-to-top. This is an independent mobile PR; no desktop changes or color picker. ## Validation - Full `just mobile-check` passed; `just mobile-test` passed 3,275 tests with 4 skips, plus 3 unconfigured-gateway checks. - Fixed message-widget lint warnings and a stale notification fixture that blocked analysis. Made the video-controller disposal test wait for lifecycle events instead of fixed delays. Mobile pre-push checks are enabled. - Physical iPhone previews were installed throughout development. The latest bubble, reply-avatar, contrast, and spacing updates were installed and launched on the physical iPhone. Final motion and status-bar behavior still need device visual verification; Android has automated coverage but no physical-device verification. --------- Signed-off-by: kenny lopez --- mobile/ios/Runner/IosNavigationBar.swift | 2 + ...NativeMessagePresentationCoordinator.swift | 16 +- mobile/ios/Runner/NativeReactionDetails.swift | 41 + .../channels/channel_detail_page.dart | 13 +- .../activity_members_sheet.dart | 81 ++ .../channel_detail_page/message_bubble.dart | 403 +++++----- .../channel_detail_page/message_list.dart | 84 +- .../oldest_navigation.dart | 88 +++ .../channel_detail_page/system_rows.dart | 574 ++++++-------- mobile/lib/features/channels/compose_bar.dart | 1 + .../compose_bar/compose_bar_widget.dart | 15 +- .../features/channels/compose_bar/layout.dart | 33 +- .../channels/composer_send_origin.dart | 23 + ...local_message_send_animation_provider.dart | 20 +- .../local_message_send_transition.dart | 130 +++- .../channels/local_send_bubble_surface.dart | 148 ++++ .../lib/features/channels/local_send_row.dart | 80 ++ .../features/channels/message_actions.dart | 5 + .../message_actions/quick_reaction_row.dart | 22 +- .../features/channels/message_content.dart | 155 +++- .../message_content/bubble_segments.dart | 58 ++ .../message_content/bubble_shape.dart | 49 ++ .../message_content/media_carousel.dart | 8 +- .../channels/message_footer_layout.dart | 128 +++ .../lib/features/channels/reaction_row.dart | 122 ++- .../features/channels/thread_detail_page.dart | 9 +- .../thread_detail_page/message_list.dart | 40 + .../thread_detail_page/thread_message.dart | 375 +++++---- .../features/channels/timeline_message.dart | 38 +- .../channels/voice_note_attachment.dart | 5 +- .../lib/shared/emoji/native_emoji_glyph.dart | 39 +- .../lib/shared/widgets/frosted_scaffold.dart | 49 +- .../lib/shared/widgets/ios_scroll_to_top.dart | 136 ++++ .../widgets/message_attachment_corners.dart | 21 + .../shared/widgets/message_author_meta.dart | 26 +- .../channels/channel_detail_page_test.dart | 724 ++++++++++------- .../action_row_tests.dart | 71 +- .../bubble_tests.dart | 733 ++++++++++++++++++ .../features/channels/compose_bar_test.dart | 74 ++ .../channels/composer_send_origin_test.dart | 73 ++ .../local_message_send_transition_test.dart | 191 +++-- .../local_send_bubble_surface_test.dart | 230 ++++++ .../media_viewer_page/video_viewer_test.dart | 19 +- .../message_content_bubble_cases.dart | 273 +++++++ .../channels/message_content_test.dart | 7 +- .../channels/message_footer_layout_test.dart | 120 +++ .../features/channels/reaction_row_test.dart | 156 ++-- .../reaction_row_test/combined_tests.dart | 297 +++++++ .../channels/timeline_message_test.dart | 2 +- .../widgets/ios_scroll_to_top_test.dart | 166 ++++ 50 files changed, 4885 insertions(+), 1288 deletions(-) create mode 100644 mobile/lib/features/channels/channel_detail_page/activity_members_sheet.dart create mode 100644 mobile/lib/features/channels/channel_detail_page/oldest_navigation.dart create mode 100644 mobile/lib/features/channels/composer_send_origin.dart create mode 100644 mobile/lib/features/channels/local_send_bubble_surface.dart create mode 100644 mobile/lib/features/channels/local_send_row.dart create mode 100644 mobile/lib/features/channels/message_content/bubble_segments.dart create mode 100644 mobile/lib/features/channels/message_content/bubble_shape.dart create mode 100644 mobile/lib/features/channels/message_footer_layout.dart create mode 100644 mobile/lib/shared/widgets/ios_scroll_to_top.dart create mode 100644 mobile/lib/shared/widgets/message_attachment_corners.dart create mode 100644 mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart create mode 100644 mobile/test/features/channels/composer_send_origin_test.dart create mode 100644 mobile/test/features/channels/local_send_bubble_surface_test.dart create mode 100644 mobile/test/features/channels/message_content_bubble_cases.dart create mode 100644 mobile/test/features/channels/message_footer_layout_test.dart create mode 100644 mobile/test/features/channels/reaction_row_test/combined_tests.dart create mode 100644 mobile/test/shared/widgets/ios_scroll_to_top_test.dart diff --git a/mobile/ios/Runner/IosNavigationBar.swift b/mobile/ios/Runner/IosNavigationBar.swift index 99429234093..614796e8308 100644 --- a/mobile/ios/Runner/IosNavigationBar.swift +++ b/mobile/ios/Runner/IosNavigationBar.swift @@ -212,6 +212,7 @@ private final class NavigationContentController: UIViewController { view = scrollView scrollView.contentSize = CGSize(width: 1, height: 10000) scrollView.isUserInteractionEnabled = false + scrollView.scrollsToTop = false scrollView.backgroundColor = .clear if #available(iOS 26.0, *) { // Configure the native effect before UIKit lays out the bar; the @@ -269,6 +270,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { if #available(iOS 27.0, *) { edgeScrollView.accessibilityIdentifier = "navigation-status-edge" edgeScrollView.isUserInteractionEnabled = false + edgeScrollView.scrollsToTop = false edgeScrollView.accessibilityElementsHidden = true edgeScrollView.backgroundColor = .clear edgeScrollView.contentInsetAdjustmentBehavior = .never diff --git a/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift b/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift index 4b7821dcd3d..1f0d8dd1b85 100644 --- a/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift +++ b/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift @@ -8,6 +8,7 @@ final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentati private weak var presented: UIViewController? private var pendingResult: FlutterResult? private var requestID: String? + private var terminalValue: [String: String]? init(messenger: FlutterBinaryMessenger, parentViewController: UIViewController?) { channel = FlutterMethodChannel(name: "buzz/native_message_presentation", binaryMessenger: messenger) @@ -74,6 +75,9 @@ final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentati } else { let sheet = NativeReactionDetailsViewController(data: data) sheet.onClose = { [weak self] in self?.dismiss([:]) } + sheet.onAddReaction = { [weak self] in + self?.dismiss(["action": "addReaction"]) + } sheet.onToggle = { [weak self] emoji in self?.channel.invokeMethod("toggleReaction", arguments: [ "requestId": data["requestId"] as? String ?? "", "emoji": emoji, @@ -98,8 +102,13 @@ final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentati } private func dismiss(_ value: [String: String]) { + guard pendingResult != nil, terminalValue == nil else { return } + terminalValue = value guard let presented else { complete(value); return } - let completion: () -> Void = { [weak self] in self?.complete(value) } + let completion: () -> Void = { [weak self] in + guard let self, self.presented === presented else { return } + self.complete(value) + } if let menu = presented as? NativeMessageMenuViewController { menu.animateOut { menu.dismiss(animated: false, completion: completion) @@ -110,14 +119,17 @@ final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentati } func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + guard presented === presentationController.presentedViewController else { return } complete([:]) } private func complete(_ value: [String: String]) { let result = pendingResult + let terminal = terminalValue ?? value + terminalValue = nil pendingResult = nil presented = nil requestID = nil - result?(value) + result?(terminal) } } diff --git a/mobile/ios/Runner/NativeReactionDetails.swift b/mobile/ios/Runner/NativeReactionDetails.swift index 8d6361e92c6..2b445ad9306 100644 --- a/mobile/ios/Runner/NativeReactionDetails.swift +++ b/mobile/ios/Runner/NativeReactionDetails.swift @@ -1,9 +1,13 @@ import UIKit import ImageIO +import CoreText /// Native reaction toggles above the complete list of reactors. final class NativeReactionDetailsViewController: UIViewController, UITableViewDataSource { var onClose: (() -> Void)? + var onAddReaction: (() -> Void)? + private let canAddReaction: Bool + private let addReactionIconCodePoint: Int var onToggle: ((String) -> Void)? private let sheetColor: UIColor private let foregroundColor: UIColor @@ -32,6 +36,8 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa } sheetColor = color("sheetColor", fallback: .systemGroupedBackground) foregroundColor = color("foregroundColor", fallback: .label) + canAddReaction = data["canAddReaction"] as? Bool == true + addReactionIconCodePoint = data["addReactionIconCodePoint"] as? Int ?? 0 outlineColor = color("outlineColor", fallback: .separator) reactions = data["reactions"] as? [[String: Any]] ?? [] profiles = data["profiles"] as? [String: [String: Any]] ?? [:] @@ -130,8 +136,43 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa } } + // Use the exact bundled Tabler font that backs BuzzIcons in Flutter. + private static let tablerFont: UIFont? = { + guard let frameworks = Bundle.main.privateFrameworksURL, + let provider = CGDataProvider(url: frameworks.appendingPathComponent( + "App.framework/flutter_assets/assets/fonts/TablerIcons.ttf") as CFURL), + let font = CGFont(provider), let name = font.postScriptName else { return nil } + CTFontManagerRegisterGraphicsFont(font, nil) + return UIFont(name: name as String, size: 24) + }() + + private var addReactionImage: UIImage? { + guard let font = Self.tablerFont, + let scalar = UnicodeScalar(addReactionIconCodePoint) else { return nil } + let glyph = NSAttributedString(string: String(scalar), attributes: [.font: font]) + let size = glyph.size() + return UIGraphicsImageRenderer(size: CGSize(width: 28, height: 28)).image { _ in + glyph.draw(at: CGPoint(x: (28 - size.width) / 2, y: (28 - size.height) / 2)) + }.withRenderingMode(.alwaysTemplate) + } + private func rebuildFilters() { for child in filters.arrangedSubviews { child.removeFromSuperview() } + if canAddReaction { + let add = UIButton(type: .system) + var config = UIButton.Configuration.gray() + config.image = addReactionImage + if config.image == nil { config.title = "Add reaction" } + config.cornerStyle = .capsule + config.baseForegroundColor = foregroundColor + add.configuration = config + add.accessibilityLabel = "Add reaction" + add.accessibilityIdentifier = "reaction-sheet-add" + add.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true + add.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true + add.addAction(UIAction { [weak self] _ in self?.onAddReaction?() }, for: .touchUpInside) + filters.addArrangedSubview(add) + } for reaction in reactions { guard let emoji = reaction["emoji"] as? String else { continue } addFilter(title: "\(reaction["count"] as? Int ?? 0)", emoji: emoji, reaction: reaction) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index b25edaa883d..f8506a57754 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,3 +1,6 @@ +import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/widgets/ios_scroll_to_top.dart'; +import 'message_footer_layout.dart'; import 'dart:async'; import 'dart:math' show cos, max, min, pi; import 'dart:ui' show ImageFilter, lerpDouble; @@ -30,7 +33,6 @@ import '../../shared/widgets/load_error_view.dart'; import '../../shared/widgets/message_author_meta.dart'; import '../../shared/widgets/skeleton.dart'; import '../profile/presence_cache_provider.dart'; -import '../profile/profile_provider.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/profile/user_profile.dart'; import '../forum/forum_posts_view.dart'; @@ -65,7 +67,6 @@ import 'message_actions.dart'; import 'message_action_backdrop_state.dart'; import 'message_long_press_region.dart'; import 'message_content.dart'; -import 'message_mention_pill.dart'; import 'message_skeleton_body.dart'; import '../../shared/read_state/deferred_read_state_update.dart'; import '../../shared/read_state/read_state_provider.dart'; @@ -81,7 +82,9 @@ import 'thread_replies_provider.dart'; import 'timeline_message.dart'; part 'channel_detail_page/message_list.dart'; +part 'channel_detail_page/oldest_navigation.dart'; part 'channel_detail_page/system_rows.dart'; +part 'channel_detail_page/activity_members_sheet.dart'; part 'channel_detail_page/huddle_sheet.dart'; part 'channel_detail_page/huddle_call_avatar.dart'; part 'channel_detail_page/huddle_participant_cluster.dart'; @@ -323,10 +326,8 @@ class ChannelDetailPage extends HookConsumerWidget { initialOldestOrdinaryUnreadMessageIdRef.value; final initialForcedUnreadMessageIds = initialForcedUnreadMessageIdsRef.value; - final currentPubkey = ref - .watch(profileProvider) - .whenData((value) => value?.pubkey) - .value; + // Ownership comes from the signing identity, not reloadable profile metadata. + final currentPubkey = ref.watch(currentPubkeyProvider); // Only show channel-level typing (exclude thread-scoped entries and self). final typingEntries = ref .watch(channelTypingProvider(channel.id)) diff --git a/mobile/lib/features/channels/channel_detail_page/activity_members_sheet.dart b/mobile/lib/features/channels/channel_detail_page/activity_members_sheet.dart new file mode 100644 index 00000000000..69a38f128ba --- /dev/null +++ b/mobile/lib/features/channels/channel_detail_page/activity_members_sheet.dart @@ -0,0 +1,81 @@ +part of '../channel_detail_page.dart'; + +class _ActivityMembersSheet extends ConsumerWidget { + const _ActivityMembersSheet({required this.pubkeys, required this.channelId}); + + final List pubkeys; + final String channelId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + Grid.md + MediaQuery.viewPaddingOf(context).bottom, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: Material( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.container), + clipBehavior: Clip.antiAlias, + child: ListTileTheme.merge( + contentPadding: const EdgeInsets.symmetric(horizontal: Grid.xs), + minLeadingWidth: 22, + horizontalTitleGap: Grid.xs, + child: ListView.separated( + key: const ValueKey('activity-members-list'), + shrinkWrap: true, + cacheExtent: 0, + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + itemCount: pubkeys.length, + separatorBuilder: (context, index) => Divider( + height: 1, + thickness: 1, + indent: Grid.xs * 2 + 22, + endIndent: Grid.xs, + color: context.colors.onSurface.withValues(alpha: 0.07), + ), + itemBuilder: (context, index) => _ActivityMemberRow( + key: ValueKey('activity-member-${pubkeys[index]}'), + pubkey: pubkeys[index], + channelId: channelId, + ), + ), + ), + ), + ), + ); + } +} + +class _ActivityMemberRow extends HookConsumerWidget { + const _ActivityMemberRow({ + super.key, + required this.pubkey, + required this.channelId, + }); + final String pubkey; + final String channelId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cache = ref.watch(userCacheProvider); + final names = ref.watch(channelIdentityNamesProvider(channelId)); + useEffect(() { + ref.read(userCacheProvider.notifier).preload([pubkey]); + return null; + }, [pubkey]); + return ListTile( + leading: SmallAvatar(pubkey: pubkey, userCache: cache, size: 40), + title: Text(names.labelFor(pubkey)), + onTap: () => showUserProfileSheet( + context, + pubkey, + names: channelIdentityNamesProvider(channelId), + ), + ); + } +} diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 41af87571e0..bda5701e622 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -3,7 +3,9 @@ part of '../channel_detail_page.dart'; class _MessageBubble extends HookConsumerWidget { final TimelineMessage message; final bool showAuthor; + final bool showAvatar; final bool hasReplies; + final Widget? replyFooter; final Map channelNames; final String currentChannelId; final String? currentPubkey; @@ -16,7 +18,9 @@ class _MessageBubble extends HookConsumerWidget { const _MessageBubble({ required this.message, required this.showAuthor, + this.showAvatar = true, required this.hasReplies, + this.replyFooter, required this.channelNames, required this.currentChannelId, required this.currentPubkey, @@ -153,19 +157,223 @@ class _MessageBubble extends HookConsumerWidget { ), child: Padding( padding: EdgeInsets.only( - top: showAuthor ? 0 : Grid.xxs, - bottom: showAuthor ? 0 : Grid.xxs, + bottom: showAvatar ? Grid.quarter : Grid.quarter / 2, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - RepaintBoundary( - key: messageSnapshotKey, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - GestureDetector( + child: MessageFooterLayout( + animateSize: true, + overlap: message.reactions.isNotEmpty || replyFooter != null + ? Grid.xs + : 0, + footer: message.reactions.isNotEmpty || replyFooter != null + ? Padding( + padding: const EdgeInsets.only( + left: + messageAvatarSize + + messageAvatarContentGap + + Grid.xxs, + ), + child: Wrap( + spacing: Grid.xxs, + runSpacing: Grid.half, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (message.reactions.isNotEmpty) + ReactionRow( + combined: true, + messageId: message.id, + channelId: currentChannelId, + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), + ), + if (replyFooter != null) + Padding( + padding: const EdgeInsets.only(bottom: Grid.xxs), + child: replyFooter, + ), + ], + ), + ) + : const SizedBox.shrink(), + child: RepaintBoundary( + key: messageSnapshotKey, + child: Stack( + clipBehavior: Clip.none, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? Grid.half : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, + left: Grid.xs, + ), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + showTimestamp: true, + nameStyle: messageUsernameTextStyle + .copyWith( + fontSize: 13.1, + height: 17 / 13.1, + ), + metadataStyle: + messageMetadataTextStyle + .copyWith( + fontSize: 13.1, + height: 17 / 13.1, + ), + timestampStyle: + messageTimestampTextStyle + .copyWith( + fontSize: 11, + height: 17 / 11, + ), + displayName: displayName, + username: messageUsernameLabel( + profile, + ), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: context.colors.onSurface, + metadataColor: + context.colors.onSurfaceVariant, + onAuthorTap: () => + showUserProfileSheet( + context, + message.pubkey, + names: + channelIdentityNamesProvider( + currentChannelId, + ), + ), + displayNameKey: ValueKey( + 'message-author-${message.id}', + ), + usernameKey: ValueKey( + 'message-username-${message.id}', + ), + timestampKey: ValueKey( + 'message-timestamp-${message.id}', + ), + ), + ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + MessageContent( + content: message.content, + bubble: true, + hasReactions: + message.reactions.isNotEmpty || + replyFooter != null, + bubbleEnd: showAvatar, + bubbleStart: showAuthor, + ownMessage: + currentPubkey?.toLowerCase() == pk, + mentionNames: resolvedMentionNames, + mentionLabels: mentionLabels, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + scaleEmojiOnly: true, + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: currentChannelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: currentChannelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of( + viewerContext, + ).maybePop(); + } + }, + ), + onChannelTap: (channelId) { + openChannelLink( + context: context, + ref: ref, + channelId: channelId, + currentChannelId: currentChannelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet( + context, + pubkey, + names: channelIdentityNamesProvider( + currentChannelId, + ), + ), + ), + ], + ), + ), + ), + ], + ), + if (showAvatar) + Positioned( + left: 0, + bottom: 0, + child: GestureDetector( onTap: () => showUserProfileSheet( context, message.pubkey, @@ -178,160 +386,11 @@ class _MessageBubble extends HookConsumerWidget { pubkey: message.pubkey, isAgent: isAgent, ), - ) - else - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Padding( - padding: EdgeInsets.only( - top: showAuthor ? Grid.half : 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.quarter, - ), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - displayName: displayName, - username: messageUsernameLabel( - profile, - ), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: context.colors.onSurface, - metadataColor: - context.colors.onSurfaceVariant, - onAuthorTap: () => - showUserProfileSheet( - context, - message.pubkey, - names: - channelIdentityNamesProvider( - currentChannelId, - ), - ), - displayNameKey: ValueKey( - 'message-author-${message.id}', - ), - usernameKey: ValueKey( - 'message-username-${message.id}', - ), - timestampKey: ValueKey( - 'message-timestamp-${message.id}', - ), - ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall - ?.copyWith( - color: context - .colors - .onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ], - ], - ), - ), - MessageContent( - content: message.content, - mentionNames: resolvedMentionNames, - mentionLabels: mentionLabels, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - scaleEmojiOnly: true, - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: currentChannelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, - ref: ref, - message: message, - channelId: currentChannelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of( - viewerContext, - ).maybePop(); - } - }, - ), - onChannelTap: (channelId) { - openChannelLink( - context: context, - ref: ref, - channelId: channelId, - currentChannelId: currentChannelId, - ); - }, - onMentionTap: (pubkey) => showUserProfileSheet( - context, - pubkey, - names: channelIdentityNamesProvider( - currentChannelId, - ), - ), - ), - ], - ), ), ), - ], - ), + ], ), - if (message.reactions.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - left: messageAvatarSize + messageAvatarContentGap, - ), - child: ReactionRow( - messageId: message.id, - channelId: currentChannelId, - reactions: message.reactions, - onToggle: (emoji) => toggleReaction(ref, message, emoji), - showAddButton: isMember && !isArchived, - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, - ), - ), - ), - ], + ), ), ), ), @@ -340,36 +399,20 @@ class _MessageBubble extends HookConsumerWidget { } } -Widget _messageTimestamp(BuildContext context, int createdAt, {Key? key}) { - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: Grid.xxl), - child: Text( - key: key, - formatMessageTime(createdAt), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: messageTimestampTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ); -} - class _UserAvatar extends StatelessWidget { final UserProfile? profile; final String pubkey; final bool isAgent; - final double size; const _UserAvatar({ required this.profile, required this.pubkey, required this.isAgent, - this.size = messageAvatarSize, }); @override Widget build(BuildContext context) { + const size = messageAvatarSize; final initial = profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); final avatarUrl = profile?.avatarUrl; diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index 2c18dcf7862..2bafc7330de 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -53,6 +53,9 @@ class _MessageList extends HookConsumerWidget { final stickyDayTimestamp = useValueNotifier(null); final timelineViewportHeight = useRef(MediaQuery.sizeOf(context).height); final isLoadingOlder = useState(false); + final navigatingOldest = useRef(false); + final olderFetchPaused = useRef(false); + final oldestNavigationGeneration = useRef(0); final isAtLatest = useState(true); final settledImeBottomInset = useState( usesFixedAndroidImeViewport @@ -346,6 +349,14 @@ class _MessageList extends HookConsumerWidget { try { final targetIndex = reversedIndexOf(latestNavigationTargetId.value) ?? 0; + if (latestNavigationTargetId.value != null) { + itemScrollController.jumpTo( + index: targetIndex, + // Index zero already includes the composer padding. + alignment: targetIndex == 0 ? 0 : latestAlignment(), + ); + return; + } await itemScrollController.scrollTo( index: targetIndex, alignment: latestAlignment(), @@ -506,7 +517,9 @@ class _MessageList extends HookConsumerWidget { .reduce((a, b) => a > b ? a : b); if (!hasUserScrolled.value || oldestVisible < displayEntries.length - 3 || - isLoadingOlder.value) { + isLoadingOlder.value || + navigatingOldest.value || + olderFetchPaused.value) { return; } final notifier = ref.read( @@ -514,9 +527,14 @@ class _MessageList extends HookConsumerWidget { ); if (notifier.reachedOldest) return; isLoadingOlder.value = true; - notifier.fetchOlder().whenComplete( - () => isLoadingOlder.value = false, - ); + notifier + .fetchOlder() + .then((loaded) { + olderFetchPaused.value = !loaded && !notifier.reachedOldest; + }) + .whenComplete(() { + if (context.mounted) isLoadingOlder.value = false; + }); } var disposed = false; @@ -675,6 +693,23 @@ class _MessageList extends HookConsumerWidget { return null; }, [latestEntryId, localSendAnimations]); + _useChannelScrollToOldest( + context, + notifier: notifier, + itemScrollController: itemScrollController, + displayCount: displayEntries.length, + isLoadingOlder: isLoadingOlder, + navigatingOldest: navigatingOldest, + olderFetchPaused: olderFetchPaused, + oldestNavigationGeneration: oldestNavigationGeneration, + onStart: () { + followsLatest.value = false; + hasUserScrolled.value = true; + isAtLatest.value = false; + if (showUnreadNavigation) detachedWhileUnreadShown.value = true; + }, + ); + if (entries.isEmpty) { return Center( child: Column( @@ -743,6 +778,9 @@ class _MessageList extends HookConsumerWidget { ); if (notification is UserScrollNotification && notification.direction != ScrollDirection.idle) { + navigatingOldest.value = false; + oldestNavigationGeneration.value++; + olderFetchPaused.value = false; hasUserScrolled.value = true; followsLatest.value = false; if (showUnreadNavigation) { @@ -807,6 +845,7 @@ class _MessageList extends HookConsumerWidget { final showAuthor = !message.isSystem && (message.hasAttachments || + prevMessage?.hasAttachments == true || prevMessage == null || prevMessage.isSystem || showDayDivider || @@ -814,7 +853,22 @@ class _MessageList extends HookConsumerWidget { message.pubkey.toLowerCase() || (message.createdAt - prevMessage.createdAt) > 300); + final nextMessage = chronIdx + 1 < displayEntries.length + ? displayEntries[chronIdx + 1].first.message + : null; + final showAvatar = + message.hasAttachments || + nextMessage == null || + nextMessage.isSystem || + nextMessage.hasAttachments || + !isSameDay(message.createdAt, nextMessage.createdAt) || + nextMessage.pubkey.toLowerCase() != + message.pubkey.toLowerCase() || + nextMessage.createdAt - message.createdAt > 300; + return LocalMessageSendTransition( + messageId: message.id, + channelId: channelId, key: ValueKey('channel-message-send-${message.id}'), animate: isRecentLocalMessageSendAnimation( localSendAnimations, @@ -853,7 +907,19 @@ class _MessageList extends HookConsumerWidget { _MessageBubble( message: message, showAuthor: showAuthor, + showAvatar: showAvatar, hasReplies: entry.summary != null, + replyFooter: entry.summary == null + ? null + : _ThreadSummaryRow( + summary: entry.summary!, + message: message, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), channelNames: channelNamesMap, currentChannelId: channelId, currentPubkey: currentPubkey, @@ -863,16 +929,6 @@ class _MessageList extends HookConsumerWidget { composerFocusNode: composerFocusNode, restoreComposerFocus: restoreComposerFocus, ), - if (entry.summary != null) - _ThreadSummaryRow( - summary: entry.summary!, - message: message, - allMessages: allMessages, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), ], ], ), diff --git a/mobile/lib/features/channels/channel_detail_page/oldest_navigation.dart b/mobile/lib/features/channels/channel_detail_page/oldest_navigation.dart new file mode 100644 index 00000000000..abf8d07ddec --- /dev/null +++ b/mobile/lib/features/channels/channel_detail_page/oldest_navigation.dart @@ -0,0 +1,88 @@ +part of '../channel_detail_page.dart'; + +// Each provider page is bounded; a gesture must also bound its page count. +const _oldestNavigationPageLimit = 3; + +// Owns pagination for one status-bar gesture without changing list layout. +void _useChannelScrollToOldest( + BuildContext context, { + required ChannelMessagesNotifier notifier, + required ItemScrollController itemScrollController, + required int displayCount, + required ValueNotifier isLoadingOlder, + required ObjectRef navigatingOldest, + required ObjectRef olderFetchPaused, + required ObjectRef oldestNavigationGeneration, + required VoidCallback onStart, +}) { + final currentDisplayCount = useRef(displayCount); + currentDisplayCount.value = displayCount; + Future navigateToOldest() async { + if (navigatingOldest.value || + !itemScrollController.isAttached || + displayCount == 0) { + return; + } + navigatingOldest.value = true; + olderFetchPaused.value = false; + final generation = ++oldestNavigationGeneration.value; + bool isCurrent() => + navigatingOldest.value && + oldestNavigationGeneration.value == generation; + var ownsFetch = false; + var pagesFetched = 0; + onStart(); + try { + // Wait for an already-running edge fetch before owning pagination. + while (isLoadingOlder.value && context.mounted && isCurrent()) { + await WidgetsBinding.instance.endOfFrame; + } + while (context.mounted && + isCurrent() && + !notifier.reachedOldest && + pagesFetched < _oldestNavigationPageLimit) { + isLoadingOlder.value = true; + ownsFetch = true; + pagesFetched++; + final loaded = await notifier.fetchOlder(); + if (!context.mounted) return; + isLoadingOlder.value = false; + ownsFetch = false; + if (!loaded) { + olderFetchPaused.value = !notifier.reachedOldest; + break; + } + await WidgetsBinding.instance.endOfFrame; + } + if (!context.mounted || + !isCurrent() || + !itemScrollController.isAttached) { + return; + } + // Landing at the loaded edge must not trigger automatic extra pages. + // The next status-bar tap or manual scroll resumes pagination. + if (pagesFetched == _oldestNavigationPageLimit && + !notifier.reachedOldest) { + olderFetchPaused.value = true; + } + final index = currentDisplayCount.value - 1; + if (index < 0) return; + if (MediaQuery.disableAnimationsOf(context)) { + itemScrollController.jumpTo(index: index); + } else { + await itemScrollController.scrollTo( + index: index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + } + } finally { + if (oldestNavigationGeneration.value == generation) { + navigatingOldest.value = false; + } + if (context.mounted && ownsFetch) isLoadingOlder.value = false; + } + } + + useIosScrollToTop(context, () => unawaited(navigateToOldest())); +} diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index 9d8eb033124..df581c6b47e 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -39,38 +39,13 @@ class _SystemMessageRow extends HookConsumerWidget { } final target = systemEvent.targetPubkey?.trim().toLowerCase(); - final targetIsAgent = - ref.watch(agentMentionPubkeysProvider(channelId)).contains(target) || - userCache[target]?.ownerPubkey != null; final messageStyleAction = switch (systemEvent.type) { SystemEventType.memberRemoved => [ const TextSpan(text: 'removed '), if (target == null || target.isEmpty) TextSpan(text: resolveLabel(target)) else - WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: Semantics( - container: true, - label: resolveLabel(target), - button: true, - child: GestureDetector( - onTap: () => showUserProfileSheet( - context, - target, - names: channelIdentityNamesProvider(channelId), - ), - child: ExcludeSemantics( - child: MessageMentionPill( - label: resolveLabel(target), - isAgent: targetIsAgent, - textStyle: _systemActionTextStyle(context), - ), - ), - ), - ), - ), + _systemNameSpan(context, target, channelId, resolveLabel), const TextSpan(text: ' from the channel'), ], SystemEventType.topicChanged => [ @@ -83,6 +58,13 @@ class _SystemMessageRow extends HookConsumerWidget { text: describeChannelTextFieldChange('purpose', systemEvent.purpose), ), ], + SystemEventType.memberLeft => [const TextSpan(text: 'left the channel')], + SystemEventType.channelArchived => [ + const TextSpan(text: 'archived this channel'), + ], + SystemEventType.channelUnarchived => [ + const TextSpan(text: 'unarchived this channel'), + ], SystemEventType.channelCreated => [ const TextSpan(text: 'created this channel'), ], @@ -95,10 +77,6 @@ class _SystemMessageRow extends HookConsumerWidget { final messageStyleActor = messageStyleAction == null ? null : systemEvent.actorPubkey?.trim(); - final usesMessageStyleLayout = - groupedMembership != null || - (messageStyleActor != null && messageStyleActor.isNotEmpty); - final reactions = groupedMessages == null ? message.reactions : _aggregateSystemMessageReactions(sourceMessages); @@ -133,6 +111,8 @@ class _SystemMessageRow extends HookConsumerWidget { context: context, ref: ref, message: message, + reactions: reactions, + onToggleReaction: toggleGroupedReaction, channelId: channelId, canManageMessage: false, allMessages: null, @@ -161,60 +141,58 @@ class _SystemMessageRow extends HookConsumerWidget { borderRadius: BorderRadius.circular(Radii.md), highlightColor: context.colors.primary.withValues(alpha: 0.1), child: Padding( - padding: EdgeInsets.only( - top: usesMessageStyleLayout ? Grid.xs : Grid.xxs, - bottom: usesMessageStyleLayout ? 0 : Grid.xxs, - ), + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ RepaintBoundary( key: spotlightKey, - child: groupedMembership != null - ? _MembershipSystemMessageContent( - event: groupedMembership, - channelId: channelId, - createdAt: message.createdAt, - resolveLabel: resolveLabel, - userCache: userCache, - ) - : messageStyleActor != null && - messageStyleActor.isNotEmpty && - messageStyleAction != null - ? _MessageStyleSystemMessageContent( - displayPubkey: messageStyleActor, - channelId: channelId, - createdAt: message.createdAt, - resolveLabel: resolveLabel, - userCache: userCache, - actionSpans: messageStyleAction, - ) - : Row( - children: [ - _systemEventAvatar( - context, - systemEvent, - userCache, - channelId, - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: Text( - systemEvent.describe(resolveLabel), - style: systemMessageBodyTextStyle.copyWith( - color: context.colors.onSurfaceVariant, + child: Align( + alignment: Alignment.center, + child: Container( + key: ValueKey('activity-card-${message.id}'), + padding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + vertical: Grid.xxs, + ), + decoration: BoxDecoration( + color: context.colors.onSurface.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(Radii.container), + ), + child: IntrinsicWidth( + child: groupedMembership != null + ? _MembershipSystemMessageContent( + event: groupedMembership, + channelId: channelId, + createdAt: message.createdAt, + resolveLabel: resolveLabel, + userCache: userCache, + ) + : messageStyleActor != null && + messageStyleActor.isNotEmpty && + messageStyleAction != null + ? _MessageStyleSystemMessageContent( + displayPubkey: messageStyleActor, + channelId: channelId, + createdAt: message.createdAt, + resolveLabel: resolveLabel, + userCache: userCache, + actionSpans: messageStyleAction, + ) + : Text.rich( + TextSpan( + children: [ + TextSpan( + text: systemEvent.describe(resolveLabel), + ), + ], ), + textAlign: TextAlign.center, + style: _systemActionTextStyle(context), ), - ), - _messageTimestamp( - context, - message.createdAt, - key: ValueKey( - 'system-message-timestamp-${message.id}', - ), - ), - ], - ), + ), + ), + ), ), if (systemEvent.type == SystemEventType.huddleStarted && systemEvent.ephemeralChannelId != null) @@ -226,14 +204,7 @@ class _SystemMessageRow extends HookConsumerWidget { isArchived: isArchived, ), if (reactions.isNotEmpty) - Padding( - padding: EdgeInsets.only( - left: - (usesMessageStyleLayout ? messageAvatarSize : 36) + - (usesMessageStyleLayout - ? messageAvatarContentGap - : Grid.xxs), - ), + IntrinsicWidth( child: ReactionRow( messageId: message.id, channelId: channelId, @@ -251,17 +222,17 @@ class _SystemMessageRow extends HookConsumerWidget { } } -const _maxVisibleAdditionalMemberNames = 3; - class _MembershipDisplayEvent { final String? actorPubkey; final List targetPubkeys; final bool isSelfJoin; + final bool isRemoval; const _MembershipDisplayEvent({ required this.actorPubkey, required this.targetPubkeys, required this.isSelfJoin, + required this.isRemoval, }); } @@ -273,7 +244,8 @@ _MembershipDisplayEvent? _membershipDisplayEvent( final first = messages.first.systemEvent; final firstActor = first?.actorPubkey?.trim().toLowerCase(); final firstTarget = first?.targetPubkey?.trim().toLowerCase(); - if (first?.type != SystemEventType.memberJoined || + if ((first?.type != SystemEventType.memberJoined && + first?.type != SystemEventType.memberRemoved) || firstActor == null || firstActor.isEmpty || firstTarget == null || @@ -281,20 +253,21 @@ _MembershipDisplayEvent? _membershipDisplayEvent( return null; } - final isSelfJoin = firstActor == firstTarget; + final isRemoval = first!.type == SystemEventType.memberRemoved; + final isSelfJoin = !isRemoval && firstActor == firstTarget; final targets = []; for (final message in messages) { final event = message.systemEvent; final actor = event?.actorPubkey?.trim().toLowerCase(); final target = event?.targetPubkey?.trim().toLowerCase(); - if (event?.type != SystemEventType.memberJoined || + if (event?.type != first.type || actor == null || actor.isEmpty || target == null || target.isEmpty || (isSelfJoin ? actor != target - : actor != firstActor || actor == target)) { + : actor != firstActor || (!isRemoval && actor == target))) { return null; } targets.add(target); @@ -302,8 +275,9 @@ _MembershipDisplayEvent? _membershipDisplayEvent( return _MembershipDisplayEvent( actorPubkey: isSelfJoin ? null : firstActor, - targetPubkeys: targets, + targetPubkeys: targets.toSet().toList(), isSelfJoin: isSelfJoin, + isRemoval: isRemoval, ); } @@ -365,38 +339,72 @@ class _MembershipSystemMessageContent extends StatelessWidget { @override Widget build(BuildContext context) { final firstTarget = event.targetPubkeys.first; - final additionalTargets = event.targetPubkeys.skip(1).toList(); - final visibleTargets = additionalTargets - .take(_maxVisibleAdditionalMemberNames) - .toList(); - final hiddenTargets = additionalTargets - .skip(_maxVisibleAdditionalMemberNames) - .toList(); - final actionStyle = _systemActionTextStyle(context); + final additionalTargets = event.targetPubkeys.skip(1).take(1).toList(); + final hiddenCount = event.targetPubkeys.length - 2; final actionSpans = [ TextSpan( text: event.isSelfJoin ? 'joined the channel' - // No "was": the name renders on the line above via - // MessageAuthorMeta, so this reads as a status line rather than a - // sentence continuing across the metadata row. Matches desktop's - // SystemMessageRow. `SystemEvent.describe` keeps "was added by" - // because it builds subject and predicate into one string. - : 'added by ${resolveLabel(event.actorPubkey)}', + : event.isRemoval + ? 'removed ' + : 'added ', ), + if (!event.isSelfJoin) + _systemNameSpan(context, firstTarget, channelId, resolveLabel), if (additionalTargets.isNotEmpty) - TextSpan(text: event.isSelfJoin ? ' along with ' : ', along with '), + TextSpan( + text: event.isSelfJoin + ? ' along with ' + : additionalTargets.length == 1 && hiddenCount <= 0 + ? ' and ' + : ', ', + ), ..._memberNameSpans( context, - visibleTargets: visibleTargets, - hiddenTargets: hiddenTargets, + targets: additionalTargets, + channelId: channelId, resolveLabel: resolveLabel, - style: actionStyle, ), + if (hiddenCount > 0) + TextSpan( + text: ' and $hiddenCount ${hiddenCount == 1 ? 'other' : 'others'}', + ), + if (!event.isSelfJoin) + TextSpan( + text: event.isRemoval ? ' from the channel' : ' to the channel', + ), + if (hiddenCount > 0) ...[ + const TextSpan(text: '. '), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Semantics( + button: true, + child: GestureDetector( + onTap: () => showBuzzModalBottomSheet( + context: context, + title: event.isRemoval ? 'Removed members' : 'Added members', + isScrollControlled: true, + showDragHandle: true, + builder: (_) => _ActivityMembersSheet( + pubkeys: event.targetPubkeys, + channelId: channelId, + ), + ), + child: Text( + 'See all', + style: _systemActionTextStyle( + context, + )?.copyWith(fontWeight: FontWeight.w600), + ), + ), + ), + ), + ], ]; return _MessageStyleSystemMessageContent( - displayPubkey: firstTarget, + displayPubkey: event.isSelfJoin ? firstTarget : event.actorPubkey!, channelId: channelId, createdAt: createdAt, resolveLabel: resolveLabel, @@ -407,7 +415,7 @@ class _MembershipSystemMessageContent extends StatelessWidget { } TextStyle? _systemActionTextStyle(BuildContext context) { - return systemMessageBodyTextStyle.copyWith( + return context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ); } @@ -431,191 +439,77 @@ class _MessageStyleSystemMessageContent extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => showUserProfileSheet( - context, - displayPubkey, - names: channelIdentityNamesProvider(channelId), - ), - child: _UserAvatar( - profile: userCache[displayPubkey.toLowerCase()], - pubkey: displayPubkey, - isAgent: - userCache[displayPubkey.toLowerCase()]?.ownerPubkey != null, - size: messageAvatarSize, - ), - ), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Padding( - padding: const EdgeInsets.only(top: Grid.half), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: Grid.quarter), - child: MessageAuthorMeta( - displayName: resolveLabel(displayPubkey), - username: messageUsernameLabel( - userCache[displayPubkey.toLowerCase()], - ), - timestamp: formatMessageTime(createdAt), - nameColor: context.colors.onSurface, - metadataColor: context.colors.onSurfaceVariant, - nameStyle: systemMessageHeadingTextStyle, - displayNameKey: ValueKey( - 'system-message-author-$displayPubkey', - ), - usernameKey: ValueKey( - 'system-message-username-$displayPubkey', - ), - timestampKey: ValueKey( - 'system-message-timestamp-$displayPubkey', - ), - ), - ), - Text.rich( - TextSpan( - style: _systemActionTextStyle(context), - children: actionSpans, - ), - ), - ], - ), - ), - ), - ], - ); - } -} - -List _memberNameSpans( - BuildContext context, { - required List visibleTargets, - required List hiddenTargets, - required String Function(String? pubkey) resolveLabel, - required TextStyle? style, -}) { - final spans = []; - for (var index = 0; index < visibleTargets.length; index++) { - final isLast = index == visibleTargets.length - 1; - final separator = index == 0 - ? '' - : isLast && hiddenTargets.isEmpty - ? (visibleTargets.length == 2 ? ' and ' : ', and ') - : ', '; - spans.add( - TextSpan(text: '$separator${resolveLabel(visibleTargets[index])}'), + return Text.rich( + TextSpan( + children: [ + _systemNameSpan(context, displayPubkey, channelId, resolveLabel), + const TextSpan(text: ' '), + ...actionSpans, + ], + ), + textAlign: TextAlign.center, + style: _systemActionTextStyle(context), ); } - - if (hiddenTargets.isNotEmpty) { - final hiddenLabels = hiddenTargets.map(resolveLabel).toList(); - spans - ..add(const TextSpan(text: ', and ')) - ..add( - WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: Tooltip( - message: hiddenLabels.join('\n'), - triggerMode: TooltipTriggerMode.tap, - child: Text( - '${hiddenTargets.length} others', - key: const Key('membership-overflow'), - style: style?.copyWith( - decoration: TextDecoration.underline, - decorationStyle: TextDecorationStyle.dotted, - ), - ), - ), - ), - ); - } - - return spans; } -Widget _systemEventAvatar( +WidgetSpan _systemNameSpan( BuildContext context, - SystemEvent event, - Map userCache, + String pubkey, String channelId, + String Function(String?) resolveLabel, ) { - final hasTarget = - event.targetPubkey != null && event.targetPubkey != event.actorPubkey; - - if (event.actorPubkey != null && hasTarget) { - // Two-avatar stack: actor + target (e.g. "Alice added Bob"). - return SizedBox( - width: 32, - height: 20, - child: Stack( - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => showUserProfileSheet( - context, - event.actorPubkey!, - names: channelIdentityNamesProvider(channelId), - ), - child: SmallAvatar( - pubkey: event.actorPubkey!, - userCache: userCache, + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Semantics( + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => showUserProfileSheet( + context, + pubkey, + names: channelIdentityNamesProvider(channelId), + ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: max( + 0, + MediaQuery.sizeOf(context).width - Grid.gutter * 2 - Grid.lg, ), ), - Positioned( - left: 12, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => showUserProfileSheet( - context, - event.targetPubkey!, - names: channelIdentityNamesProvider(channelId), - ), - child: SmallAvatar( - pubkey: event.targetPubkey!, - userCache: userCache, - ), - ), + child: Text( + resolveLabel(pubkey), + key: ValueKey('system-message-author-$pubkey'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _systemActionTextStyle(context), ), - ], - ), - ); - } - - if (event.actorPubkey != null) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => showUserProfileSheet( - context, - event.actorPubkey!, - names: channelIdentityNamesProvider(channelId), + ), ), - child: SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache), - ); - } - - // Fallback: generic icon when no actor is available. - return Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon( - BuzzIcons.arrowLeftRight, - size: 12, - color: context.colors.onSurfaceVariant, ), ); } +List _memberNameSpans( + BuildContext context, { + required List targets, + required String channelId, + required String Function(String? pubkey) resolveLabel, +}) { + return [ + for (var index = 0; index < targets.length; index++) ...[ + if (index > 0) + TextSpan( + text: index == targets.length - 1 + ? (targets.length == 2 ? ' and ' : ', and ') + : ', ', + ), + _systemNameSpan(context, targets[index], channelId, resolveLabel), + ], + ]; +} + class _ThreadSummaryRow extends ConsumerWidget { final ThreadSummary summary; final TimelineMessage message; @@ -637,8 +531,8 @@ class _ThreadSummaryRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final participants = summary.participantPubkeys.take(3).toList(); final userCache = ref.watch(userCacheProvider); - return GestureDetector( onTap: () { Navigator.of(context).push( @@ -654,71 +548,59 @@ class _ThreadSummaryRow extends ConsumerWidget { ), ); }, - child: Padding( - key: ValueKey('thread-summary-${message.id}'), - padding: const EdgeInsets.only( - left: messageAvatarSize + messageAvatarContentGap, - top: Grid.half, - bottom: Grid.xs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Stacked participant avatars. - SizedBox( - width: 32.0 + (summary.participantPubkeys.length - 1) * 20.0, - height: 32, - child: Stack( - children: [ - for (var i = 0; i < summary.participantPubkeys.length; i++) - Positioned( - left: i * 20.0, - child: SmallAvatar( - pubkey: summary.participantPubkeys[i], - userCache: userCache, - size: 32, - ), - ), - ], - ), - ), - const SizedBox(width: Grid.xxs), - Flexible( - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: summary.isCountPending - ? 'Replies' - : '${summary.replyCount}${summary.isLowerBound ? '+' : ''} ${summary.replyCount == 1 ? 'reply' : 'replies'}', - style: replyPreviewTextStyle.copyWith( - color: context.colors.primary, - ), - ), - if (summary.lastReplyAt case final lastReplyAt?) ...[ - TextSpan( - text: ' · ', - style: replyPreviewTextStyle.copyWith( - color: context.colors.onSurfaceVariant.withValues( - alpha: 0.5, + child: Semantics( + button: true, + child: Container( + key: ValueKey('thread-summary-${message.id}'), + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs + Grid.quarter, + vertical: Grid.half, + ), + decoration: BoxDecoration( + color: context.colors.secondaryContainer, + borderRadius: BorderRadius.circular(Radii.container), + border: Border.all(color: context.colors.surface, width: 2), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (participants.isNotEmpty) ...[ + ExcludeSemantics( + child: SizedBox( + width: + Grid.gutter + (participants.length - 1) * Grid.twelve, + height: Grid.gutter, + child: Stack( + children: [ + for (var i = 0; i < participants.length; i++) + Positioned( + left: i * Grid.twelve, + child: SmallAvatar( + pubkey: participants[i], + userCache: userCache, + size: Grid.gutter, + ), ), - ), - ), - TextSpan( - text: - 'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}', - style: replyPreviewTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ], + ], + ), + ), + ), + const SizedBox(width: Grid.half), + ], + Flexible( + child: Text( + summary.isCountPending + ? 'Replies' + : '${summary.replyCount}${summary.isLowerBound ? '+' : ''} ${summary.replyCount == 1 ? 'reply' : 'replies'}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurface, + ), ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), - ), - ], + ], + ), ), ), ); diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index c43d4057fef..e528970a044 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,3 +1,4 @@ +import 'composer_send_origin.dart'; import 'dart:async'; import 'dart:collection'; import 'dart:io'; diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 0a09eb5c3cd..591bcdd2e1e 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -32,6 +32,7 @@ class ComposeBar extends HookConsumerWidget { }); @override Widget build(BuildContext context, WidgetRef ref) { + final sendSurfaceKey = useMemoized(GlobalKey.new); final controller = useMemoized(_MarkdownEditingController.new); final composerText = useListenableSelector( controller, @@ -510,6 +511,15 @@ class ComposeBar extends HookConsumerWidget { uploadingCount.value > 0) { return; } + final submittedOrigin = ComposerSendOrigin.capture(sendSurfaceKey); + Future sendWithOrigin( + String content, + List mentionPubkeys, { + List> mediaTags = const [], + }) => ComposerSendOrigin.run( + submittedOrigin, + () => onSend(content, mentionPubkeys, mediaTags: mediaTags), + ); final submittedDraftRevision = draftRevision.value; // Resolved before any await: see // `_reportSendCancelledByCommunitySwitch`. @@ -632,7 +642,7 @@ class ComposeBar extends HookConsumerWidget { customEmoji: customEmoji, ), outgoing: outgoing, - onSend: onSend, + onSend: sendWithOrigin, messenger: messenger, ); return; @@ -652,7 +662,7 @@ class ComposeBar extends HookConsumerWidget { final cancellation = UploadCancellationToken(); final uploadService = ref.read(mediaUploadServiceProvider); activeUploadCancellation.value = cancellation; - final delivery = onSend; + final delivery = sendWithOrigin; unawaited(() async { var retainedForRetry = false; try { @@ -1055,6 +1065,7 @@ class ComposeBar extends HookConsumerWidget { attachmentSurface.value = _AttachmentSurface.closed; }, child: _ComposeBarLayout( + surfaceKey: sendSurfaceKey, voiceNoteRecorder: voiceNote.recorder, attachments: attachments.value, onRemoveAttachment: removeAttachment, diff --git a/mobile/lib/features/channels/compose_bar/layout.dart b/mobile/lib/features/channels/compose_bar/layout.dart index 354a0d3ce20..b51dad01b94 100644 --- a/mobile/lib/features/channels/compose_bar/layout.dart +++ b/mobile/lib/features/channels/compose_bar/layout.dart @@ -1,6 +1,7 @@ part of '../compose_bar.dart'; class _ComposeBarLayout extends HookWidget { + final GlobalKey surfaceKey; final Widget? voiceNoteRecorder; final List<_PendingAttachment> attachments; final ValueChanged onRemoveAttachment; @@ -30,6 +31,7 @@ class _ComposeBarLayout extends HookWidget { final bool isSending; const _ComposeBarLayout({ + required this.surfaceKey, required this.voiceNoteRecorder, required this.attachments, required this.onRemoveAttachment, @@ -289,22 +291,25 @@ class _ComposeBarLayout extends HookWidget { final voiceNoteInsetProgress = hasVoiceNoteAttachment ? 1.0 : recordingTransition.value; - final composer = Container( - key: const ValueKey('composer-surface'), - decoration: BoxDecoration( - color: usesIosConcentricSurface - ? Colors.transparent - : context.colors.surfaceContainerHighest, - borderRadius: radius, - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, + final composer = KeyedSubtree( + key: surfaceKey, + child: Container( + key: const ValueKey('composer-surface'), + decoration: BoxDecoration( + color: usesIosConcentricSurface + ? Colors.transparent + : context.colors.surfaceContainerHighest, + borderRadius: radius, + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), ), + padding: EdgeInsets.all( + Grid.xxs + Grid.half * voiceNoteInsetProgress, + ), + child: child, ), - padding: EdgeInsets.all( - Grid.xxs + Grid.half * voiceNoteInsetProgress, - ), - child: child, ); if (!usesIosConcentricSurface) return composer; return ConcentricSheetSurface( diff --git a/mobile/lib/features/channels/composer_send_origin.dart b/mobile/lib/features/channels/composer_send_origin.dart new file mode 100644 index 00000000000..270c8111f51 --- /dev/null +++ b/mobile/lib/features/channels/composer_send_origin.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +/// Carries submitted composer geometry through asynchronous send preparation. +class ComposerSendOrigin { + static final _zoneKey = Object(); + + /// Captures only geometry; no draft or identity data is retained. + static Rect? capture(GlobalKey key) { + final box = key.currentContext?.findRenderObject(); + return box is RenderBox && box.hasSize && box.attached + ? box.localToGlobal(Offset.zero) & box.size + : null; + } + + /// Geometry for this send, isolated from other concurrent submissions. + static Rect? get current => Zone.current[_zoneKey] as Rect?; + + /// Retains the submitted surface until this send creates its local claim. + static Future run(Rect? origin, Future Function() send) => + runZoned(send, zoneValues: {_zoneKey: origin}); +} diff --git a/mobile/lib/features/channels/local_message_send_animation_provider.dart b/mobile/lib/features/channels/local_message_send_animation_provider.dart index 1b3fa0f0368..43f2d2dd071 100644 --- a/mobile/lib/features/channels/local_message_send_animation_provider.dart +++ b/mobile/lib/features/channels/local_message_send_animation_provider.dart @@ -1,3 +1,6 @@ +import 'package:flutter/widgets.dart'; + +import 'composer_send_origin.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; const _localMessageSendAnimationClaimWindow = Duration(seconds: 1); @@ -14,19 +17,34 @@ class LocalMessageSendAnimationNotifier /// Creates animation state scoped to [channelId]. LocalMessageSendAnimationNotifier(this.channelId); + final _origins = {}; + + /// Submitted composer geometry attached to this event's local send claim. + Rect? originFor(String eventId) => + isRecentLocalMessageSendAnimation(state, eventId) + ? _origins[eventId] + : null; + @override Map build() => const {}; /// Marks [eventId] as a newly signed local message eligible for animation. void mark(String eventId) { final now = DateTime.now(); - state = { + final claims = { for (final entry in state.entries) if (now.difference(entry.value) <= _localMessageSendAnimationClaimWindow) entry.key: entry.value, eventId: now, }; + _origins.removeWhere((id, _) => !claims.containsKey(id)); + if (ComposerSendOrigin.current case final origin?) { + _origins[eventId] = origin; + } else { + _origins.remove(eventId); + } + state = claims; } } diff --git a/mobile/lib/features/channels/local_message_send_transition.dart b/mobile/lib/features/channels/local_message_send_transition.dart index dcf8df4f9a7..23024701e7a 100644 --- a/mobile/lib/features/channels/local_message_send_transition.dart +++ b/mobile/lib/features/channels/local_message_send_transition.dart @@ -1,48 +1,86 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'local_message_send_animation_provider.dart'; +import 'local_send_row.dart'; /// Duration of a local message's entrance transition. -const localMessageSendTransitionDuration = Duration(milliseconds: 300); +const localMessageSendTransitionDuration = Duration(milliseconds: 500); -/// Default vertical offset, in child heights, for a message entrance. +// A damped spring contracts quickly, gently passes the target width, then +// settles. Normalize the endpoint so there is no snap when the clock completes. +final _contractionSpring = _SendContractionSpring(); + +class _SendContractionSpring extends Curve { + final _simulation = SpringSimulation( + SpringDescription.withDampingRatio(mass: 1, stiffness: 256, ratio: 0.82), + 0, + 1, + 4, + ); + + double get _seconds => + localMessageSendTransitionDuration.inMicroseconds / + Duration.microsecondsPerSecond; + + @override + double transformInternal(double t) => + _simulation.x(t * _seconds) / _simulation.x(_seconds); +} + +/// Default travel multiplier for a message entrance. const localMessageSendTransitionStartOffset = 1.5; -/// Vertical offset, in child heights, used for an avatar entrance. +/// Travel multiplier used when the message includes a sender header. const localMessageSendTransitionAvatarStartOffset = 2.5; -/// Slides a newly sent local message upward from beneath the composer. -class LocalMessageSendTransition extends HookWidget { +/// Coordinates upward row expansion and the bubble flight from the composer. +class LocalMessageSendTransition extends HookConsumerWidget { /// Creates a local send transition around [child]. const LocalMessageSendTransition({ super.key, required this.animate, + this.channelId, + this.messageId, + this.threadHeadId, this.startOffsetFactor = localMessageSendTransitionStartOffset, required this.child, }); + /// Composer scope used to retrieve the captured sending surface. + final String? channelId; + + /// Event whose local claim owns the submitted composer geometry. + final String? messageId; + final String? threadHeadId; + /// Whether to play the entrance transition. final bool animate; - /// Initial vertical offset expressed as a multiple of the child's height. + /// Initial vertical offset expressed as a multiple of 24 logical pixels. final double startOffsetFactor; - /// Message content translated by the transition. + /// Message row whose bottom stays anchored during expansion. final Widget child; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final origin = useMemoized( + () => !animate || channelId == null || messageId == null + ? null + : ref + .read(localMessageSendAnimationProvider(channelId!).notifier) + .originFor(messageId!), + const [], + ); final reduceMotion = MediaQuery.disableAnimationsOf(context); final controller = useAnimationController( duration: localMessageSendTransitionDuration, initialValue: animate && !reduceMotion ? 0 : 1, ); - final easedAnimation = useMemoized( - () => CurvedAnimation(parent: controller, curve: Curves.easeOutCubic), - [controller], - ); - useEffect(() => easedAnimation.dispose, [easedAnimation]); useEffect(() { if (animate && !reduceMotion) { @@ -60,13 +98,69 @@ class LocalMessageSendTransition extends HookWidget { }, [animate, controller, reduceMotion]); return AnimatedBuilder( - animation: easedAnimation, + animation: controller, child: child, - builder: (context, child) => FractionalTranslation( - translation: Offset(0, startOffsetFactor * (1 - easedAnimation.value)), - transformHitTests: false, - child: child, + builder: (context, child) => LocalSendProgress( + progress: const Interval( + 0.15, + 1, + curve: Curves.easeInOutCubic, + ).transform(controller.value), + widthProgress: _contractionSpring.transform(controller.value), + origin: origin, + startOffset: 24 * startOffsetFactor, + // History follows the lift, while width contracts ahead of it. + child: LocalSendRow( + progress: const Interval( + 0.15, + 1, + curve: Curves.easeInOutCubic, + ).transform(controller.value), + child: child!, + ), ), ); } } + +/// Shares the local entrance progress with bubble backgrounds beneath the row. +class LocalSendProgress extends InheritedWidget { + /// Creates a progress scope for the message row. + const LocalSendProgress({ + super.key, + required this.progress, + this.widthProgress, + this.origin, + this.startOffset = 36, + required super.child, + }); + + /// Horizontal contraction, independent of vertical travel. + /// Defaults to [progress] for surfaces without a staged entrance. + final double? widthProgress; + + /// Captured composer rectangle in global coordinates. + final Rect? origin; + + /// Fallback travel when no composer geometry is available. + final double startOffset; + + /// Entrance progress from zero (full width) to one (settled). + final double progress; + + /// Geometry and progress inherited by the bubble's paint surface. + static LocalSendProgress? scopeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + + /// Returns settled progress for content outside a local-send transition. + static double of(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType() + ?.progress ?? + 1; + + @override + bool updateShouldNotify(LocalSendProgress oldWidget) => + progress != oldWidget.progress || + widthProgress != oldWidget.widthProgress; +} diff --git a/mobile/lib/features/channels/local_send_bubble_surface.dart b/mobile/lib/features/channels/local_send_bubble_surface.dart new file mode 100644 index 00000000000..946538405ff --- /dev/null +++ b/mobile/lib/features/channels/local_send_bubble_surface.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import 'local_message_send_transition.dart'; + +/// Paints a local-send bubble from the captured composer rectangle. +/// The text travels with the bubble while keeping its final size and wrapping. +class LocalSendBubbleSurface extends SingleChildRenderObjectWidget { + /// Creates an animated background without changing the child layout. + const LocalSendBubbleSurface({ + super.key, + required this.color, + required this.shape, + required super.child, + }); + + /// Bubble fill in the active theme. + final Color color; + + /// Final conversation geometry, including grouped corners and thought dots. + final ShapeBorder shape; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderSendBubble(color, shape, LocalSendProgress.scopeOf(context)); + + @override + void updateRenderObject( + BuildContext context, + covariant RenderProxyBox renderObject, + ) { + (renderObject as _RenderSendBubble).update( + color, + shape, + LocalSendProgress.scopeOf(context), + ); + } +} + +class _RenderSendBubble extends RenderProxyBox { + _RenderSendBubble(this.color, this.shape, this.motion); + + Color color; + ShapeBorder shape; + LocalSendProgress? motion; + double get progress => motion?.progress ?? 1; + + void update( + Color nextColor, + ShapeBorder nextShape, + LocalSendProgress? nextMotion, + ) { + if (color == nextColor && shape == nextShape && motion == nextMotion) { + return; + } + color = nextColor; + shape = nextShape; + motion = nextMotion; + markNeedsPaint(); + markNeedsSemanticsUpdate(); + } + + @override + Rect get paintBounds { + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : size.width; + final bounds = shape + .getOuterPath(Offset.zero & Size(width, size.height)) + .getBounds() + .expandToInclude(super.paintBounds); + return bounds + .expandToInclude(animatedRect) + .expandToInclude(child?.paintBounds.shift(childOffset) ?? Rect.zero); + } + + Rect get animatedRect { + final end = Offset.zero & size; + final origin = motion?.origin; + final start = origin == null + ? Rect.fromLTWH( + 0, + motion?.startOffset ?? 0, + constraints.hasBoundedWidth ? constraints.maxWidth : size.width, + size.height, + ) + : origin.shift(-localToGlobal(Offset.zero)); + // Contract horizontally first, then finish lifting into the timeline. + // Both tracks share a clock, but their overlapping curves differ. + final horizontal = Rect.lerp( + start, + end, + motion?.widthProgress ?? progress, + )!; + final vertical = Rect.lerp(start, end, progress)!; + return Rect.fromLTWH( + horizontal.left, + vertical.top, + horizontal.width, + vertical.height, + ); + } + + Offset get childOffset => progress < 1 ? animatedRect.topLeft : Offset.zero; + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + transform.translateByDouble(childOffset.dx, childOffset.dy, 0, 1); + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + final rect = animatedRect; + if (!shape.getOuterPath(rect).contains(position)) return false; + if (!hitTestChildren(result, position: position)) return false; + result.add(BoxHitTestEntry(this, position)); + return true; + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) => + result.addWithPaintOffset( + offset: childOffset, + position: position, + hitTest: (result, position) => + child?.hitTest(result, position: position) ?? false, + ); + + @override + void paint(PaintingContext context, Offset offset) { + final rect = animatedRect; + final path = shape.getOuterPath(rect.shift(offset)); + context.canvas.drawPath(path, Paint()..color = color); + if (child case final child?) { + if (progress < 1) { + context.pushClipPath( + needsCompositing, + Offset.zero, + path.getBounds(), + path, + (context, _) => context.paintChild(child, offset + rect.topLeft), + ); + } else { + context.paintChild(child, offset); + } + } + } +} diff --git a/mobile/lib/features/channels/local_send_row.dart b/mobile/lib/features/channels/local_send_row.dart new file mode 100644 index 00000000000..77beb12944a --- /dev/null +++ b/mobile/lib/features/channels/local_send_row.dart @@ -0,0 +1,80 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Grows a message row upward while keeping the child's bottom anchored. +class LocalSendRow extends SingleChildRenderObjectWidget { + /// Creates a row driven by the same progress as its bubble entrance. + const LocalSendRow({super.key, required this.progress, required super.child}); + + /// Fraction of the final row height allocated in the conversation. + final double progress; + @override + RenderObject createRenderObject(BuildContext context) => + _RenderSendRow(progress); + @override + void updateRenderObject( + BuildContext context, + covariant RenderProxyBox renderObject, + ) { + (renderObject as _RenderSendRow).setProgress(progress); + } +} + +class _RenderSendRow extends RenderProxyBox { + _RenderSendRow(this.progress); + double progress; + void setProgress(double value) { + if (progress == value) return; + progress = value; + markNeedsLayout(); + markNeedsSemanticsUpdate(); + } + + Offset get childOffset => Offset(0, size.height - (child?.size.height ?? 0)); + @override + void performLayout() { + child?.layout(constraints, parentUsesSize: true); + // A zero-extent item can be culled by the indexed list before the lift + // begins, briefly removing its avatar. Keep it laid out from the first frame. + size = constraints.constrain( + Size( + child?.size.width ?? 0, + (child?.size.height ?? 0) * progress.clamp(0.0001, 1.0), + ), + ); + } + + @override + Rect get paintBounds => super.paintBounds.expandToInclude( + child?.paintBounds.shift(childOffset) ?? Rect.zero, + ); + + @override + void paint(PaintingContext context, Offset offset) { + if (child case final child?) { + context.paintChild(child, offset + childOffset); + } + } + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + transform.translateByDouble(childOffset.dx, childOffset.dy, 0, 1); + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (!paintBounds.contains(position)) return false; + if (!hitTestChildren(result, position: position)) return false; + result.add(BoxHitTestEntry(this, position)); + return true; + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) => + result.addWithPaintOffset( + offset: childOffset, + position: position, + hitTest: (result, position) => + child?.hitTest(result, position: position) ?? false, + ); +} diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index 6aac44bfa6a..a0af9e27fe1 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -60,6 +60,7 @@ bool _selectMessageAction(BuildContext context) { } /// Presents message reactions and grouped actions in the shared bottom sheet. +/// Grouped rows can supply aggregate reactions and a toggle for all source events. /// Snapshot and preview callbacks are not used by the sheet presentation. Future showMessageActions({ required BuildContext context, @@ -68,6 +69,8 @@ Future showMessageActions({ required String channelId, required bool canManageMessage, List? allMessages, + List? reactions, + ValueChanged? onToggleReaction, String? currentPubkey, bool isMember = false, Rect? anchorRect, @@ -114,6 +117,8 @@ Future showMessageActions({ children: [ _QuickReactionRow( message: message, + reactions: reactions ?? message.reactions, + onToggleReaction: onToggleReaction, sheetContext: sheetContext, pageContext: context, pageRef: ref, diff --git a/mobile/lib/features/channels/message_actions/quick_reaction_row.dart b/mobile/lib/features/channels/message_actions/quick_reaction_row.dart index 1673086e73e..7d9e5898b52 100644 --- a/mobile/lib/features/channels/message_actions/quick_reaction_row.dart +++ b/mobile/lib/features/channels/message_actions/quick_reaction_row.dart @@ -2,6 +2,8 @@ part of '../message_actions.dart'; class _QuickReactionRow extends ConsumerWidget { final TimelineMessage message; + final List reactions; + final ValueChanged? onToggleReaction; /// The sheet's context, popped before the reaction fires. final BuildContext sheetContext; @@ -16,6 +18,8 @@ class _QuickReactionRow extends ConsumerWidget { const _QuickReactionRow({ required this.message, + required this.reactions, + this.onToggleReaction, required this.sheetContext, required this.pageContext, required this.pageRef, @@ -36,10 +40,12 @@ class _QuickReactionRow extends ConsumerWidget { }; void react(String value) { - if (message.reactions.any( - (r) => r.emoji == value && r.reactedByCurrentUser, - )) { - toggleReaction(pageRef, message, value); + if (reactions.any((r) => r.emoji == value && r.reactedByCurrentUser)) { + if (onToggleReaction != null) { + onToggleReaction!(value); + } else { + toggleReaction(pageRef, message, value); + } return; } // The generic picker is also used for composing and statuses. Record @@ -49,7 +55,11 @@ class _QuickReactionRow extends ConsumerWidget { // The sheet is on its way out, so the burst can't come from this tile — // hand it to the pill that's about to appear in the timeline. armReactionBurst(pageRef, message, value); - pageRef.read(channelActionsProvider).addReaction(message.id, value); + if (onToggleReaction != null) { + onToggleReaction!(value); + } else { + pageRef.read(channelActionsProvider).addReaction(message.id, value); + } } return LayoutBuilder( @@ -73,7 +83,7 @@ class _QuickReactionRow extends ConsumerWidget { child: _QuickReactionCircle( size: circleSize, label: dataset.displayName(emoji[index]), - selected: message.reactions.any( + selected: reactions.any( (r) => r.emoji == emoji[index] && r.reactedByCurrentUser, ), onTap: () { diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index a3ac34d8597..cfbe413fa3d 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../../shared/widgets/message_attachment_corners.dart'; import 'dart:io'; import 'dart:math' as math; @@ -29,9 +30,11 @@ import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/emoji/emoji_only.dart'; import 'channels_provider.dart'; +import 'local_send_bubble_surface.dart'; import 'media_viewer_page.dart'; import 'message_content/link_normalizer.dart'; import 'message_media.dart'; +import 'message_content/bubble_segments.dart'; import 'message_mention_pill.dart'; import 'message_gallery.dart'; import 'message_gallery_frame.dart'; @@ -39,6 +42,7 @@ import 'message_media_geometry.dart'; import 'voice_note_attachment.dart'; part 'message_content/media_carousel.dart'; +part 'message_content/bubble_shape.dart'; part 'message_content/inline_components.dart'; part 'message_content/token_pill.dart'; part 'message_content/video_preview.dart'; @@ -89,6 +93,21 @@ String _safeDownloadedFilename(String filename) { class MessageContent extends HookConsumerWidget { final String content; + /// Paint conversation text bubbles while keeping attachments on the page. + final bool bubble; + + /// Distinguishes messages authored by the signed-in identity. + final bool ownMessage; + + /// Marks the final message in a sender run for rounded corners and dots. + final bool bubbleEnd; + + /// Marks the first message in a sender run for rounded top corners. + final bool bubbleStart; + + /// Reserves room above overlapping reactions. + final bool hasReactions; + /// Display names for mentioned pubkeys, extracted from event p-tags. /// Keys are lowercase pubkeys, values are display names. final Map mentionNames; @@ -148,6 +167,11 @@ class MessageContent extends HookConsumerWidget { const MessageContent({ super.key, required this.content, + this.bubble = false, + this.ownMessage = false, + this.bubbleEnd = true, + this.bubbleStart = true, + this.hasReactions = false, this.mentionNames = const {}, this.mentionLabels = const {}, this.agentMentionPubkeys = const {}, @@ -167,9 +191,16 @@ class MessageContent extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final markdownTheme = GptMarkdownTheme.of(context); + final ownTextColor = context.colors.brightness == Brightness.dark + ? Colors.black + : Colors.white; final baseTextStyle = - baseStyle ?? - context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface); + (baseStyle ?? + context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurface, + )) + ?.copyWith(color: bubble && ownMessage ? ownTextColor : null); final resolvedMentionNames = mentionNames; final signedMentionPubkeys = mentionedPubkeysFromTags(tags); final mentionBindings = renderedMentionBindings( @@ -316,12 +347,12 @@ class MessageContent extends HookConsumerWidget { onChannelTap: resolvedChannelTap, ); - final markdown = KeyedSubtree( + Widget renderMarkdown(String text) => KeyedSubtree( key: ValueKey( - '$finalContent\u0000$mentionPresentationKey\u0000$channelPresentationKey', + '$text\u0000$mentionPresentationKey\u0000$channelPresentationKey', ), child: GptMarkdown( - finalContent, + text, style: style, followLinkColor: false, // normalizeBareLinks() already turns bare URLs into Markdown links; @@ -352,6 +383,90 @@ class MessageContent extends HookConsumerWidget { inlineComponents: inlineComponents, ), ); + final segments = bubble + ? messageBubbleSegments(finalContent, imetaByUrl) + : const <({String text, bool attachment})>[]; + final markdown = bubble + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (final (index, segment) in segments.indexed) + if (segment.attachment) + MessageAttachmentCorners( + borderRadius: _attachmentCorners( + start: bubbleStart && index == 0, + end: + bubbleEnd && + trailingGallery == null && + index == segments.length - 1, + ), + child: renderMarkdown(segment.text), + ) + else + Padding( + key: ValueKey('message-text-bubble-$index'), + padding: const EdgeInsets.only(bottom: Grid.quarter), + child: LocalSendBubbleSurface( + color: ownMessage + ? (context.colors.brightness == Brightness.dark + ? const Color(0xFFF2F2F2) + : Colors.black) + : context.colors.secondaryContainer, + shape: _MessageBubbleShape( + start: bubbleStart && index == 0, + end: + bubbleEnd && + trailingGallery == null && + index == segments.length - 1, + ), + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.xs, + Grid.xxs, + Grid.xs, + hasReactions && index == segments.length - 1 + ? Grid.xs + : Grid.xxs, + ), + child: ownMessage + ? Theme( + data: Theme.of(context).copyWith( + colorScheme: context.colors.copyWith( + primary: ownTextColor, + ), + ), + child: GptMarkdownTheme( + gptThemeData: markdownTheme.copyWith( + h1: markdownTheme.h1?.copyWith( + color: ownTextColor, + ), + h2: markdownTheme.h2?.copyWith( + color: ownTextColor, + ), + h3: markdownTheme.h3?.copyWith( + color: ownTextColor, + ), + h4: markdownTheme.h4?.copyWith( + color: ownTextColor, + ), + h5: markdownTheme.h5?.copyWith( + color: ownTextColor, + ), + h6: markdownTheme.h6?.copyWith( + color: ownTextColor, + ), + ), + child: renderMarkdown(segment.text), + ), + ) + : renderMarkdown(segment.text), + ), + ), + ), + ], + ) + : renderMarkdown(finalContent); if (trailingGallery == null) return markdown; return Column( @@ -359,15 +474,21 @@ class MessageContent extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ if (trailingGallery.content.trim().isNotEmpty) markdown, - _MessageImageCarousel( - key: ValueKey( - trailingGallery.items.map((item) => item.url).join('\u0000'), + MessageAttachmentCorners( + borderRadius: _attachmentCorners( + start: bubbleStart && trailingGallery.content.trim().isEmpty, + end: bubbleEnd, + ), + child: _MessageImageCarousel( + key: ValueKey( + trailingGallery.items.map((item) => item.url).join('\u0000'), + ), + items: trailingGallery.items, + leadingOverflow: mediaCarouselLeadingOverflow, + trailingOverflow: mediaCarouselTrailingOverflow, + onReply: onMediaReply, + onMore: onMediaMore, ), - items: trailingGallery.items, - leadingOverflow: mediaCarouselLeadingOverflow, - trailingOverflow: mediaCarouselTrailingOverflow, - onReply: onMediaReply, - onMore: onMediaMore, ), ], ); @@ -612,7 +733,9 @@ class _MessageImagePreview extends HookConsumerWidget { child: MediaViewerHero( tag: heroTag, child: ClipRRect( - borderRadius: BorderRadius.circular(Radii.md), + borderRadius: + MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(Radii.md), child: MediaImage( url: url, decodeWidth: previewDecodeWidth, @@ -661,7 +784,9 @@ class _MessageMediaPreviewFrame extends StatelessWidget { clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: backgroundColor, - borderRadius: BorderRadius.circular(Radii.md), + borderRadius: + MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(Radii.md), border: Border.all(color: context.colors.outlineVariant), ), child: child, diff --git a/mobile/lib/features/channels/message_content/bubble_segments.dart b/mobile/lib/features/channels/message_content/bubble_segments.dart new file mode 100644 index 00000000000..6ad52cdb8b2 --- /dev/null +++ b/mobile/lib/features/channels/message_content/bubble_segments.dart @@ -0,0 +1,58 @@ +import '../message_media.dart'; + +/// Splits top-level media from text without breaking Markdown blocks. +/// Fenced code, lists and quotes retain their nested content. +List<({String text, bool attachment})> messageBubbleSegments( + String content, + Map imeta, +) { + final result = <({String text, bool attachment})>[]; + final lines = []; + bool? attachment; + String? fence; + var nestedBlock = false; + final embed = RegExp(r'^!?\[[^\]]*\]\((https?://[^)\s]+)\)$'); + void flush() { + final text = lines.join('\n').trimRight(); + if (text.trim().isNotEmpty) { + result.add((text: text, attachment: attachment ?? false)); + } + lines.clear(); + } + + for (final line in content.split('\n')) { + final trimmed = line.trimLeft(); + final marker = RegExp(r'^(`{3,}|~{3,})').firstMatch(trimmed)?.group(1); + final inFence = fence != null || marker != null || line.startsWith(' '); + if (marker != null) { + if (fence == null) { + fence = marker; + } else if (marker[0] == fence[0] && marker.length >= fence.length) { + fence = null; + } + } + if (trimmed.isEmpty) { + nestedBlock = false; + } else if (RegExp(r'^(?:>|[-+*]\s|\d+[.)]\s)').hasMatch(trimmed)) { + nestedBlock = true; + } + // Indented embeds belong to a list, quote or code block. Lazy block + // continuations also stay together until a blank line ends the block. + final topLevel = line == trimmed && !nestedBlock; + final match = inFence || !topLevel + ? null + : embed.firstMatch(line.trimRight()); + final isAttachment = + match != null && + (line.trim().startsWith('!') || imeta.containsKey(match.group(1))); + if (attachment != null && + isAttachment != attachment && + line.trim().isNotEmpty) { + flush(); + } + if (line.trim().isNotEmpty) attachment = isAttachment; + lines.add(line); + } + flush(); + return result; +} diff --git a/mobile/lib/features/channels/message_content/bubble_shape.dart b/mobile/lib/features/channels/message_content/bubble_shape.dart new file mode 100644 index 00000000000..6c3f5676b0e --- /dev/null +++ b/mobile/lib/features/channels/message_content/bubble_shape.dart @@ -0,0 +1,49 @@ +part of '../message_content.dart'; + +/// Grouped message corners without a tail. +class _MessageBubbleShape extends ShapeBorder { + const _MessageBubbleShape({required this.start, required this.end}); + + final bool start; + final bool end; + + @override + EdgeInsetsGeometry get dimensions => EdgeInsets.zero; + + @override + ShapeBorder scale(double t) => this; + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + final round = Radius.circular( + math.min(Radii.container, rect.shortestSide / 2), + ); + const joined = Radius.circular(Radii.sm); + final body = Path() + ..addRRect( + RRect.fromRectAndCorners( + rect, + topLeft: start ? round : joined, + topRight: round, + bottomLeft: end ? round : joined, + bottomRight: round, + ), + ); + return body; + } + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) => + getOuterPath(rect, textDirection: textDirection); + + @override + void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {} +} + +BorderRadius _attachmentCorners({required bool start, required bool end}) => + BorderRadius.only( + topLeft: Radius.circular(start ? Radii.container : Radii.sm), + topRight: const Radius.circular(Radii.container), + bottomLeft: Radius.circular(end ? Radii.container : Radii.sm), + bottomRight: const Radius.circular(Radii.container), + ); diff --git a/mobile/lib/features/channels/message_content/media_carousel.dart b/mobile/lib/features/channels/message_content/media_carousel.dart index 3436fd4ab79..d847db16702 100644 --- a/mobile/lib/features/channels/message_content/media_carousel.dart +++ b/mobile/lib/features/channels/message_content/media_carousel.dart @@ -146,7 +146,9 @@ class _MessageImageCarousel extends HookConsumerWidget { clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.md), + borderRadius: + MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(Radii.md), border: Border.all( color: context.colors.outlineVariant, ), @@ -154,7 +156,9 @@ class _MessageImageCarousel extends HookConsumerWidget { child: MediaViewerHero( tag: heroTags[index], child: ClipRRect( - borderRadius: BorderRadius.circular(Radii.md), + borderRadius: + MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(Radii.md), child: MediaImage( url: item.url, decodeWidth: previewDecodeWidths[index], diff --git a/mobile/lib/features/channels/message_footer_layout.dart b/mobile/lib/features/channels/message_footer_layout.dart new file mode 100644 index 00000000000..a35409a9e25 --- /dev/null +++ b/mobile/lib/features/channels/message_footer_layout.dart @@ -0,0 +1,128 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Places reactions at the bottom edge of a message. +/// Both children remain inside the hit-test bounds, including overlapping badges. +class MessageFooterLayout extends StatelessWidget { + /// Creates a message with overlapping reaction controls. + const MessageFooterLayout({ + super.key, + required this.child, + required this.footer, + this.overlap = 0, + this.animateSize = false, + }); + + /// Main message content. + final Widget child; + + /// Controls attached to the bottom of the message. + final Widget footer; + + /// Vertical distance shared by the message and its controls. + final double overlap; + + /// Smoothly adjusts the space occupied by added or removed reactions. + final bool animateSize; + + @override + Widget build(BuildContext context) { + final layout = _FooterLayout(overlap: overlap, children: [child, footer]); + if (!animateSize || MediaQuery.disableAnimationsOf(context)) return layout; + return AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOutCubic, + alignment: Alignment.topLeft, + clipBehavior: Clip.none, + child: layout, + ); + } +} + +class _FooterLayout extends MultiChildRenderObjectWidget { + const _FooterLayout({required this.overlap, required super.children}); + final double overlap; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderMessageFooter(overlap); + + @override + void updateRenderObject( + BuildContext context, + covariant RenderBox renderObject, + ) { + (renderObject as _RenderMessageFooter) + ..overlap = overlap + ..markNeedsLayout(); + } +} + +class _RenderMessageFooter extends RenderBox + with + ContainerRenderObjectMixin< + RenderBox, + ContainerBoxParentData + >, + RenderBoxContainerDefaultsMixin< + RenderBox, + ContainerBoxParentData + > { + _RenderMessageFooter(this.overlap); + + double overlap; + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! ContainerBoxParentData) { + child.parentData = _FooterParentData(); + } + } + + Size _layout(BoxConstraints constraints, {required bool dry}) { + final content = firstChild!; + final footer = lastChild!; + final loose = constraints.loosen(); + Size measure(RenderBox child) { + if (dry) return child.getDryLayout(loose); + child.layout(loose, parentUsesSize: true); + return child.size; + } + + final contentSize = measure(content); + final footerSize = measure(footer); + final width = math.max(contentSize.width, footerSize.width); + final footerOffset = Offset(0, math.max(0, contentSize.height - overlap)); + if (!dry) { + (content.parentData! as ContainerBoxParentData).offset = + Offset.zero; + (footer.parentData! as ContainerBoxParentData).offset = + footerOffset; + } + return constraints.constrain( + Size( + width, + math.max(contentSize.height, footerOffset.dy + footerSize.height), + ), + ); + } + + @override + Size computeDryLayout(BoxConstraints constraints) => + _layout(constraints, dry: true); + + @override + void performLayout() => size = _layout(constraints, dry: false); + + @override + void paint(PaintingContext context, Offset offset) => + defaultPaint(context, offset); + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) => + defaultHitTestChildren(result, position: position); +} + +class _FooterParentData extends ContainerBoxParentData {} diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index 367a92e455c..cca734f1841 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -104,6 +104,9 @@ class ReactionRow extends HookWidget { /// Channel whose members define the reactor names' comparison context. final String channelId; + /// Groups reaction controls in one capsule attached to a bubble. + final bool combined; + final List reactions; final void Function(String emoji) onToggle; @@ -122,6 +125,7 @@ class ReactionRow extends HookWidget { required this.reactions, required this.onToggle, this.showAddButton = false, + this.combined = false, this.onAddReaction, }); @@ -148,6 +152,78 @@ class ReactionRow extends HookWidget { // the point. Without it, an empty row would be dead space. if (reactions.isEmpty && !showAddButton) return const SizedBox.shrink(); + if (combined && reactions.isNotEmpty) { + final count = reactions.fold( + 0, + (sum, reaction) => sum + reaction.count, + ); + final ownReaction = reactions.any( + (reaction) => reaction.reactedByCurrentUser, + ); + void openDetails() => showReactionDetailSheet( + context: context, + channelId: channelId, + reactions: liveReactions, + onToggle: (emoji) { + if (context.mounted) latestToggle.value(emoji); + }, + onAddReaction: showAddButton ? onAddReaction : null, + ); + return Padding( + padding: const EdgeInsets.only(bottom: Grid.xxs), + child: Align( + widthFactor: 1, + alignment: Alignment.centerLeft, + child: Semantics( + button: true, + label: + 'View $count ${count == 1 ? 'reaction' : 'reactions'}' + '${ownReaction ? ', including yours' : ''}', + child: GestureDetector( + key: const ValueKey('combined-reaction-tray'), + behavior: HitTestBehavior.opaque, + onTap: openDetails, + onLongPress: openDetails, + child: ExcludeSemantics( + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.secondaryContainer, + borderRadius: BorderRadius.circular(Radii.container), + border: Border.all(color: context.colors.surface, width: 2), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs + Grid.quarter, + vertical: Grid.half, + ), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: Grid.quarter, + runSpacing: Grid.quarter, + children: [ + for (final reaction in reactions) + Padding( + padding: const EdgeInsets.all(Grid.quarter), + child: _ReactionEmoji(reaction: reaction, size: 20), + ), + Padding( + padding: const EdgeInsets.only(left: Grid.half), + child: Text( + '${reactions.fold(0, (sum, reaction) => sum + reaction.count)}', + style: context.textTheme.labelMedium, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } + return Padding( padding: const EdgeInsets.only(top: Grid.half), child: Wrap( @@ -354,10 +430,25 @@ class _ReactionEmoji extends StatelessWidget { Widget build(BuildContext context) { final emojiUrl = reaction.emojiUrl; if (emojiUrl == null || emojiUrl.isEmpty) { - return NativeEmojiGlyph(emoji: reaction.emoji, size: size); + return NativeEmojiGlyph( + emoji: reaction.emoji, + size: size, + opticalBoxSize: size, + centerInOpticalBox: true, + ); } final shortcode = reaction.emoji.substring(1, reaction.emoji.length - 1); - return CustomEmojiImage(shortcode: shortcode, url: emojiUrl, size: size); + return SizedBox.square( + dimension: size, + child: FittedBox( + fit: BoxFit.scaleDown, + child: CustomEmojiImage( + shortcode: shortcode, + url: emojiUrl, + size: size, + ), + ), + ); } } @@ -366,6 +457,7 @@ Future showReactionDetailSheet({ required String channelId, required ValueListenable> reactions, required ValueChanged onToggle, + VoidCallback? onAddReaction, }) async { if (reactions.value.isEmpty) return; if (await _showNativeReactionDetails( @@ -373,6 +465,7 @@ Future showReactionDetailSheet({ channelId, reactions, onToggle, + onAddReaction, )) { return; } @@ -398,6 +491,7 @@ Future showReactionDetailSheet({ channelId: channelId, reactions: value, onToggle: onToggle, + onAddReaction: onAddReaction, ); }, ), @@ -408,11 +502,13 @@ class _ReactionDetailSheet extends HookConsumerWidget { final String channelId; final List reactions; final ValueChanged onToggle; + final VoidCallback? onAddReaction; const _ReactionDetailSheet({ required this.channelId, required this.reactions, required this.onToggle, + this.onAddReaction, }); @override @@ -446,6 +542,16 @@ class _ReactionDetailSheet extends HookConsumerWidget { scrollDirection: Axis.horizontal, child: Row( children: [ + if (onAddReaction != null) + IconButton( + key: const ValueKey('reaction-sheet-add'), + tooltip: 'Add reaction', + icon: const Icon(BuzzIcons.smilePlus), + onPressed: () { + Navigator.of(context).pop(); + onAddReaction!(); + }, + ), for (final reaction in reactions) Padding( padding: const EdgeInsets.only(right: Grid.half), @@ -613,9 +719,11 @@ Future _showNativeReactionDetails( String channelId, ValueListenable> reactions, ValueChanged onToggle, + VoidCallback? onAddReaction, ) async { if (!NativeMessagePresentation.isSupportedPlatform) return false; - return NativeMessagePresentation.withLease(() async { + var addRequested = false; + final handled = await NativeMessagePresentation.withLease(() async { final container = ProviderScope.containerOf(context, listen: false); final requestId = DateTime.now().microsecondsSinceEpoch.toString(); List pubkeys() => @@ -743,11 +851,15 @@ Future _showNativeReactionDetails( context, ).colorScheme.onSurface.withValues(alpha: 0.25).toARGB32(), 'dark': Theme.of(context).brightness == Brightness.dark, + 'canAddReaction': onAddReaction != null, + 'addReactionIconCodePoint': BuzzIcons.smilePlus.codePoint, 'profiles': profiles(), 'reactions': reactionData(), }, onReaction: onToggle); container.read(userCacheProvider.notifier).preload(pubkeys()); - return await pending != null; + final result = await pending; + addRequested = result?['action'] == 'addReaction'; + return result != null; } finally { disposed = true; reactions.removeListener(updateReactions); @@ -756,4 +868,6 @@ Future _showNativeReactionDetails( namesSubscription.close(); } }); + if (addRequested && context.mounted) onAddReaction?.call(); + return handled; } diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index e3b6f10c621..7e519ca9388 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -1,3 +1,5 @@ +import '../../shared/widgets/ios_scroll_to_top.dart'; +import 'message_footer_layout.dart'; import 'dart:async'; import 'dart:math' as math; @@ -25,6 +27,7 @@ import '../../shared/profile/user_cache_provider.dart'; import '../../shared/profile/user_profile.dart'; import 'android_ime_lift.dart'; import 'channel.dart'; +import 'channel_management_provider.dart' show currentPubkeyProvider; import 'channel_identity_names_provider.dart'; import 'channel_link_navigation.dart'; import 'channel_messages_provider.dart'; @@ -104,6 +107,10 @@ class ThreadDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // A route can open before profile metadata arrives. Follow the active + // signing identity instead of freezing that incomplete route snapshot. + final currentPubkey = + ref.watch(currentPubkeyProvider) ?? this.currentPubkey; final appView = View.of(context); final composerDockHeight = useState(0.0); final composerFocusNode = useFocusNode(); @@ -714,7 +721,7 @@ class ThreadDetailPage extends HookConsumerWidget { .where( (e) => currentPubkey == null || - e.pubkey.toLowerCase() != currentPubkey?.toLowerCase(), + e.pubkey.toLowerCase() != currentPubkey.toLowerCase(), ) .toList(); diff --git a/mobile/lib/features/channels/thread_detail_page/message_list.dart b/mobile/lib/features/channels/thread_detail_page/message_list.dart index dd1a1a4d5d2..d4894bfe29e 100644 --- a/mobile/lib/features/channels/thread_detail_page/message_list.dart +++ b/mobile/lib/features/channels/thread_detail_page/message_list.dart @@ -150,6 +150,27 @@ class _ThreadMessageList extends HookWidget { const headIndex = 0; final tailAnchorIndex = replies.length + 1; + useIosScrollToTop(context, () { + if (!itemScrollController.isAttached) return; + onUserScrollStart(); + if (MediaQuery.disableAnimationsOf(context)) { + itemScrollController.jumpTo(index: 0); + onUserScrollEnd(); + } else { + unawaited( + itemScrollController + .scrollTo( + index: 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ) + .whenComplete(() { + if (context.mounted) onUserScrollEnd(); + }), + ); + } + }); + return LaidOutViewportReporter( viewport: viewport, child: KeyboardDismissOnDrag( @@ -221,12 +242,27 @@ class _ThreadMessageList extends HookWidget { reply.createdAt, ); final showAuthor = + reply.hasAttachments || + previousReply?.hasAttachments == true || previousReply == null || showDayDivider || previousReply.pubkey.toLowerCase() != reply.pubkey.toLowerCase() || (reply.createdAt - previousReply.createdAt) > 300; + final nextReply = + chronologicalIndex + 1 < replies.length + ? replies[chronologicalIndex + 1] + : null; + final showAvatar = + reply.hasAttachments || + nextReply?.hasAttachments == true || + nextReply == null || + !isSameDay(reply.createdAt, nextReply.createdAt) || + nextReply.pubkey.toLowerCase() != + reply.pubkey.toLowerCase() || + nextReply.createdAt - reply.createdAt > 300; + // Check if this reply itself has children (nested thread). final nestedChildren = childrenByParent[reply.id]; final nestedSummary = @@ -236,6 +272,9 @@ class _ThreadMessageList extends HookWidget { return trackActiveScrollPosition( LocalMessageSendTransition( + messageId: reply.id, + channelId: channelId, + threadHeadId: head.id, key: ValueKey('thread-message-send-${reply.id}'), animate: isRecentLocalMessageSendAnimation( localSendAnimations, @@ -265,6 +304,7 @@ class _ThreadMessageList extends HookWidget { channelId: channelId, currentPubkey: currentPubkey, showAuthor: showAuthor, + showAvatar: showAvatar, isHighlighted: reply.id == highlightedMessageId, allMessages: allMessages, diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index 2334be5dd3d..b28da14bd90 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -6,6 +6,7 @@ class _ThreadMessage extends HookConsumerWidget { final String channelId; final String? currentPubkey; final bool showAuthor; + final bool showAvatar; final bool isHighlighted; final List? allMessages; final bool isMember; @@ -23,6 +24,7 @@ class _ThreadMessage extends HookConsumerWidget { required this.channelId, required this.currentPubkey, required this.showAuthor, + this.showAvatar = true, this.isHighlighted = false, this.allMessages, this.isMember = false, @@ -148,185 +150,228 @@ class _ThreadMessage extends HookConsumerWidget { snapshotKey: messageSnapshotKey, child: Padding( padding: EdgeInsets.only( - top: showAuthor ? 0 : Grid.xxs, - bottom: showAuthor ? 0 : Grid.xxs, + bottom: showAvatar ? Grid.quarter : Grid.quarter / 2, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - RepaintBoundary( - key: messageSnapshotKey, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - GestureDetector( - onTap: () => showUserProfileSheet( - context, - message.pubkey, - names: channelIdentityNamesProvider(channelId), - ), - child: _Avatar( - profile: profile, - pubkey: message.pubkey, - isAgent: isAgent, - ), - ) - else + child: MessageFooterLayout( + animateSize: true, + overlap: message.reactions.isNotEmpty ? Grid.xs : 0, + footer: (isThreadHead || message.reactions.isNotEmpty) + ? Padding( + padding: const EdgeInsets.only( + left: + messageAvatarSize + + messageAvatarContentGap + + Grid.xxs, + ), + child: ReactionRow( + combined: true, + messageId: message.id, + channelId: channelId, + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), + ), + ) + : const SizedBox.shrink(), + child: RepaintBoundary( + key: messageSnapshotKey, + child: Stack( + clipBehavior: Clip.none, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Padding( - padding: EdgeInsets.only( - top: showAuthor ? Grid.half : 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.quarter, - ), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - displayName: displayName, - username: messageUsernameLabel( - profile, - ), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: context.colors.onSurface, - metadataColor: - context.colors.onSurfaceVariant, - onAuthorTap: () => - showUserProfileSheet( - context, - message.pubkey, - names: - channelIdentityNamesProvider( - channelId, + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? Grid.half : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, + left: Grid.xs, + ), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + showTimestamp: true, + nameStyle: + messageUsernameTextStyle + .copyWith( + fontSize: 13.1, + height: 17 / 13.1, ), - ), - displayNameKey: ValueKey( - 'thread-message-author-${message.id}', - ), - usernameKey: ValueKey( - 'thread-message-username-${message.id}', - ), - timestampKey: ValueKey( - 'thread-message-timestamp-${message.id}', + metadataStyle: + messageMetadataTextStyle + .copyWith( + fontSize: 13.1, + height: 17 / 13.1, + ), + timestampStyle: + messageTimestampTextStyle + .copyWith( + fontSize: 11, + height: 17 / 11, + ), + displayName: displayName, + username: messageUsernameLabel( + profile, + ), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: + context.colors.onSurface, + metadataColor: context + .colors + .onSurfaceVariant, + onAuthorTap: () => + showUserProfileSheet( + context, + message.pubkey, + names: + channelIdentityNamesProvider( + channelId, + ), + ), + displayNameKey: ValueKey( + 'thread-message-author-${message.id}', + ), + usernameKey: ValueKey( + 'thread-message-username-${message.id}', + ), + timestampKey: ValueKey( + 'thread-message-timestamp-${message.id}', + ), ), ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall - ?.copyWith( - color: context - .colors - .onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context + .textTheme + .labelSmall + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], ], - ], + ), ), - ), - MessageContent( - content: message.content, - mentionNames: resolvedMentionNames, - mentionLabels: mentionLabels, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - scaleEmojiOnly: true, - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, + MessageContent( + content: message.content, + bubble: true, + hasReactions: message.reactions.isNotEmpty, + bubbleEnd: showAvatar, + bubbleStart: showAuthor, + ownMessage: + currentPubkey?.toLowerCase() == pk, + mentionNames: resolvedMentionNames, + mentionLabels: mentionLabels, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + scaleEmojiOnly: true, + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: channelId, + currentPubkey: + currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), ), - ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: channelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of( + viewerContext, + ).maybePop(); + } + }, + ), + onChannelTap: (targetChannelId) { + openChannelLink( + context: context, ref: ref, - message: message, - channelId: channelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of( - viewerContext, - ).maybePop(); - } - }, - ), - onChannelTap: (targetChannelId) { - openChannelLink( - context: context, - ref: ref, - channelId: targetChannelId, - currentChannelId: channelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet( - context, - pubkey, - names: channelIdentityNamesProvider( - channelId, + channelId: targetChannelId, + currentChannelId: channelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet( + context, + pubkey, + names: channelIdentityNamesProvider( + channelId, + ), ), - ), - ), - ], + ), + ], + ), ), ), - ), - ], - ), - ), - if (isThreadHead || message.reactions.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - left: messageAvatarSize + messageAvatarContentGap, + ], ), - child: ReactionRow( - messageId: message.id, - channelId: channelId, - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: isMember && !isArchived, - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, + if (showAvatar) + Positioned( + left: 0, + bottom: 0, + child: GestureDetector( + onTap: () => showUserProfileSheet( + context, + message.pubkey, + names: channelIdentityNamesProvider(channelId), + ), + child: _Avatar( + profile: profile, + pubkey: message.pubkey, + isAgent: isAgent, + ), + ), ), - ), - ), - ], + ], + ), + ), ), ), ), diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart index 81bd0537f62..3e2e72005be 100644 --- a/mobile/lib/features/channels/timeline_message.dart +++ b/mobile/lib/features/channels/timeline_message.dart @@ -6,6 +6,8 @@ import '../../shared/relay/relay.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/mentions/mention_tags.dart'; import 'channel_window.dart'; +import 'message_media.dart'; +import 'message_content/bubble_segments.dart'; enum SystemEventType { memberJoined, @@ -111,7 +113,7 @@ class SystemEvent { return '$actor joined the channel'; } final target = resolveLabel(targetPubkey); - return '$target was added by $actor'; + return '$actor added $target to the channel'; }(), SystemEventType.memberLeft => '$actor left the channel', SystemEventType.memberRemoved => () { @@ -212,7 +214,11 @@ class TimelineMessage { /// Attachment messages stay visually distinct from surrounding messages, /// even when several are sent by the same author in quick succession. bool get hasAttachments => - tags.any((tag) => tag.isNotEmpty && tag.first == 'imeta'); + tags.any((tag) => tag.isNotEmpty && tag.first == 'imeta') || + messageBubbleSegments( + content, + parseImetaTags(tags), + ).any((segment) => segment.attachment); } @immutable @@ -255,13 +261,20 @@ const _membershipGroupWindowSeconds = 5 * 60; class _MembershipChange { final String? actor; final bool isSelfJoin; + final SystemEventType type; - const _MembershipChange({required this.actor, required this.isSelfJoin}); + const _MembershipChange({ + required this.actor, + required this.isSelfJoin, + required this.type, + }); } _MembershipChange? _membershipChange(MainTimelineEntry entry) { final event = entry.message.systemEvent; - if (!entry.message.isSystem || event?.type != SystemEventType.memberJoined) { + if (!entry.message.isSystem || + (event?.type != SystemEventType.memberJoined && + event?.type != SystemEventType.memberRemoved)) { return null; } @@ -271,8 +284,10 @@ _MembershipChange? _membershipChange(MainTimelineEntry entry) { return null; } - final isSelfJoin = actor == target; + final isSelfJoin = + event!.type == SystemEventType.memberJoined && actor == target; return _MembershipChange( + type: event.type, actor: isSelfJoin ? null : actor, isSelfJoin: isSelfJoin, ); @@ -282,7 +297,8 @@ bool _membershipChangesCanGroup( _MembershipChange first, _MembershipChange second, ) { - return first.isSelfJoin == second.isSelfJoin && + return first.type == second.type && + first.isSelfJoin == second.isSelfJoin && (first.isSelfJoin || first.actor == second.actor); } @@ -294,10 +310,9 @@ bool _isSameLocalDay(int firstTimestamp, int secondTimestamp) { first.day == second.day; } -/// Groups consecutive membership arrivals using the same display rule as -/// desktop: matching additions (or self-joins) within a fixed five-minute -/// window become one render item. Other events and local day boundaries break -/// the group. +/// Groups consecutive matching additions, removals, or self-joins within a +/// five-minute window into one render item. Other events and local day +/// boundaries break the group. /// /// Each inner list is one renderable timeline item. Non-grouped entries are /// returned as single-item lists. @@ -462,6 +477,9 @@ List formatTimeline( if (event.kind == EventKind.systemMessage) { final systemEvent = SystemEvent.fromContent(event.content); + // Purpose is legacy metadata with no mobile surface. Omit its activity + // before grouping so it cannot leave blank rows or split sender groups. + if (systemEvent?.type == SystemEventType.purposeChanged) continue; if (systemEvent != null) { result.add( TimelineMessage( diff --git a/mobile/lib/features/channels/voice_note_attachment.dart b/mobile/lib/features/channels/voice_note_attachment.dart index c62bd356017..064355a2773 100644 --- a/mobile/lib/features/channels/voice_note_attachment.dart +++ b/mobile/lib/features/channels/voice_note_attachment.dart @@ -1,3 +1,4 @@ +import '../../shared/widgets/message_attachment_corners.dart'; import 'dart:async'; import 'dart:math' as math; @@ -147,7 +148,9 @@ class VoiceNoteAttachment extends HookConsumerWidget { padding: const EdgeInsets.all(Grid.twelve), decoration: BoxDecoration( color: context.colors.surface, - borderRadius: BorderRadius.circular(radius), + borderRadius: + MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(radius), border: Border.all(color: context.colors.outlineVariant), ), child: Row( diff --git a/mobile/lib/shared/emoji/native_emoji_glyph.dart b/mobile/lib/shared/emoji/native_emoji_glyph.dart index 40b6f81b798..affe6c69e41 100644 --- a/mobile/lib/shared/emoji/native_emoji_glyph.dart +++ b/mobile/lib/shared/emoji/native_emoji_glyph.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; /// A standalone system emoji whose visual centre matches its surrounding UI. /// -/// Apple's emoji glyphs sit slightly low inside Flutter's text box. Keep the -/// layout box unchanged and lift only the painted glyph on iOS; Android's -/// system emoji metrics are already visually centred. +/// Inline glyphs retain their platform baseline adjustment. Fixed reaction +/// boxes can opt into emoji-font metrics and optical centering independently +/// of the surrounding text font. class NativeEmojiGlyph extends StatelessWidget { /// Creates a system emoji with optional optical bounds. const NativeEmojiGlyph({ @@ -13,6 +13,7 @@ class NativeEmojiGlyph extends StatelessWidget { required this.emoji, required this.size, this.opticalBoxSize, + this.centerInOpticalBox = false, }); /// Emoji text rendered by the platform font. @@ -24,6 +25,9 @@ class NativeEmojiGlyph extends StatelessWidget { /// Optional square that normalizes the apparent bounds of wide emoji. final double? opticalBoxSize; + /// Centers reaction artwork in its fixed box instead of a surrounding line. + final bool centerInOpticalBox; + @override Widget build(BuildContext context) { Widget glyph = Text( @@ -31,9 +35,19 @@ class NativeEmojiGlyph extends StatelessWidget { maxLines: 1, softWrap: false, textScaler: TextScaler.noScaling, - style: TextStyle(fontSize: size, height: 1), + style: TextStyle( + fontSize: size, + height: 1, + leadingDistribution: centerInOpticalBox + ? TextLeadingDistribution.even + : null, + fontFamily: + centerInOpticalBox && defaultTargetPlatform == TargetPlatform.iOS + ? 'Apple Color Emoji' + : null, + ), ); - if (defaultTargetPlatform == TargetPlatform.iOS) { + if (defaultTargetPlatform == TargetPlatform.iOS && !centerInOpticalBox) { glyph = Transform.translate(offset: const Offset(0, -1), child: glyph); } final boxSize = opticalBoxSize; @@ -42,9 +56,16 @@ class NativeEmojiGlyph extends StatelessWidget { // Emoji sequences have very different typographic advances. Constrain // them to the same square optical box so wide glyphs scale down around the // same center instead of appearing to lean toward one edge. - return SizedBox.square( - dimension: boxSize, - child: FittedBox(fit: BoxFit.scaleDown, child: glyph), - ); + Widget fitted = FittedBox(fit: BoxFit.scaleDown, child: glyph); + if (defaultTargetPlatform == TargetPlatform.iOS && centerInOpticalBox) { + // Apple emoji artwork sits below its line-box center on the device. + // Apply the optical lift after fitting so wide sequences get the same + // correction as a single glyph; the surrounding box stays centered. + fitted = Transform.translate( + offset: Offset(0, -boxSize * 0.1), + child: fitted, + ); + } + return SizedBox.square(dimension: boxSize, child: fitted); } } diff --git a/mobile/lib/shared/widgets/frosted_scaffold.dart b/mobile/lib/shared/widgets/frosted_scaffold.dart index 160d9ab127c..1c6b2b211fb 100644 --- a/mobile/lib/shared/widgets/frosted_scaffold.dart +++ b/mobile/lib/shared/widgets/frosted_scaffold.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'ios_navigation_bar.dart'; +import 'ios_scroll_to_top.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import '../theme/theme.dart'; @@ -176,7 +177,9 @@ class FrostedScaffold extends HookWidget { // rotation) can apply its new collapse range without another scroll. final next = topContentDepth.clamp(0.0, double.infinity); if ((scrollOffset.value - next).abs() > 0.1) { - scrollOffset.value = next; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) scrollOffset.value = next; + }); } } final next = topContentDepth > 0.5; @@ -185,20 +188,36 @@ class FrostedScaffold extends HookWidget { }, child: body, ); - final scaffold = IosNavigationScrollScope( - offset: scrollOffset, - child: Scaffold( - backgroundColor: backgroundColor, - resizeToAvoidBottomInset: resizeToAvoidBottomInset, - floatingActionButton: floatingActionButton, - body: FrostedScrollUnderScope( - isScrolledUnder: isScrolledUnder.value, - child: Stack( - children: _stackChildren( - observedBody, - pinnedController, - pinnedStorage, - pinnedKey, + final scaffold = IosScrollToTop( + onScrollToTop: nativePinned + ? () { + if (!pinnedController.hasClients) return; + if (MediaQuery.disableAnimationsOf(context)) { + pinnedController.jumpTo(0); + } else { + pinnedController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + } + } + : null, + child: IosNavigationScrollScope( + offset: scrollOffset, + child: Scaffold( + backgroundColor: backgroundColor, + resizeToAvoidBottomInset: resizeToAvoidBottomInset, + floatingActionButton: floatingActionButton, + body: FrostedScrollUnderScope( + isScrolledUnder: isScrolledUnder.value, + child: Stack( + children: _stackChildren( + observedBody, + pinnedController, + pinnedStorage, + pinnedKey, + ), ), ), ), diff --git a/mobile/lib/shared/widgets/ios_scroll_to_top.dart b/mobile/lib/shared/widgets/ios_scroll_to_top.dart new file mode 100644 index 00000000000..e703eaa5fa0 --- /dev/null +++ b/mobile/lib/shared/widgets/ios_scroll_to_top.dart @@ -0,0 +1,136 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Handles the native status-bar gesture for the visible page's vertical list. +class IosScrollToTop extends HookWidget { + final Widget child; + + /// Used when a page coordinates multiple scroll positions, such as a title. + final VoidCallback? onScrollToTop; + + const IosScrollToTop({super.key, required this.child, this.onScrollToTop}); + + @override + Widget build(BuildContext context) { + final target = useMemoized(_ScrollTarget.new); + final marker = useMemoized(GlobalKey.new); + final position = useRef(null); + final callback = useRef(() {}); + callback.value = () { + if (!context.mounted || ModalRoute.of(context)?.isCurrent == false) { + return; + } + final renderObject = marker.currentContext?.findRenderObject(); + if (renderObject == null || !renderObject.attached) return; + final hits = HitTestResult(); + WidgetsBinding.instance.hitTestInView( + hits, + Offset.zero, + View.of(context).viewId, + ); + if (!hits.path.any((hit) => hit.target == renderObject)) return; + if (target.callback != null) { + target.callback!(); + } else if (onScrollToTop != null) { + onScrollToTop!(); + } else { + final scroll = position.value; + if (scroll == null || + scroll.context.notificationContext?.mounted != true || + !scroll.hasContentDimensions) { + return; + } + final top = scroll.axisDirection == AxisDirection.up + ? scroll.maxScrollExtent + : scroll.minScrollExtent; + if (MediaQuery.disableAnimationsOf(context)) { + scroll.jumpTo(top); + } else { + unawaited( + scroll.animateTo( + top, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ), + ); + } + } + }; + useEffect(() { + if (defaultTargetPlatform != TargetPlatform.iOS) return null; + final observer = _StatusBarObserver(() => callback.value()); + WidgetsBinding.instance.addObserver(observer); + return () => WidgetsBinding.instance.removeObserver(observer); + }, const []); + if (defaultTargetPlatform != TargetPlatform.iOS) return child; + return _ScrollTargetScope( + target: target, + child: Listener( + key: marker, + behavior: HitTestBehavior.translucent, + child: NotificationListener( + onNotification: (notification) { + final BuildContext? source; + final int depth; + if (notification is ScrollNotification) { + source = notification.context; + depth = notification.depth; + } else if (notification is ScrollMetricsNotification) { + source = notification.context; + depth = notification.depth; + } else { + return false; + } + if (depth != 0 || + source == null || + source.findAncestorWidgetOfExactType() != null) { + return false; + } + final scroll = Scrollable.maybeOf(source)?.position; + if (scroll?.axis == Axis.vertical) position.value = scroll; + return false; + }, + // This page handles the native event, including custom/reversed lists. + child: PrimaryScrollController.none(child: child), + ), + ), + ); + } +} + +/// Registers an indexed list's top target with its enclosing page. +void useIosScrollToTop(BuildContext context, VoidCallback callback) { + final target = context + .dependOnInheritedWidgetOfExactType<_ScrollTargetScope>() + ?.target; + useEffect(() { + if (target == null) return null; + target.callback = callback; + return () { + if (target.callback == callback) target.callback = null; + }; + }, [target, callback]); +} + +class _ScrollTarget { + VoidCallback? callback; +} + +class _ScrollTargetScope extends InheritedWidget { + final _ScrollTarget target; + const _ScrollTargetScope({required this.target, required super.child}); + @override + bool updateShouldNotify(_ScrollTargetScope oldWidget) => + target != oldWidget.target; +} + +class _StatusBarObserver with WidgetsBindingObserver { + final VoidCallback onTap; + _StatusBarObserver(this.onTap); + @override + void handleStatusBarTap() => onTap(); +} diff --git a/mobile/lib/shared/widgets/message_attachment_corners.dart b/mobile/lib/shared/widgets/message_attachment_corners.dart new file mode 100644 index 00000000000..d9b609c60c8 --- /dev/null +++ b/mobile/lib/shared/widgets/message_attachment_corners.dart @@ -0,0 +1,21 @@ +import 'package:flutter/widgets.dart'; + +/// Shares conversation corner geometry with attachment surfaces. +class MessageAttachmentCorners extends InheritedWidget { + final BorderRadius borderRadius; + + const MessageAttachmentCorners({ + super.key, + required this.borderRadius, + required super.child, + }); + + /// Returns the conversation corners, or null outside a message bubble. + static BorderRadius? of(BuildContext context) => context + .dependOnInheritedWidgetOfExactType() + ?.borderRadius; + + @override + bool updateShouldNotify(MessageAttachmentCorners oldWidget) => + borderRadius != oldWidget.borderRadius; +} diff --git a/mobile/lib/shared/widgets/message_author_meta.dart b/mobile/lib/shared/widgets/message_author_meta.dart index ed1e3a6e2f4..2de13ad5b54 100644 --- a/mobile/lib/shared/widgets/message_author_meta.dart +++ b/mobile/lib/shared/widgets/message_author_meta.dart @@ -13,6 +13,9 @@ class MessageAuthorMeta extends StatelessWidget { /// Timestamp label shown after the author metadata. final String timestamp; + /// Whether metadata includes the timestamp alongside the author. + final bool showTimestamp; + /// Color applied to [displayName]. final Color nameColor; @@ -48,6 +51,7 @@ class MessageAuthorMeta extends StatelessWidget { required this.nameColor, required this.metadataColor, this.username, + this.showTimestamp = true, this.onAuthorTap, this.displayNameKey, this.usernameKey, @@ -104,17 +108,19 @@ class MessageAuthorMeta extends StatelessWidget { ), ), ], - const SizedBox(width: Grid.xxs), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: metadataMaxWidth), - child: Text( - timestamp, - key: timestampKey, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: resolvedTimestampStyle, + if (showTimestamp) ...[ + const SizedBox(width: Grid.xxs), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: metadataMaxWidth), + child: Text( + timestamp, + key: timestampKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: resolvedTimestampStyle, + ), ), - ), + ], ], ); }, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index cf746acde5c..2286bbb5726 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -45,6 +45,7 @@ import 'package:buzz/features/channels/day_divider.dart'; import 'package:buzz/features/channels/emoji_picker.dart'; import 'package:buzz/features/channels/ime_metrics_settle_observer.dart'; import 'package:buzz/features/channels/local_message_send_animation_provider.dart'; +import 'package:buzz/features/channels/local_send_bubble_surface.dart'; import 'package:buzz/features/channels/message_action_backdrop_state.dart'; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/mobile_huddle_controller.dart'; @@ -84,6 +85,7 @@ part 'thread_title_capsule_cases.dart'; part 'channel_detail_page_test/loading_review_tests.dart'; part 'channel_detail_page_test/presence_tests.dart'; part 'channel_detail_page_test/action_row_tests.dart'; +part 'channel_detail_page_test/bubble_tests.dart'; const _channelId = '11111111-2222-4333-8444-555555555555'; const _huddleChannelId = '8d764100-fd8f-44cf-9c98-6d8fbd739b8c'; @@ -219,6 +221,9 @@ NostrEvent _edit({ ); Widget _buildTestable({ + ProfileNotifier? profileNotifier, + String? signingPubkey, + bool darkTheme = false, required List messages, PresenceCacheNotifier? presenceCache, List typing = const [], @@ -294,7 +299,11 @@ Widget _buildTestable({ userCacheProvider.overrideWith( () => userCacheNotifier ?? _FakeUserCacheNotifier(users), ), - profileProvider.overrideWith(() => _FakeProfileNotifier()), + profileProvider.overrideWith( + () => profileNotifier ?? _FakeProfileNotifier(), + ), + if (signingPubkey != null) + myPubkeyProvider.overrideWithValue(signingPubkey), if (presenceCache != null) presenceCacheProvider.overrideWith(() => presenceCache), channelsProvider.overrideWith(() => fakeChannelsNotifier), @@ -404,7 +413,7 @@ Widget _buildTestable({ ], child: MaterialApp( navigatorKey: navigatorKey, - theme: AppTheme.light(), + theme: darkTheme ? AppTheme.dark() : AppTheme.light(), builder: (context, child) => MediaQuery( data: MediaQuery.of(context).copyWith( textScaler: textScaler, @@ -506,6 +515,7 @@ void main() { threadTitleCapsuleTests(); presenceTests(); actionRowTests(); + bubbleLayoutTests(); setUp(() async { SharedPreferences.setMockInitialValues({}); _testPrefs = await SharedPreferences.getInstance(); @@ -653,7 +663,14 @@ void main() { await tester.pumpAndSettle(); AvatarImage avatarIn(Finder row) => tester.widget( - find.descendant(of: row, matching: find.byType(AvatarImage)), + find.descendant( + of: row, + matching: find.byWidgetPredicate( + (widget) => + widget is AvatarImage && + widget.radius == messageAvatarSize / 2, + ), + ), ); expect( avatarIn( @@ -3191,23 +3208,26 @@ void main() { } final aliceName = find.text('Alice'); final aliceText = tester.widget(aliceName); - expect(aliceText.style?.fontSize, messageUsernameTextStyle.fontSize); + expect(aliceText.style?.fontSize, 13.1); expect(aliceText.style?.fontWeight, messageUsernameTextStyle.fontWeight); - expect(aliceText.style?.height, messageUsernameTextStyle.height); + expect(aliceText.style?.height, 17 / 13.1); final aliceUsername = tester.widget( find.byKey(const ValueKey('message-username-msg1')), ); final aliceTimestamp = tester.widget( find.byKey(const ValueKey('message-timestamp-msg1')), ); - expect(aliceUsername.style?.fontSize, messageMetadataTextStyle.fontSize); + expect(aliceUsername.style?.fontSize, 13.1); expect(aliceUsername.style?.fontWeight, FontWeight.w400); - expect(aliceUsername.style?.height, messageMetadataTextStyle.height); + expect(aliceUsername.style?.height, 17 / 13.1); + expect(aliceTimestamp.style?.fontSize, 11); + expect(aliceTimestamp.style?.fontWeight, FontWeight.w400); expect( - aliceTimestamp.style?.fontSize, - messageTimestampTextStyle.fontSize, + tester + .getCenter(find.byKey(const ValueKey('message-timestamp-msg1'))) + .dy, + closeTo(tester.getCenter(aliceName).dy, 0.01), ); - expect(aliceTimestamp.style?.fontWeight, FontWeight.w400); expect( aliceTimestamp.style?.fontSize, lessThan(aliceText.style!.fontSize!), @@ -3509,12 +3529,13 @@ void main() { }, ); - testWidgets('uses larger participant avatars in reply summaries', ( + testWidgets('attaches replies after reactions with an eight pixel gap', ( tester, ) async { await tester.pumpWidget( _buildTestable( messages: [ + _reaction(id: 'reaction-root', targetId: 'root'), _textMsg( id: 'root', pubkey: 'alice', @@ -3544,33 +3565,30 @@ void main() { ); await tester.pumpAndSettle(); - expect( - findRichText( - '2 replies · last reply ' - '${formatThreadSummaryLastReplyTime(1200)}', - ), - findsOneWidget, - ); - expect(find.byIcon(BuzzIcons.chevronRight), findsNothing); - final replyAvatars = find.byType(SmallAvatar); - expect(replyAvatars, findsNWidgets(2)); - for (final avatar in replyAvatars.evaluate()) { + expect(find.text('2 replies'), findsOneWidget); + expect(find.byIcon(BuzzIcons.messageSquareReply), findsNothing); + expect(find.byType(SmallAvatar), findsNWidgets(2)); + for (final avatar in find.byType(SmallAvatar).evaluate()) { expect( tester.getSize(find.byWidget(avatar.widget)), - const Size.square(32), + const Size.square(20), ); } - final summaryPadding = tester.widget( - find.byKey(const ValueKey('thread-summary-root')), + final pill = find.byKey(const ValueKey('thread-summary-root')); + final tray = find.byKey(const ValueKey('combined-reaction-tray')); + expect(tester.getTopLeft(pill).dx - tester.getTopRight(tray).dx, 8); + expect( + tester.getCenter(pill).dy, + closeTo(tester.getCenter(tray).dy, 0.1), ); + final body = find.text('Thread head', findRichText: true); expect( - summaryPadding.padding, - const EdgeInsets.only( - left: messageAvatarSize + messageAvatarContentGap, - top: Grid.half, - bottom: Grid.xs, - ), + tester.getTopLeft(pill).dy, + lessThan(tester.getBottomLeft(body).dy + 24), ); + await tester.tap(pill); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsOneWidget); }); testWidgets('constrains reply summaries at accessibility text sizes', ( @@ -3592,7 +3610,7 @@ void main() { content: 'Thread head', createdAt: lastReplyAt - 300, ), - for (var i = 0; i < 3; i++) + for (var i = 0; i < 4; i++) _textMsg( id: 'reply-$i', pubkey: 'participant-$i', @@ -3609,9 +3627,10 @@ void main() { ); await tester.pumpAndSettle(); - final summaryText = tester.widget(findRichText('3 replies')); + final summaryText = tester.widget(findRichText('4 replies')); expect(summaryText.maxLines, 2); expect(summaryText.overflow, TextOverflow.ellipsis); + expect(find.byType(SmallAvatar), findsNWidgets(3)); expect(tester.takeException(), isNull); }); @@ -4719,6 +4738,162 @@ void main() { ); }); + for (final dark in [false, true]) { + for (final thread in [false, true]) { + testWidgets( + 'own bubble colors survive profile reloads dark=$dark thread=$thread', + (tester) async { + final events = [ + _textMsg( + id: 'own-color', + pubkey: 'self', + content: 'My message', + createdAt: 2000, + ), + _textMsg( + id: 'other-color', + pubkey: 'other', + content: 'Their message', + createdAt: 2001, + ), + ]; + final profile = _MutableColorProfileNotifier(); + final timeline = formatTimeline(events, currentPubkey: 'self'); + await tester.pumpWidget( + _buildTestable( + messages: events, + profileNotifier: profile, + signingPubkey: 'SELF', + darkTheme: dark, + home: thread + ? ThreadDetailPage( + threadHead: timeline.first, + allMessages: timeline, + channelId: _channelId, + currentPubkey: null, + isMember: true, + isArchived: false, + ) + : null, + ), + ); + await tester.pumpAndSettle(); + Color ownColor() => tester + .widget( + find + .descendant( + of: find + .ancestor( + of: findRichText('My message'), + matching: find.byType(MessageContent), + ) + .first, + matching: find.byType(LocalSendBubbleSurface), + ) + .first, + ) + .color; + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent).first), + ); + final subscription = container.listen(profileProvider, (_, _) {}); + addTearDown(subscription.close); + final expected = dark ? const Color(0xFFF2F2F2) : Colors.black; + expect(ownColor(), expected); + for (final state in >[ + const AsyncLoading(), + AsyncError(StateError('Profile unavailable'), StackTrace.current), + const AsyncData(null), + const AsyncData( + UserProfile(pubkey: 'other', displayName: 'Stale profile'), + ), + const AsyncData(UserProfile(pubkey: 'self', displayName: 'Self')), + ]) { + profile.setProfileState(state); + await tester.pump(); + expect(ownColor(), expected); + } + await tester.pumpAndSettle(); + }, + ); + } + } + + for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { + for (final multiline in [false, true]) { + testWidgets( + 'group avatar stays fixed throughout a local send: $platform, multiline=$multiline', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = platform; + try { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final initial = [ + _textMsg( + id: 'first', + pubkey: 'self', + content: 'First message', + createdAt: 2000, + ), + ]; + final notifier = _FakeMessagesNotifier(initial); + await tester.pumpWidget( + _buildTestable( + messages: const [], + messagesNotifier: notifier, + users: const { + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + ), + ); + await tester.pumpAndSettle(); + Finder avatar(String id) => find.descendant( + of: find.byKey(ValueKey('message-row-$id')), + matching: find.byType(AvatarImage), + ); + final before = tester.getRect(avatar('first')); + final container = ProviderScope.containerOf( + tester.element(find.byType(ChannelDetailPage)), + ); + container + .read(localMessageSendAnimationProvider(_channelId).notifier) + .mark('second'); + notifier.setMessages([ + ...initial, + _textMsg( + id: 'second', + pubkey: 'self', + content: multiline + ? 'Second message\nwith more lines\nand more text' + : 'Second message', + createdAt: 2001, + ), + ]); + for (var frame = 0; frame < 32; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + final visibleAvatar = avatar('second').evaluate().isNotEmpty + ? avatar('second') + : avatar('first'); + expect(visibleAvatar, findsOneWidget); + final current = tester.getRect(visibleAvatar); + expect( + current.top, + closeTo(before.top, 1), + reason: 'avatar frame $frame', + ); + } + await tester.pumpAndSettle(); + expect(avatar('second'), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + } + } + testWidgets( 'a same-second local send follows its inserted row when the tail ID stays unchanged', (tester) async { @@ -5418,35 +5593,26 @@ void main() { expect(find.text('Alice'), findsOneWidget); final createdAction = findRichText('created this channel'); expect(createdAction, findsOneWidget); - expect( - tester.getSize(find.byType(CircleAvatar)), - const Size.square(messageAvatarSize), - ); - final nameRect = tester.getRect(find.text('Alice')); + expect(find.byType(CircleAvatar), findsNothing); final nameText = tester.widget(find.text('Alice')); - expect(nameText.style?.fontSize, systemMessageHeadingTextStyle.fontSize); - expect( - nameText.style?.fontWeight, - systemMessageHeadingTextStyle.fontWeight, - ); + expect(nameText.style?.fontSize, 14); + expect(nameText.style?.fontWeight, FontWeight.w400); expect( find.byKey(const ValueKey('system-message-username-alice')), findsNothing, ); - final timestampRect = tester.getRect( + expect( find.byKey(const ValueKey('system-message-timestamp-alice')), + findsNothing, ); - expect(timestampRect.left, greaterThan(nameRect.right)); final createdText = tester.widget(createdAction); expect( effectiveFontSizeForText(createdText.text, 'created this channel'), - systemMessageBodyTextStyle.fontSize, + 14, ); }); - testWidgets('renders a huddle event like a regular message row', ( - tester, - ) async { + testWidgets('renders a compact huddle activity row', (tester) async { await tester.pumpWidget( _buildTestable( messages: [_huddleMsg(id: 'huddle-1', kind: EventKind.huddleStarted)], @@ -5459,13 +5625,10 @@ void main() { expect(find.text('Alice'), findsOneWidget); expect(findRichText('started a huddle'), findsOneWidget); - expect( - tester.getSize(find.byType(CircleAvatar)), - const Size.square(messageAvatarSize), - ); + expect(find.byType(CircleAvatar), findsNothing); expect( find.byKey(const ValueKey('system-message-timestamp-alice')), - findsOneWidget, + findsNothing, ); }); @@ -8282,7 +8445,7 @@ void main() { (kind: EventKind.huddleEnded, action: 'ended the huddle'), ]) { testWidgets( - '${huddleEvent.action} aligns its author and body with a regular message', + '${huddleEvent.action} retains its system layout beside bubble messages', (tester) async { await tester.pumpWidget( _buildTestable( @@ -8322,34 +8485,23 @@ void main() { .descendant(of: regularRow, matching: find.byType(CircleAvatar)) .first, ); - final huddleAvatar = tester.getRect( - find - .descendant(of: huddleRow, matching: find.byType(CircleAvatar)) - .first, - ); - final regularAuthor = tester.getRect( - find.byKey(const ValueKey('message-author-regular-message')), + expect( + find.descendant(of: huddleRow, matching: find.byType(CircleAvatar)), + findsNothing, ); final huddleAuthor = tester.getRect( find.byKey(const ValueKey('system-message-author-bob')), ); - final regularBody = tester.getRect(findRichText('Regular message')); final huddleBody = tester.getRect(findRichText(huddleEvent.action)); - - expect( - huddleAuthor.top - huddleAvatar.top, - closeTo(regularAuthor.top - regularAvatar.top, 0.01), - ); - expect( - huddleBody.top - huddleAuthor.bottom, - closeTo(regularBody.top - regularAuthor.bottom, 0.01), - ); + expect(huddleAuthor.left, greaterThanOrEqualTo(huddleBody.left)); + expect(huddleBody.top, greaterThan(regularAvatar.bottom)); + expect(huddleAuthor.top, greaterThanOrEqualTo(huddleBody.top)); }, ); } testWidgets( - 'keeps membership and huddle rows evenly spaced with authored messages', + 'keeps membership and huddle rows separated from bubble messages', (tester) async { await tester.pumpWidget( _buildTestable( @@ -8396,29 +8548,26 @@ void main() { ); await tester.pumpAndSettle(); - Rect avatarRect(String rowKey) => tester.getRect( - find - .descendant( - of: find.byKey(ValueKey(rowKey)), - matching: find.byType(CircleAvatar), - ) - .first, - ); - - final avatars = [ - avatarRect('message-row-message-alice'), - avatarRect('message-row-message-bob'), - avatarRect('system-message-row-membership-carol'), - avatarRect('system-message-row-huddle-dave'), - avatarRect('message-row-message-erin'), - ]; - final authoredMessageGap = avatars[1].top - avatars[0].bottom; - - for (var index = 2; index < avatars.length; index++) { + final rows = [ + 'message-row-message-alice', + 'message-row-message-bob', + 'system-message-row-membership-carol', + 'system-message-row-huddle-dave', + 'message-row-message-erin', + ].map((key) => tester.getRect(find.byKey(ValueKey(key)))).toList(); + for (var index = 1; index < rows.length; index++) { + expect(rows[index].top, greaterThanOrEqualTo(rows[index - 1].bottom)); + } + for (final key in [ + 'system-message-row-membership-carol', + 'system-message-row-huddle-dave', + ]) { expect( - avatars[index].top - avatars[index - 1].bottom, - closeTo(authoredMessageGap, 1), - reason: 'row $index should use the authored-message gap', + find.descendant( + of: find.byKey(ValueKey(key)), + matching: find.byType(CircleAvatar), + ), + findsNothing, ); } }, @@ -8444,13 +8593,10 @@ void main() { expect(find.text('Bob'), findsOneWidget); expect(findRichText('joined the channel'), findsOneWidget); - expect( - tester.getSize(find.byType(CircleAvatar)), - const Size.square(messageAvatarSize), - ); + expect(find.byType(CircleAvatar), findsNothing); }); - testWidgets('opens a profile sheet from a membership system avatar', ( + testWidgets('opens a profile sheet from a membership activity name', ( tester, ) async { const alicePubkey = @@ -8512,7 +8658,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byType(CircleAvatar)); + await tester.tap( + find.byKey(const ValueKey('system-message-author-$bobPubkey')), + ); await tester.pumpAndSettle(); expect(find.text('Copy public key'), findsOneWidget); @@ -8557,7 +8705,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byType(CircleAvatar)); + await tester.tap( + find.byKey(const ValueKey('system-message-author-$invalidPubkey')), + ); await tester.pumpAndSettle(); expect(find.text('Copy public key'), findsOneWidget); @@ -8647,7 +8797,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byType(CircleAvatar)); + await tester.tap( + find.byKey(const ValueKey('system-message-author-$bobPubkey')), + ); await tester.pumpAndSettle(); expect(find.byType(UserProfileSheet), findsOneWidget); @@ -8676,7 +8828,7 @@ void main() { }, ); - testWidgets('opens a profile sheet from a huddle system avatar', ( + testWidgets('opens a profile sheet from a huddle activity name', ( tester, ) async { await tester.pumpWidget( @@ -8695,7 +8847,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byType(CircleAvatar)); + await tester.tap( + find.byKey(const ValueKey('system-message-author-alice')), + ); await tester.pumpAndSettle(); expect(find.text('Copy public key'), findsOneWidget); @@ -8738,7 +8892,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byType(CircleAvatar)); + await tester.tap( + find.byKey(const ValueKey('system-message-author-alice')), + ); await tester.pumpAndSettle(); expect(find.byType(UserProfileSheet), findsOneWidget); @@ -8803,9 +8959,7 @@ void main() { }, ); - testWidgets('opens a profile sheet from a generic system avatar', ( - tester, - ) async { + testWidgets('opens a profile sheet from an activity name', (tester) async { await tester.pumpWidget( _buildTestable( messages: [ @@ -8826,7 +8980,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byType(CircleAvatar).first); + await tester.tap( + find.byKey(const ValueKey('system-message-author-alice')), + ); await tester.pumpAndSettle(); expect(find.text('Copy public key'), findsOneWidget); @@ -8855,100 +9011,152 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Bob'), findsOneWidget); - final addedAction = findRichText('added by Alice'); + final addedAction = findRichText('added '); expect(addedAction, findsOneWidget); expect(find.text('Alice added Bob to the channel'), findsNothing); - expect( - tester.getSize(find.byType(CircleAvatar)), - const Size.square(messageAvatarSize), - ); - final nameRect = tester.getRect(find.text('Bob')); + expect(find.byType(CircleAvatar), findsNothing); expect( find.byKey(const ValueKey('system-message-username-bob')), findsNothing, ); - final timestampRect = tester.getRect( + expect( find.byKey(const ValueKey('system-message-timestamp-bob')), + findsNothing, ); - expect(timestampRect.left, greaterThan(nameRect.right)); - final addedText = tester.widget(addedAction); + final card = find.byKey(const ValueKey('activity-card-sys1')); expect( - effectiveFontSizeForText(addedText.text, 'added by Alice'), - systemMessageBodyTextStyle.fontSize, + find.descendant(of: card, matching: find.byType(Icon)), + findsNothing, ); + final actor = find.byKey(const ValueKey('system-message-author-alice')); + final target = find.byKey(const ValueKey('system-message-author-bob')); + expect(tester.getCenter(actor).dx, lessThan(tester.getCenter(target).dx)); + expect(findRichText(' to the channel'), findsOneWidget); + await tester.tap(target); + await tester.pumpAndSettle(); + expect(find.byType(UserProfileSheet), findsOneWidget); + Navigator.of(tester.element(find.byType(UserProfileSheet))).pop(); + await tester.pumpAndSettle(); + final addedText = tester.widget(addedAction); + expect(effectiveFontSizeForText(addedText.text, 'added '), 14); }); - testWidgets('groups member additions with tappable overflow names', ( - tester, - ) async { - final messages = [ - _systemMsg( - id: 'sys1', - payload: {'type': 'member_joined', 'actor': 'alice', 'target': 'bob'}, - createdAt: 1000, - ), - _systemMsg( - id: 'sys2', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'carol', - }, - createdAt: 1060, - ), - _systemMsg( - id: 'sys3', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'dave', - }, - createdAt: 1120, - ), - _systemMsg( - id: 'sys4', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'erin', - }, - createdAt: 1180, - ), - _systemMsg( - id: 'sys5', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'frank', - }, - createdAt: 1240, - ), - ]; + for (final removal in [false, true]) { + testWidgets( + 'limits activity names and opens all members, removal=$removal', + (tester) async { + final messages = [ + _systemMsg( + id: 'sys1', + payload: { + 'type': removal ? 'member_removed' : 'member_joined', + 'actor': 'alice', + 'target': 'bob', + }, + createdAt: 1000, + ), + _systemMsg( + id: 'sys2', + payload: { + 'type': removal ? 'member_removed' : 'member_joined', + 'actor': 'alice', + 'target': 'carol', + }, + createdAt: 1060, + ), + _systemMsg( + id: 'sys3', + payload: { + 'type': removal ? 'member_removed' : 'member_joined', + 'actor': 'alice', + 'target': 'dave', + }, + createdAt: 1120, + ), + _systemMsg( + id: 'sys4', + payload: { + 'type': removal ? 'member_removed' : 'member_joined', + 'actor': 'alice', + 'target': 'erin', + }, + createdAt: 1180, + ), + _systemMsg( + id: 'sys5', + payload: { + 'type': removal ? 'member_removed' : 'member_joined', + 'actor': 'alice', + 'target': 'frank', + }, + createdAt: 1240, + ), + ]; - await tester.pumpWidget( - _buildTestable( - messages: messages, - users: { - 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), - 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), - 'carol': const UserProfile(pubkey: 'carol', displayName: 'Carol'), - 'dave': const UserProfile(pubkey: 'dave', displayName: 'Dave'), - 'erin': const UserProfile(pubkey: 'erin', displayName: 'Erin'), - 'frank': const UserProfile(pubkey: 'frank', displayName: 'Frank'), - }, - ), - ); - await tester.pumpAndSettle(); + await tester.pumpWidget( + _buildTestable( + messages: messages, + users: { + 'alice': const UserProfile( + pubkey: 'alice', + displayName: 'Alice', + ), + 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), + 'carol': const UserProfile( + pubkey: 'carol', + displayName: 'Carol', + ), + 'dave': const UserProfile(pubkey: 'dave', displayName: 'Dave'), + 'erin': const UserProfile(pubkey: 'erin', displayName: 'Erin'), + 'frank': const UserProfile( + pubkey: 'frank', + displayName: 'Frank', + ), + }, + ), + ); + await tester.pumpAndSettle(); - expect(find.text('Bob'), findsOneWidget); - expect( - findRichText('added by Alice, along with Carol, Dave, Erin, and '), - findsOneWidget, + expect(find.text('Bob'), findsOneWidget); + for (final pubkey in ['alice', 'bob', 'carol']) { + final name = find.byKey(ValueKey('system-message-author-$pubkey')); + expect(name, findsOneWidget); + expect( + tester.widget(name).style?.fontWeight, + FontWeight.w400, + ); + } + expect(find.text('Dave'), findsNothing); + expect(find.text('Erin'), findsNothing); + expect(find.text('Frank'), findsNothing); + expect(findRichText('and 3 others'), findsOneWidget); + expect( + tester.widget(find.text('See all')).style?.fontWeight, + FontWeight.w600, + ); + await tester.tap(find.text('See all')); + await tester.pumpAndSettle(); + expect( + find.text(removal ? 'Removed members' : 'Added members'), + findsOneWidget, + ); + for (final pubkey in ['bob', 'carol', 'dave', 'erin', 'frank']) { + final tile = find.byKey(ValueKey('activity-member-$pubkey')); + await tester.ensureVisible(tile); + await tester.tap(tile); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byType(UserProfileSheet)) + .pubkey, + pubkey, + ); + await tester.tap(find.byTooltip('Close sheet').last); + await tester.pumpAndSettle(); + } + }, ); - expect(find.byKey(const Key('membership-overflow')), findsOneWidget); - expect(find.text('1 others'), findsOneWidget); - expect(find.byTooltip('Frank'), findsOneWidget); - }); + } testWidgets('aligns grouped reactions with the system message content', ( tester, @@ -8983,11 +9191,12 @@ void main() { ); await tester.pumpAndSettle(); - final avatarRect = tester.getRect(find.byType(CircleAvatar)); - final reactionRect = tester.getRect(find.byType(ReactionRow)); + final card = tester.getRect( + find.byKey(const ValueKey('activity-card-sys1')), + ); expect( - reactionRect.left, - avatarRect.left + messageAvatarSize + messageAvatarContentGap, + tester.getCenter(find.byType(ReactionRow)).dx, + closeTo(card.center.dx, 0.01), ); }); @@ -9009,14 +9218,25 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('add-reaction-pill')), findsOneWidget); - - await tester.tap(find.byKey(const ValueKey('add-reaction-pill'))); + expect( + find.byKey(const ValueKey('combined-reaction-tray')), + findsOneWidget, + ); + await tester.tap(find.byKey(const ValueKey('combined-reaction-tray'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('reaction-sheet-add'))); // Not pumpAndSettle: with no dataset asset in a widget test the sheet // shows its loading spinner, which animates forever. await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); expect(find.byType(EmojiPickerSheet), findsOneWidget); + expect( + find.byKey( + const ValueKey('reaction-details-sheet'), + skipOffstage: false, + ), + findsNothing, + ); }); testWidgets('an unreacted message keeps the timeline free of chrome', ( @@ -9052,50 +9272,10 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Bob left the channel'), findsOneWidget); + expect(find.text('Bob'), findsOneWidget); + expect(findRichText('left the channel'), findsOneWidget); }); - testWidgets( - 'constrains generic system timestamps at accessibility text sizes', - (tester) async { - tester.view.physicalSize = const Size(240, 600); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget( - _buildTestable( - messages: [ - _systemMsg( - id: 'sys-accessible', - payload: {'type': 'member_left', 'actor': 'alice'}, - createdAt: - DateTime(2026, 7, 28, 12, 34).millisecondsSinceEpoch ~/ - 1000, - ), - ], - users: { - 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, - textScaler: const TextScaler.linear(3), - ), - ); - await tester.pumpAndSettle(); - - final timestampFinder = find.byKey( - const ValueKey('system-message-timestamp-sys-accessible'), - ); - final timestamp = tester.widget(timestampFinder); - expect(timestamp.maxLines, 1); - expect(timestamp.overflow, TextOverflow.ellipsis); - expect( - tester.getSize(timestampFinder).width, - lessThanOrEqualTo(Grid.xxl), - ); - expect(tester.takeException(), isNull); - }, - ); - testWidgets('renders member_removed system event', (tester) async { final messages = [ _systemMsg( @@ -9121,7 +9301,10 @@ void main() { expect(find.text('Alice'), findsOneWidget); expect(findRichText('removed '), findsOneWidget); - expect(find.widgetWithText(MessageMentionPill, 'Bob'), findsOneWidget); + expect( + find.byKey(const ValueKey('system-message-author-bob')), + findsOneWidget, + ); }); testWidgets('renders topic_changed system event', (tester) async { @@ -9152,34 +9335,6 @@ void main() { ); }); - testWidgets('renders purpose_changed system event', (tester) async { - final messages = [ - _systemMsg( - id: 'sys1', - payload: { - 'type': 'purpose_changed', - 'actor': 'alice', - 'purpose': 'Team standup notes', - }, - ), - ]; - - await tester.pumpWidget( - _buildTestable( - messages: messages, - users: { - 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, - ), - ); - await tester.pumpAndSettle(); - - expect( - findRichText('changed the purpose to "Team standup notes"'), - findsOneWidget, - ); - }); - testWidgets('system message breaks author grouping', (tester) async { final messages = [ _textMsg( @@ -11933,10 +12088,7 @@ void main() { final threadTimestamp = tester.widget( find.byKey(const ValueKey('thread-message-timestamp-thread-root')), ); - expect( - threadTimestamp.style?.fontSize, - messageTimestampTextStyle.fontSize, - ); + expect(threadTimestamp.style?.fontSize, 11); expect( find.descendant( of: find.byKey(const ValueKey('thread-message-row-thread-root')), @@ -12419,7 +12571,7 @@ void main() { id: 'reply-$i', pubkey: 'bob', content: i == 29 - ? List.filled(33, 'Tall latest reply').join('\n') + ? List.filled(31, 'Tall latest reply').join('\n') : 'Reply $i', createdAt: 1100 + i, extraTags: const [ @@ -12602,7 +12754,7 @@ void main() { findsNothing, ); - for (var i = 0; i < 12; i++) { + for (var i = 0; i < 20; i++) { await tester.drag(list, const Offset(0, -100)); await tester.pumpAndSettle(); } @@ -12785,7 +12937,7 @@ void main() { id: 'reply-$i', pubkey: 'bob', content: i == 29 - ? List.filled(15, 'Tall latest reply').join('\n') + ? List.filled(14, 'Tall latest reply').join('\n') : 'Reply $i', createdAt: 1100 + i, extraTags: const [ @@ -15050,7 +15202,10 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('reaction-pill-👍')), findsNothing); + expect( + find.byKey(const ValueKey('combined-reaction-tray')), + findsNothing, + ); // The reaction arrives over the channel socket, as it does on device. messagesNotifier.setMessages([ @@ -15061,7 +15216,10 @@ void main() { // Still on the thread route — no pop needed for the pill to appear. expect(find.byType(ThreadDetailPage), findsOneWidget); - expect(find.byKey(const ValueKey('reaction-pill-👍')), findsOneWidget); + expect( + find.byKey(const ValueKey('combined-reaction-tray')), + findsOneWidget, + ); }); testWidgets('a live deletion does not restore the routed thread head', ( @@ -15190,9 +15348,8 @@ void main() { await tester.pumpAndSettle(); // The head keeps a standing +; the bare reply gets none, so a thread - // reads as quietly as the channel does. Two + pills, not three: the head - // and the reacted reply. - expect(find.byKey(const ValueKey('add-reaction-pill')), findsNWidgets(2)); + // reads quietly. Reacted replies open details, whose first control adds emoji. + expect(find.byKey(const ValueKey('add-reaction-pill')), findsOneWidget); final headRow = find.descendant( of: find.byKey(const ValueKey('thread-message-group-thread-root')), matching: find.byKey(const ValueKey('add-reaction-pill')), @@ -15208,7 +15365,7 @@ void main() { expect( find.descendant( of: find.byKey(const ValueKey('thread-message-group-reply-reacted')), - matching: find.byKey(const ValueKey('add-reaction-pill')), + matching: find.byKey(const ValueKey('combined-reaction-tray')), ), findsOneWidget, ); @@ -16198,3 +16355,10 @@ String _previewRowAvatarInitial(WidgetTester tester, String pubkey) { ); return initial.data!; } + +class _MutableColorProfileNotifier extends ProfileNotifier { + @override + Future build() => SynchronousFuture(null); + + void setProfileState(AsyncValue value) => state = value; +} diff --git a/mobile/test/features/channels/channel_detail_page_test/action_row_tests.dart b/mobile/test/features/channels/channel_detail_page_test/action_row_tests.dart index 3e5919cec4e..824b07a4fac 100644 --- a/mobile/test/features/channels/channel_detail_page_test/action_row_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/action_row_tests.dart @@ -14,7 +14,7 @@ void actionRowTests() { payload: {'type': 'member_removed'}, action: 'removed Someone from the channel', ), - for (final field in ['topic', 'purpose']) ...[ + for (final field in ['topic']) ...[ ( payload: { 'type': '${field}_changed', @@ -33,7 +33,9 @@ void actionRowTests() { group('Channel action rows', () { for (var index = 0; index < cases.length; index++) { final scenario = cases[index]; - testWidgets('uses message layout for action $index', (tester) async { + testWidgets('uses compact activity layout for action $index', ( + tester, + ) async { await tester.pumpWidget( _buildTestable( messages: [ @@ -60,15 +62,14 @@ void actionRowTests() { of: row, matching: find.byType(CircleAvatar), ); - expect(avatars, findsOneWidget); - expect(tester.getSize(avatars), const Size.square(messageAvatarSize)); + expect(avatars, findsNothing); final author = find.byKey( const ValueKey('system-message-author-alice'), ); expect(author, findsOneWidget); expect( find.byKey(const ValueKey('system-message-author-bob')), - findsNothing, + scenario.payload['target'] == 'bob' ? findsOneWidget : findsNothing, ); expect(findRichText('Alice ${scenario.action}'), findsNothing); final body = findRichText( @@ -76,10 +77,10 @@ void actionRowTests() { ); if (scenario.payload['target'] == 'bob') { expect( - find.widgetWithText(MessageMentionPill, 'Bob'), + find.byKey(const ValueKey('system-message-author-bob')), findsOneWidget, ); - expect(find.text('@'), findsOneWidget); + expect(find.text('@'), findsNothing); } expect(body, findsOneWidget); final regularBody = tester.getRect(findRichText('Regular message')); @@ -88,23 +89,23 @@ void actionRowTests() { ); final actionBody = tester.getRect(body); final actionAuthor = tester.getRect(author); - expect(actionBody.left, regularBody.left); - expect(actionAuthor.left, regularAuthor.left); - expect( - actionBody.top - actionAuthor.bottom, - closeTo(regularBody.top - regularAuthor.bottom, 0.01), + final card = tester.getRect( + find.byKey(const ValueKey('activity-card-action')), ); + expect(card.center.dx, closeTo(tester.getRect(row).center.dx, 0.01)); + expect(actionAuthor.left, greaterThanOrEqualTo(actionBody.left)); + expect(regularAuthor.left, regularBody.left); + expect(actionAuthor.top, greaterThanOrEqualTo(actionBody.top)); final timestamp = find.byKey( const ValueKey('system-message-timestamp-alice'), ); - expect(tester.widget(timestamp).data, formatMessageTime(1010)); - expect(tester.getRect(timestamp).top, lessThan(actionBody.top)); + expect(timestamp, findsNothing); final reactions = find.descendant( of: row, matching: find.byType(ReactionRow), ); expect(reactions, findsOneWidget); - expect(tester.getTopLeft(reactions).dx, actionBody.left); + expect(tester.getCenter(reactions).dx, closeTo(card.center.dx, 0.01)); expect(tester.takeException(), isNull); }); @@ -118,11 +119,18 @@ void actionRowTests() { ), ); await tester.pumpAndSettle(); - expect(find.text('Someone ${scenario.action}'), findsOneWidget); - expect(find.byIcon(BuzzIcons.arrowLeftRight), findsOneWidget); + expect(findRichText('Someone ${scenario.action}'), findsOneWidget); + expect( + find.byIcon( + scenario.payload['type'] == 'member_removed' + ? BuzzIcons.userMinus + : BuzzIcons.pencil, + ), + findsNothing, + ); expect( find.byKey(const ValueKey('system-message-timestamp-fallback')), - findsOneWidget, + findsNothing, ); }); } @@ -146,7 +154,9 @@ void actionRowTests() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byType(CircleAvatar)); + await tester.tap( + find.byKey(const ValueKey('system-message-author-alice')), + ); await tester.pumpAndSettle(); expect( tester.widget(find.byType(UserProfileSheet)).pubkey, @@ -162,7 +172,7 @@ void actionRowTests() { ), isTrue, ); - await tester.tap(find.widgetWithText(MessageMentionPill, 'Bob')); + await tester.tap(find.byKey(const ValueKey('system-message-author-bob'))); await tester.pumpAndSettle(); expect( tester.widget(find.byType(UserProfileSheet)).pubkey, @@ -180,7 +190,7 @@ void actionRowTests() { }); for (final source in ['directory', 'profile', 'channel role']) { - testWidgets('removed agent uses chat mention pill from $source', ( + testWidgets('removed agent has a plain tappable name from $source', ( tester, ) async { await tester.pumpWidget( @@ -209,17 +219,9 @@ void actionRowTests() { ), ); await tester.pumpAndSettle(); - final pill = find.widgetWithText(MessageMentionPill, 'Helper Bot'); + final pill = find.byKey(const ValueKey('system-message-author-bob')); expect(pill, findsOneWidget); - expect(tester.widget(pill).isAgent, isTrue); - expect( - find.descendant(of: pill, matching: find.byIcon(BuzzIcons.bot)), - findsOneWidget, - ); - expect( - find.descendant(of: pill, matching: find.text('@')), - findsNothing, - ); + expect(find.byType(MessageMentionPill), findsNothing); expect( tester.widget(find.text('Helper Bot')).style?.decoration, isNot(TextDecoration.underline), @@ -290,10 +292,7 @@ void actionRowTests() { .data, names.labelFor(first), ); - final pill = find.widgetWithText( - MessageMentionPill, - names.labelFor(second), - ); + final pill = find.byKey(ValueKey('system-message-author-$second')); expect(pill, findsOneWidget); expect(names.labelFor(first), isNot(names.labelFor(second))); expect(find.text('Scout'), findsNothing); @@ -345,7 +344,7 @@ void actionRowTests() { ); expect( find.byKey(const ValueKey('system-message-timestamp-alice')), - findsOneWidget, + findsNothing, ); expect(tester.takeException(), isNull); }); diff --git a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart new file mode 100644 index 00000000000..627ebe98396 --- /dev/null +++ b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart @@ -0,0 +1,733 @@ +part of '../channel_detail_page_test.dart'; + +void bubbleLayoutTests() { + testWidgets('large activity members build and fetch only visible rows', ( + tester, + ) async { + final loaded = {}; + final cache = _FakeUserCacheNotifier( + {}, + preload: (pubkeys) async { + loaded.addAll(pubkeys); + return true; + }, + ); + await tester.pumpWidget( + _buildTestable( + messages: [ + for (var index = 0; index < 1000; index++) + _systemMsg( + id: 'bulk-${index.toString().padLeft(4, '0')}', + payload: { + 'type': 'member_removed', + 'actor': 'alice', + 'target': 'target-$index', + }, + ), + ], + userCacheNotifier: cache, + ), + ); + await tester.pumpAndSettle(); + loaded.clear(); + await tester.tap(find.text('See all')); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('activity-member-target-999')), + findsNothing, + ); + expect(loaded.length, allOf(greaterThan(0), lessThan(20))); + final list = find.byKey(const ValueKey('activity-members-list')); + final scrollable = find.descendant( + of: list, + matching: find.byType(Scrollable), + ); + await tester.scrollUntilVisible( + find.byKey(const ValueKey('activity-member-target-50')), + 300, + scrollable: scrollable, + ); + await tester.pumpAndSettle(); + expect(loaded, contains('target-50')); + expect(loaded.length, lessThan(100)); + final beforeUpdate = {...loaded}; + cache.replace( + const UserProfile(pubkey: 'target-50', displayName: 'Updated target'), + ); + await tester.pumpAndSettle(); + expect(find.text('Updated target'), findsOneWidget); + expect(loaded, beforeUpdate); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'status bar pagination is capped and later taps continue', + (tester) async { + final notifier = _FakeMessagesNotifier( + [ + for (var index = 0; index < 30; index++) + _textMsg( + id: 'recent-$index', + pubkey: 'alice', + content: 'Recent $index', + createdAt: 3000 + index, + ), + ], + olderPages: [ + for (var index = 1; index <= 10; index++) + [ + _textMsg( + id: 'page-$index', + pubkey: 'alice', + content: 'Older page $index', + createdAt: 3000 - index * 100, + ), + ], + ], + ); + await tester.pumpWidget( + _buildTestable(messages: const [], messagesNotifier: notifier), + ); + await tester.pumpAndSettle(); + expect(notifier.fetchOlderCalls, 0); + for (final expected in [3, 6, 9, 10]) { + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/status_bar', + const JSONMethodCodec().encodeMethodCall( + const MethodCall('handleScrollToTop'), + ), + (_) {}, + ); + await tester.pumpAndSettle(); + expect(notifier.fetchOlderCalls, expected); + expect(find.text('Older page $expected').hitTestable(), findsOneWidget); + await tester.pumpAndSettle(); + expect( + notifier.fetchOlderCalls, + expected, + reason: 'landing must not bypass the gesture cap', + ); + } + expect(notifier.reachedOldest, isTrue); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'grouped removal quick reactions remove reactions on later events', + (tester) async { + final actions = _GroupedReactionActions(); + await tester.pumpWidget( + _buildTestable( + signingPubkey: 'self', + messages: [ + for (var index = 0; index < 3; index++) + _systemMsg( + id: 'removal-$index', + payload: { + 'type': 'member_removed', + 'actor': 'alice', + 'target': 'target-$index', + }, + createdAt: 1000 + index, + ), + for (var index = 1; index < 3; index++) + _reaction( + id: 'own-$index', + targetId: 'removal-$index', + pubkey: 'self', + ), + ], + createChannelActions: (_) => actions, + ), + ); + await tester.pumpAndSettle(); + await tester.longPress( + find.byKey(const ValueKey('system-message-row-removal-0')), + ); + await tester.pumpAndSettle(); + final quick = find.byKey(const ValueKey('quick-reaction-👍')); + expect(quick, findsOneWidget); + expect( + find.descendant( + of: quick, + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.selected == true, + ), + ), + findsOneWidget, + ); + await tester.tap(quick); + await tester.pumpAndSettle(); + expect(actions.removed, ['own-1', 'own-2']); + expect(actions.added, isEmpty); + }, + ); + + testWidgets( + 'status bar navigation fetches short history and stops on failure', + (tester) async { + for (final fail in [false, true]) { + final notifier = _FakeMessagesNotifier( + [ + for (var index = 0; index < 30; index++) + _textMsg( + id: 'recent-$index', + pubkey: 'alice', + content: 'Recent $index', + createdAt: 3000 + index, + ), + ], + olderPages: [ + [ + _textMsg( + id: 'middle', + pubkey: 'alice', + content: 'Middle page', + createdAt: 2000, + ), + ], + [ + _textMsg( + id: 'oldest', + pubkey: 'alice', + content: 'First page', + createdAt: 1000, + ), + ], + ], + failOlderFetch: fail, + ); + await tester.pumpWidget( + _buildTestable(messages: const [], messagesNotifier: notifier), + ); + await tester.pumpAndSettle(); + expect(notifier.fetchOlderCalls, 0); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/status_bar', + const JSONMethodCodec().encodeMethodCall( + const MethodCall('handleScrollToTop'), + ), + (_) {}, + ); + await tester.pumpAndSettle(); + expect(notifier.fetchOlderCalls, fail ? 1 : 2); + if (!fail) { + expect(find.text('First page').hitTestable(), findsOneWidget); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + } + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + for (final thread in [false, true]) { + for (final tagged in [false, true]) { + testWidgets( + 'attachments isolate both group boundaries, thread=$thread tagged=$tagged', + (tester) async { + final events = [ + if (thread) + _textMsg( + id: 'root', + pubkey: 'bob', + content: 'Root', + createdAt: 900, + ), + for (var index = 0; index < 3; index++) + _textMsg( + id: 'boundary-$index', + pubkey: 'alice', + createdAt: 1000 + index, + content: index == 1 + ? '![photo](https://example.com/photo.png)' + : 'Text $index', + extraTags: [ + if (thread) ['e', 'root', '', 'reply'], + if (tagged && index == 1) + [ + 'imeta', + 'url https://example.com/photo.png', + 'm image/png', + ], + ], + ), + ]; + final messages = formatTimeline(events); + await tester.pumpWidget( + _buildTestable( + messages: events, + home: thread + ? ThreadDetailPage( + threadHead: messages.firstWhere( + (message) => message.id == 'root', + ), + allMessages: messages, + channelId: _channelId, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ) + : null, + ), + ); + await tester.pumpAndSettle(); + for (var index = 0; index < 3; index++) { + if (thread) { + final list = tester.widget( + find.byKey(const ValueKey('thread-message-list')), + ); + list.itemScrollController!.jumpTo(index: index + 1); + await tester.pumpAndSettle(); + } + final row = find.byKey( + ValueKey( + '${thread ? 'thread-message-row' : 'message-row'}-boundary-$index', + ), + ); + final content = tester.widget( + find.descendant(of: row, matching: find.byType(MessageContent)), + ); + expect(content.bubbleStart, isTrue, reason: 'start $index'); + expect(content.bubbleEnd, isTrue, reason: 'end $index'); + expect( + find.descendant(of: row, matching: find.byType(AvatarImage)), + findsOneWidget, + ); + } + }, + ); + } + } + + testWidgets( + 'legacy purpose activity is hidden without splitting the sender group', + (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'before-purpose', + pubkey: 'alice', + content: 'Before', + createdAt: 1000, + ), + _systemMsg( + id: 'purpose-change', + payload: { + 'type': 'purpose_changed', + 'actor': 'alice', + 'purpose': 'Legacy purpose', + }, + createdAt: 1010, + ), + _reaction(id: 'purpose-reaction', targetId: 'purpose-change'), + _textMsg( + id: 'after-purpose', + pubkey: 'alice', + content: 'After', + createdAt: 1020, + ), + _systemMsg( + id: 'topic-change', + payload: { + 'type': 'topic_changed', + 'actor': 'alice', + 'topic': 'Current topic', + }, + createdAt: 1030, + ), + ], + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('system-message-row-purpose-change')), + findsNothing, + ); + expect(findRichText('Legacy purpose'), findsNothing); + expect(find.byType(ReactionRow), findsNothing); + expect( + find.byKey(const ValueKey('message-author-before-purpose')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-author-after-purpose')), + findsNothing, + ); + expect( + findRichText('changed the topic to "Current topic"'), + findsOneWidget, + ); + }, + ); + + testWidgets('single-line activity uses text without an icon', (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _systemMsg( + id: 'single-line', + payload: {'type': 'member_left', 'actor': 'alice'}, + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + final card = find.byKey(const ValueKey('activity-card-single-line')); + final icon = find.descendant(of: card, matching: find.byType(Icon)); + expect(icon, findsNothing); + expect(findRichText('left the channel'), findsOneWidget); + }); + + testWidgets( + 'keeps centered activities timestamp-free at accessibility text sizes', + (tester) async { + tester.view.physicalSize = const Size(240, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _systemMsg( + id: 'sys-accessible', + payload: {'type': 'member_left', 'actor': 'alice'}, + createdAt: + DateTime(2026, 7, 28, 12, 34).millisecondsSinceEpoch ~/ 1000, + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + textScaler: const TextScaler.linear(3), + ), + ); + await tester.pumpAndSettle(); + + final timestampFinder = find.byKey( + const ValueKey('system-message-timestamp-alice'), + ); + expect(timestampFinder, findsNothing); + final card = find.byKey(const ValueKey('activity-card-sys-accessible')); + final icon = find.descendant(of: card, matching: find.byType(Icon)); + expect(icon, findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + for (final thread in [false, true]) { + testWidgets( + 'status bar tap reaches oldest content, thread=$thread', + (tester) async { + final events = [ + _textMsg( + id: 'top-root', + pubkey: 'bob', + content: 'Oldest content', + createdAt: 900, + ), + for (var index = 0; index < 60; index++) + _textMsg( + id: 'top-$index', + pubkey: 'alice', + content: 'Message $index', + createdAt: 1000 + index, + extraTags: thread + ? const [ + ['e', 'top-root', '', 'reply'], + ] + : const [], + ), + ]; + final messages = formatTimeline(events); + await tester.pumpWidget( + _buildTestable( + messages: events, + home: thread + ? ThreadDetailPage( + threadHead: messages.firstWhere((m) => m.id == 'top-root'), + allMessages: messages, + channelId: _channelId, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ) + : null, + ), + ); + await tester.pumpAndSettle(); + final list = tester.widget( + find.byKey( + ValueKey(thread ? 'thread-message-list' : 'channel-message-list'), + ), + ); + list.itemScrollController!.jumpTo(index: thread ? 55 : 0); + await tester.pumpAndSettle(); + expect(find.text('Oldest content').hitTestable(), findsNothing); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/status_bar', + const JSONMethodCodec().encodeMethodCall( + const MethodCall('handleScrollToTop'), + ), + (_) {}, + ); + await tester.pumpAndSettle(); + expect(find.text('Oldest content').hitTestable(), findsOneWidget); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + } + + testWidgets('newest short message keeps the same group gap', (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + for (var index = 0; index < 3; index++) + _textMsg( + id: 'gap-$index', + pubkey: 'alice', + content: 'Short', + createdAt: 1000 + index, + ), + ], + ), + ); + await tester.pumpAndSettle(); + Rect bubble(int index) => tester.getRect( + find.descendant( + of: find.byKey(ValueKey('message-row-gap-$index')), + matching: find.byKey(const ValueKey('message-text-bubble-0')), + ), + ); + final firstGap = bubble(1).top - bubble(0).bottom; + final newestGap = bubble(2).top - bubble(1).bottom; + expect(newestGap, closeTo(firstGap, 0.01)); + }); + + testWidgets('newest short thread reply keeps the same group gap', ( + tester, + ) async { + final events = [ + _textMsg(id: 'root', pubkey: 'bob', content: 'Root', createdAt: 900), + for (var index = 0; index < 3; index++) + _textMsg( + id: 'gap-$index', + pubkey: 'alice', + content: 'Short', + createdAt: 1000 + index, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final messages = formatTimeline(events); + await tester.pumpWidget( + _buildTestable( + messages: events, + home: ThreadDetailPage( + threadHead: messages.firstWhere((m) => m.id == 'root'), + allMessages: messages, + channelId: _channelId, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + Rect bubble(int index) => tester.getRect( + find.descendant( + of: find.byKey(ValueKey('thread-message-row-gap-$index')), + matching: find.byKey(const ValueKey('message-text-bubble-0')), + ), + ); + expect( + bubble(2).top - bubble(1).bottom, + closeTo(bubble(1).top - bubble(0).bottom, 0.01), + ); + }); + + testWidgets('channel groups keep one avatar on the last message', ( + tester, + ) async { + final notifier = _FakeMessagesNotifier([ + _textMsg(id: 'first', pubkey: 'alice', content: 'First'), + _textMsg(id: 'last', pubkey: 'alice', content: 'Last', createdAt: 1010), + ]); + await tester.pumpWidget( + _buildTestable(messages: const [], messagesNotifier: notifier), + ); + await tester.pumpAndSettle(); + final first = find.byKey(const ValueKey('message-row-first')); + final last = find.byKey(const ValueKey('message-row-last')); + expect( + find.descendant(of: first, matching: find.byType(AvatarImage)), + findsNothing, + ); + expect( + find.descendant(of: last, matching: find.byType(AvatarImage)), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('message-author-first')), findsOneWidget); + expect(find.byKey(const ValueKey('message-author-last')), findsNothing); + expect( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'message-text-bubble-', + ), + ), + findsNWidgets(2), + ); + }); + + testWidgets('live appends move the group avatar and author changes end it', ( + tester, + ) async { + final first = _textMsg(id: 'first', pubkey: 'alice', content: 'First'); + final next = _textMsg( + id: 'next', + pubkey: 'alice', + content: 'Next', + createdAt: 1010, + ); + final other = _textMsg( + id: 'other', + pubkey: 'bob', + content: 'Other', + createdAt: 1020, + ); + final notifier = _FakeMessagesNotifier([first]); + await tester.pumpWidget( + _buildTestable(messages: const [], messagesNotifier: notifier), + ); + await tester.pumpAndSettle(); + Finder avatar(String id) => find.descendant( + of: find.byKey(ValueKey('message-row-$id')), + matching: find.byType(AvatarImage), + ); + expect(avatar('first'), findsOneWidget); + notifier.setMessages([first, next]); + await tester.pumpAndSettle(); + expect(avatar('first'), findsNothing); + expect(avatar('next'), findsOneWidget); + notifier.setMessages([first, next, other]); + await tester.pumpAndSettle(); + expect(avatar('next'), findsOneWidget); + expect(avatar('other'), findsOneWidget); + notifier.setMessages([first]); + await tester.pumpAndSettle(); + expect(avatar('first'), findsOneWidget); + }); + + for (final gap in [301, 86400]) { + testWidgets('author groups end across a $gap second gap', (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg(id: 'first', pubkey: 'alice', content: 'First'), + _textMsg( + id: 'next', + pubkey: 'alice', + content: 'Next', + createdAt: 1000 + gap, + ), + ], + ), + ); + await tester.pumpAndSettle(); + for (final id in ['first', 'next']) { + final row = find.byKey(ValueKey('message-row-$id')); + expect( + find.descendant(of: row, matching: find.byType(AvatarImage)), + findsOneWidget, + ); + expect(find.byKey(ValueKey('message-author-$id')), findsOneWidget); + } + }); + } + + testWidgets( + 'thread replies use the same bubble and group-end avatar layout', + (tester) async { + final events = [ + _textMsg(id: 'root', pubkey: 'bob', content: 'Root'), + _textMsg( + id: 'first', + pubkey: 'alice', + content: 'First reply', + createdAt: 1010, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + _textMsg( + id: 'last', + pubkey: 'alice', + content: 'Last reply', + createdAt: 1020, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final messages = formatTimeline(events); + await tester.pumpWidget( + _buildTestable( + messages: events, + home: ThreadDetailPage( + threadHead: messages.firstWhere((m) => m.id == 'root'), + allMessages: messages, + channelId: _channelId, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + final first = find.byKey(const ValueKey('thread-message-row-first')); + final last = find.byKey(const ValueKey('thread-message-row-last')); + expect( + find.descendant(of: first, matching: find.byType(AvatarImage)), + findsNothing, + ); + expect( + find.descendant(of: last, matching: find.byType(AvatarImage)), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'message-text-bubble-', + ), + ), + findsNWidgets(3), + ); + }, + ); +} + +class _GroupedReactionActions implements ChannelActions { + final removed = []; + final added = []; + @override + Future removeReaction(String id, String emoji) async => removed.add(id); + @override + Future addReaction(String id, String emoji) async => added.add(id); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 38c896be481..9abb9caddb5 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:buzz/features/channels/composer_send_origin.dart'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; @@ -3162,6 +3163,79 @@ void main() { ); }); + testWidgets('ComposeBar retains submitted origin through delayed upload', ( + tester, + ) async { + final uploadResponse = Completer(); + Rect? submittedOrigin; + var sent = false; + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient((_) => uploadResponse.future), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + pickGalleryImages: () async => [ + XFile.fromData(_pngBytes, name: 'tiny.png'), + ], + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + sent = true; + submittedOrigin = ComposerSendOrigin.current; + }, + ), + ); + await _openSystemPhotoPicker(tester); + await tester.pumpAndSettle(); + await _expandComposer(tester); + final composer = tester.getRect( + find.byKey(const ValueKey('composer-surface')), + ); + await tester.tap( + find + .ancestor( + of: find.byIcon(BuzzIcons.arrowUp), + matching: find.byType(IconButton), + ) + .hitTestable(), + ); + await tester.pump(const Duration(seconds: 5)); + expect(sent, isFalse); + // Layout changes during upload must not replace the submitted geometry. + tester.view.physicalSize = Size( + tester.view.physicalSize.width, + tester.view.physicalSize.height + 200, + ); + addTearDown(tester.view.resetPhysicalSize); + await tester.pump(); + uploadResponse.complete( + http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.png', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ), + ); + await tester.pumpAndSettle(); + expect(sent, isTrue); + expect(submittedOrigin, isNotNull); + expect(submittedOrigin, composer); + expect(ComposerSendOrigin.current, isNull); + }); + testWidgets( 'shows post-send upload progress above the composer and cancels', (tester) async { diff --git a/mobile/test/features/channels/composer_send_origin_test.dart b/mobile/test/features/channels/composer_send_origin_test.dart new file mode 100644 index 00000000000..c99f553f151 --- /dev/null +++ b/mobile/test/features/channels/composer_send_origin_test.dart @@ -0,0 +1,73 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/composer_send_origin.dart'; +import 'package:buzz/features/channels/local_message_send_animation_provider.dart'; +import 'package:buzz/features/channels/local_message_send_transition.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + testWidgets('slow sends bind submitted geometry to their own local claim', ( + tester, + ) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read( + localMessageSendAnimationProvider('channel').notifier, + ); + final key = GlobalKey(); + expect(ComposerSendOrigin.capture(key), isNull); + Future layout(double height) => tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.bottomCenter, + child: SizedBox(key: key, width: 320, height: height), + ), + ), + ); + await layout(120); + final submitted = ComposerSendOrigin.capture(key); + expect(submitted, tester.getRect(find.byKey(key))); + final waitForUpload = Completer(); + final upload = ComposerSendOrigin.run(submitted, () async { + await waitForUpload.future; + notifier.mark('upload'); + }); + await layout(44); + final secondOrigin = ComposerSendOrigin.capture(key); + expect(secondOrigin, tester.getRect(find.byKey(key))); + expect(secondOrigin, isNot(submitted)); + final invite = ComposerSendOrigin.run(secondOrigin, () async { + await Future.delayed(const Duration(seconds: 3)); + notifier.mark('invite'); + }); + await tester.pump(const Duration(seconds: 3)); + await invite; + waitForUpload.complete(); + await upload; + expect(notifier.originFor('upload'), submitted); + expect(notifier.originFor('invite'), secondOrigin); + expect(ComposerSendOrigin.current, isNull); + notifier.mark('unrelated'); + expect(notifier.originFor('unrelated'), isNull); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: LocalMessageSendTransition( + animate: true, + channelId: 'channel', + messageId: 'upload', + child: SizedBox(width: 100, height: 80), + ), + ), + ), + ); + expect( + tester.widget(find.byType(LocalSendProgress)).origin, + submitted, + ); + await tester.pumpAndSettle(); + }); +} diff --git a/mobile/test/features/channels/local_message_send_transition_test.dart b/mobile/test/features/channels/local_message_send_transition_test.dart index 574c12fc518..dfde7864f67 100644 --- a/mobile/test/features/channels/local_message_send_transition_test.dart +++ b/mobile/test/features/channels/local_message_send_transition_test.dart @@ -1,107 +1,144 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/features/channels/local_message_send_animation_provider.dart'; import 'package:buzz/features/channels/local_message_send_transition.dart'; +import 'package:buzz/features/channels/local_send_row.dart'; -Widget _testable({ - bool animate = true, - bool reduceMotion = false, - double startOffsetFactor = localMessageSendTransitionStartOffset, -}) => MaterialApp( - home: MediaQuery( - data: MediaQueryData(disableAnimations: reduceMotion), - child: Scaffold( - body: LocalMessageSendTransition( - animate: animate, - startOffsetFactor: startOffsetFactor, - child: const SizedBox(key: ValueKey('message'), width: 100, height: 40), +Widget _testable({bool animate = true, bool reduceMotion = false}) => + ProviderScope( + child: MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduceMotion), + child: Scaffold( + body: Align( + alignment: Alignment.bottomLeft, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + key: ValueKey('history'), + width: 100, + height: 40, + ), + LocalMessageSendTransition( + animate: animate, + child: const SizedBox( + width: 100, + height: 80, + child: Align( + alignment: Alignment.bottomLeft, + child: SizedBox( + key: ValueKey('avatar'), + width: 40, + height: 40, + ), + ), + ), + ), + ], + ), + ), + ), + ), ), - ), - ), -); + ); void main() { - Finder sendTranslation() => find.descendant( - of: find.byType(LocalMessageSendTransition), - matching: find.byType(FractionalTranslation), - ); - - test('local send claims expire before a later navigation', () { - final markedAt = DateTime(2026); - final animations = {'event': markedAt}; - + test('local send claims expire before later navigation', () { + final now = DateTime(2026); expect( isRecentLocalMessageSendAnimation( - animations, - 'event', - now: markedAt.add(const Duration(milliseconds: 999)), + {'id': now}, + 'id', + now: now.add(const Duration(milliseconds: 999)), ), isTrue, ); expect( isRecentLocalMessageSendAnimation( - animations, - 'event', - now: markedAt.add(const Duration(seconds: 2)), + {'id': now}, + 'id', + now: now.add(const Duration(seconds: 2)), ), isFalse, ); }); - - testWidgets('local send rises from behind the composer', (tester) async { + testWidgets('history moves up while the avatar remains anchored', ( + tester, + ) async { await tester.pumpWidget(_testable()); - - var transition = tester.widget(sendTranslation()); + final avatar = tester.getRect(find.byKey(const ValueKey('avatar'))); + final history = tester.getRect(find.byKey(const ValueKey('history'))); + expect(tester.getSize(find.byType(LocalSendRow)).height, closeTo(0, 0.01)); + await tester.pump(localMessageSendTransitionDuration ~/ 2); expect( - transition.translation, - const Offset(0, localMessageSendTransitionStartOffset), + tester.getSize(find.byType(LocalSendRow)).height, + inExclusiveRange(15, 35), ); + expect(tester.getRect(find.byKey(const ValueKey('avatar'))), avatar); expect( - find.descendant( - of: find.byType(LocalMessageSendTransition), - matching: find.byType(FadeTransition), + tester.getTopLeft(find.byKey(const ValueKey('history'))).dy, + closeTo( + history.top - tester.getSize(find.byType(LocalSendRow)).height, + 0.1, ), - findsNothing, ); - - await tester.pump(localMessageSendTransitionDuration ~/ 2); - transition = tester.widget(sendTranslation()); - expect(transition.translation.dy, greaterThan(0)); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(LocalSendRow)).height, 80); + expect(tester.getRect(find.byKey(const ValueKey('avatar'))), avatar); expect( - transition.translation.dy, - lessThan(localMessageSendTransitionStartOffset), + tester.getTopLeft(find.byKey(const ValueKey('history'))).dy, + closeTo(history.top - 80, 0.01), ); - - await tester.pump(localMessageSendTransitionDuration); - transition = tester.widget(sendTranslation()); - expect(transition.translation, Offset.zero); }); - - testWidgets('an avatar row starts deeper behind the composer', ( - tester, - ) async { - await tester.pumpWidget( - _testable(startOffsetFactor: localMessageSendTransitionAvatarStartOffset), - ); - - final transition = tester.widget(sendTranslation()); - expect( - transition.translation, - const Offset(0, localMessageSendTransitionAvatarStartOffset), - ); - }); - - testWidgets('ordinary messages do not animate', (tester) async { - await tester.pumpWidget(_testable(animate: false)); - - final transition = tester.widget(sendTranslation()); - expect(transition.translation, Offset.zero); - }); - - testWidgets('reduced motion skips the send translation', (tester) async { - await tester.pumpWidget(_testable(reduceMotion: true)); - - final transition = tester.widget(sendTranslation()); - expect(transition.translation, Offset.zero); + testWidgets('spring contraction leads the overlapping lift', (tester) async { + await tester.pumpWidget(_testable()); + LocalSendProgress motion() => tester.widget(find.byType(LocalSendProgress)); + await tester.pump(localMessageSendTransitionDuration ~/ 4); + expect(motion().widthProgress, greaterThan(motion().progress)); + expect(motion().progress, greaterThan(0)); + await tester.pump(localMessageSendTransitionDuration * 0.375); + expect(motion().widthProgress, greaterThan(1)); + expect(motion().progress, inExclusiveRange(0, 1)); + await tester.pump(localMessageSendTransitionDuration * 0.375); + expect(motion().progress, 1); }); + testWidgets( + 'spring gently overshoots width and settles with the avatar fixed', + (tester) async { + await tester.pumpWidget(_testable()); + LocalSendProgress motion() => + tester.widget(find.byType(LocalSendProgress)); + final avatar = tester.getRect(find.byKey(const ValueKey('avatar'))); + expect(motion().widthProgress, 0); + await tester.pump(localMessageSendTransitionDuration * 0.65); + final peak = motion().widthProgress!; + expect(peak, inExclusiveRange(1, 1.02)); + expect(tester.getRect(find.byKey(const ValueKey('avatar'))), avatar); + await tester.pump(localMessageSendTransitionDuration * 0.2); + expect(motion().widthProgress, inExclusiveRange(1, peak)); + await tester.pump(localMessageSendTransitionDuration * 0.15); + expect(motion().widthProgress, 1); + expect(tester.getRect(find.byKey(const ValueKey('avatar'))), avatar); + }, + ); + for (final reduced in [false, true]) { + testWidgets('settled or reduced motion does not expand, reduced=$reduced', ( + tester, + ) async { + await tester.pumpWidget( + _testable(animate: reduced, reduceMotion: reduced), + ); + expect(tester.getSize(find.byType(LocalSendRow)).height, 80); + expect( + tester + .widget(find.byType(LocalSendProgress)) + .widthProgress, + 1, + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.getSize(find.byType(LocalSendRow)).height, 80); + }); + } } diff --git a/mobile/test/features/channels/local_send_bubble_surface_test.dart b/mobile/test/features/channels/local_send_bubble_surface_test.dart new file mode 100644 index 00000000000..723334df8e9 --- /dev/null +++ b/mobile/test/features/channels/local_send_bubble_surface_test.dart @@ -0,0 +1,230 @@ +import 'dart:ui' as ui; +import 'package:buzz/features/channels/local_message_send_transition.dart'; +import 'package:buzz/features/channels/local_send_bubble_surface.dart'; +import 'package:buzz/features/channels/local_send_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + for (final withOrigin in [false, true]) { + for (final progress in [0.25, 0.5, 1.0]) { + testWidgets( + 'flight transforms, hit bounds and semantics agree at $progress origin=$withOrigin', + (tester) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: 300, + height: 300, + child: Align( + alignment: Alignment.topLeft, + child: LocalSendProgress( + progress: progress, + startOffset: 100, + origin: withOrigin + ? const Rect.fromLTWH(300, 250, 100, 100) + : null, + child: LocalSendRow( + progress: progress, + child: LocalSendBubbleSurface( + color: Colors.black, + shape: const RoundedRectangleBorder(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, + child: Semantics( + label: 'Flying link', + child: const SizedBox(width: 100, height: 100), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + final surface = tester.renderObject( + find.byType(LocalSendBubbleSurface), + ); + final row = tester.renderObject(find.byType(LocalSendRow)); + final target = tester.renderObject( + find.byType(GestureDetector), + ); + final rowOffset = Offset(0, row.size.height - 100); + final start = withOrigin + ? const Offset(300, 250) - surface.localToGlobal(Offset.zero) + : const Offset(0, 100); + final bubbleOffset = start * (1 - progress); + final visibleCenter = rowOffset + bubbleOffset + const Offset(50, 50); + expect( + target.localToGlobal(Offset.zero), + row.localToGlobal(rowOffset + bubbleOffset), + ); + final rowHits = BoxHitTestResult(); + expect(row.hitTest(rowHits, position: visibleCenter), isTrue); + expect( + rowHits.path.any((entry) => identical(entry.target, target)), + isTrue, + ); + final bubbleHits = BoxHitTestResult(); + expect( + surface.hitTest( + bubbleHits, + position: bubbleOffset + const Offset(50, 50), + ), + isTrue, + ); + expect( + surface.hitTest( + BoxHitTestResult(), + position: const Offset(-10, -10), + ), + isFalse, + ); + final node = tester.getSemantics( + find.bySemanticsLabel('Flying link'), + ); + expect(node.rect.size, const Size(100, 100)); + expect(tester.takeException(), isNull); + semantics.dispose(); + }, + ); + } + } + + for (final text in [ + 'Hello', + 'A longer message\nwith several lines\nof text', + ]) { + testWidgets( + 'send background contracts without changing text layout: $text', + (tester) async { + final key = GlobalKey(); + Rect? origin; + Future draw(double progress, {double? widthProgress}) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: RepaintBoundary( + key: key, + child: SizedBox( + width: 300, + height: 120, + child: ColoredBox( + color: Colors.white, + child: Align( + alignment: Alignment.topLeft, + child: LocalSendProgress( + progress: progress, + widthProgress: widthProgress, + startOffset: 0, + origin: origin, + child: LocalSendBubbleSurface( + color: const Color(0xFFFF0000), + shape: const RoundedRectangleBorder(), + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + text, + style: const TextStyle(fontSize: 12), + textWidthBasis: TextWidthBasis.longestLine, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Future greenAt(int x) async { + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + return (await tester.runAsync(() async { + final image = await boundary.toImage(); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final green = bytes.getUint8((4 * 300 + x) * 4 + 1); + image.dispose(); + return green; + }))!; + } + + await draw(0); + final textRect = tester.getRect(find.text(text)); + final finalWidth = tester + .getSize(find.byType(LocalSendBubbleSurface)) + .width; + expect(finalWidth, lessThan(280)); + expect(await greenAt(280), 0); + await draw(0.5); + expect(tester.getRect(find.text(text)), textRect); + final middle = ((300 + finalWidth) / 2).floor(); + expect(await greenAt(middle - 2), 0); + expect(await greenAt(middle + 2), 255); + await draw(1); + expect(tester.getRect(find.text(text)), textRect); + expect(await greenAt(finalWidth.ceil() + 2), 255); + await draw(1, widthProgress: 1.012); + expect( + tester.getSize(find.byType(LocalSendBubbleSurface)).width, + finalWidth, + ); + // The fractional spring edge can partially cover this pixel. + expect(await greenAt(finalWidth.floor() - 1), greaterThan(128)); + expect( + await greenAt((finalWidth - (300 - finalWidth) * 0.012).floor() - 2), + 0, + ); + await draw(1); + expect(await greenAt(finalWidth.floor() - 1), 0); + final boundary = key.currentContext!.findRenderObject()! as RenderBox; + origin = Rect.fromLTWH( + 20, + 50, + 250, + 30, + ).shift(boundary.localToGlobal(Offset.zero)); + await draw(0); + final image = await tester.runAsync(() async { + final result = await (boundary as RenderRepaintBoundary).toImage(); + final bytes = (await result.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final inside = bytes.getUint8((55 * 300 + 25) * 4 + 1); + final outside = bytes.getUint8((55 * 300 + 275) * 4 + 1); + result.dispose(); + return (inside, outside); + }); + expect(image, (0, 255)); + // Width can settle while the bubble is still partway up its flight. + await draw(0.5, widthProgress: 1); + final staged = await tester.runAsync(() async { + final result = await (boundary as RenderRepaintBoundary).toImage(); + final bytes = (await result.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final inside = bytes.getUint8((28 * 300 + 12) * 4 + 1); + final outside = bytes.getUint8( + (28 * 300 + finalWidth.ceil() + 14) * 4 + 1, + ); + result.dispose(); + return (inside, outside); + }); + expect(staged, (0, 255)); + await draw(1); + expect(tester.takeException(), isNull); + }, + ); + } +} diff --git a/mobile/test/features/channels/media_viewer_page/video_viewer_test.dart b/mobile/test/features/channels/media_viewer_page/video_viewer_test.dart index b6f2a5e30fb..d4c47eaac23 100644 --- a/mobile/test/features/channels/media_viewer_page/video_viewer_test.dart +++ b/mobile/test/features/channels/media_viewer_page/video_viewer_test.dart @@ -77,6 +77,8 @@ class _FakeVideoPlayerPlatform extends VideoPlayerPlatform { // _creatingCompleter is never completed and dispose() deadlocks waiting // on it. This flag exercises the F2r(d) production fix. final bool forceCreateError; + final created = Completer(); + final disposed = Completer(); int disposeCallCount = 0; int nextPlayerId = 0; final Map> _streams = {}; @@ -106,6 +108,7 @@ class _FakeVideoPlayerPlatform extends VideoPlayerPlatform { final id = nextPlayerId++; final controller = StreamController( onListen: () { + if (!created.isCompleted) created.complete(); // Emit the event/error only when the stream is first subscribed so that // VideoPlayerController.initialize() is already listening. Emitting // before the subscription means the event is dropped and initialize() @@ -136,6 +139,7 @@ class _FakeVideoPlayerPlatform extends VideoPlayerPlatform { @override Future dispose(int playerId) async { disposeCallCount++; + if (!disposed.isCompleted) disposed.complete(); // Record the dispose call and close the event stream. // // Note: an error injected here does NOT reach initialize()'s pending @@ -604,8 +608,7 @@ void main() { testWidgets( 'F2r(c): controller created but never initialized is disposed on unmount', (tester) async { - final fakePlayer = _FakeVideoPlayerPlatform(neverInitialize: true); - VideoPlayerPlatform.instance = fakePlayer; + late _FakeVideoPlayerPlatform fakePlayer; final fakeClient = _FinalizingFakeClient( responseBuilder: () => @@ -614,6 +617,9 @@ void main() { addTearDown(fakeClient.close); await tester.runAsync(() async { + // Create completion signals in the real async zone used by file I/O. + fakePlayer = _FakeVideoPlayerPlatform(neverInitialize: true); + VideoPlayerPlatform.instance = fakePlayer; await tester.pumpWidget( WidgetHelpers.testable( disableAnimations: true, @@ -626,10 +632,9 @@ void main() { ), ), ); - // Allow enough time for the download to complete and for - // VideoPlayerController.file() to be constructed, but NOT long - // enough for initialize() to complete (it never will). - await Future.delayed(const Duration(milliseconds: 300)); + // Wait for the pending controller to exist, regardless of download + // scheduling under concurrent suite load. Initialization never completes. + await fakePlayer.created.future.timeout(const Duration(seconds: 10)); // Now unmount — this triggers the effect cleanup with the pending // controller still in pendingController.value (never reached play()). @@ -645,7 +650,7 @@ void main() { child: const SizedBox.shrink(), ), ); - await Future.delayed(const Duration(milliseconds: 100)); + await fakePlayer.disposed.future.timeout(const Duration(seconds: 10)); }); await tester.pump(); diff --git a/mobile/test/features/channels/message_content_bubble_cases.dart b/mobile/test/features/channels/message_content_bubble_cases.dart new file mode 100644 index 00000000000..5baf1154bb4 --- /dev/null +++ b/mobile/test/features/channels/message_content_bubble_cases.dart @@ -0,0 +1,273 @@ +part of 'message_content_test.dart'; + +void bubbleContentTests() { + final bubbles = find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'message-text-bubble-', + ), + ); + testWidgets('message shapes remain inside their bounds without tails', ( + tester, + ) async { + for (final tail in [false, true]) { + await tester.pumpWidget( + _testable( + MessageContent(content: 'Hello', bubble: true, bubbleEnd: tail), + ), + ); + await tester.pumpAndSettle(); + final decoration = tester.widget( + find.descendant( + of: bubbles, + matching: find.byType(LocalSendBubbleSurface), + ), + ); + final bounds = decoration.shape + .getOuterPath(Offset.zero & tester.getSize(bubbles)) + .getBounds(); + expect(bounds.left, 0); + expect(bounds.bottom, tester.getSize(bubbles).height); + } + }); + + for (final dark in [false, true]) { + testWidgets('outgoing headings, mentions and links contrast in dark=$dark', ( + tester, + ) async { + String? tapped; + await tester.pumpWidget( + _testable( + MessageContent( + content: + '# H1\n\n## H2\n\n### H3\n\n#### H4\n\n##### H5\n\n###### H6\n\n@Alice @Helper [Website](https://example.com)', + bubble: true, + ownMessage: true, + mentionNames: const {'person': 'Alice', 'agent': 'Helper'}, + agentMentionPubkeys: const {'agent'}, + onMentionTap: (pubkey) => tapped = pubkey, + ), + dark: dark, + ), + ); + await tester.pumpAndSettle(); + final foreground = dark ? Colors.black : Colors.white; + for (final heading in ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']) { + final text = tester.widget(_findRich(heading).last); + final colors = []; + void inspect(InlineSpan span, TextStyle? inherited) { + final effective = inherited?.merge(span.style) ?? span.style; + if (span is TextSpan) { + if (span.text?.contains(heading) == true) { + colors.add(effective?.color); + } + for (final child in span.children ?? const []) { + inspect(child, effective); + } + } + } + + inspect(text.text, null); + expect(colors, [foreground]); + } + for (final label in ['Alice', 'Helper']) { + expect(tester.widget(find.text(label)).style?.color, foreground); + } + expect(tester.widget(find.byIcon(BuzzIcons.bot)).color, foreground); + final link = tester.widget( + find.byWidgetPredicate( + (w) => w is Text && w.textSpan?.toPlainText() == 'Website', + ), + ); + expect(link.textSpan?.style?.color, foreground); + await tester.tap(find.text('Alice')); + expect(tapped, 'person'); + await tester.tap(find.text('Helper')); + expect(tapped, 'agent'); + }); + } + + for (final content in [ + '- screenshots:\n ![result](https://example.com/result.png)\n explanation', + '1. screenshots:\n ![result](https://example.com/result.png)\n explanation', + '> screenshots:\n> ![result](https://example.com/result.png)\n> explanation', + '> screenshots:\n![result](https://example.com/result.png)\nexplanation', + ]) { + testWidgets('nested media retains its Markdown block: $content', ( + tester, + ) async { + await tester.pumpWidget( + _testable(MessageContent(content: content, bubble: true)), + ); + await tester.pumpAndSettle(); + expect(bubbles, findsOneWidget); + final markdown = tester.widget(find.byType(GptMarkdown)); + expect(markdown.data, content); + final media = find.byKey( + const ValueKey( + 'message-media-image-preview:https://example.com/result.png', + ), + ); + expect(find.descendant(of: bubbles, matching: media), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('image corners follow the conversation group', (tester) async { + for (final grouped in [false, true]) { + await tester.pumpWidget( + _testable( + MessageContent( + content: '![photo](https://example.com/photo.png)', + bubble: true, + bubbleStart: !grouped, + bubbleEnd: !grouped, + ), + ), + ); + await tester.pumpAndSettle(); + final frame = tester.widget( + find.byKey( + const ValueKey( + 'message-media-image-preview:https://example.com/photo.png', + ), + ), + ); + final corners = + (frame.decoration! as BoxDecoration).borderRadius! as BorderRadius; + expect(corners.topLeft.x, grouped ? Radii.sm : Radii.container); + expect(corners.bottomLeft.x, grouped ? Radii.sm : Radii.container); + expect(corners.topRight.x, Radii.container); + expect(corners.bottomRight.x, Radii.container); + } + }); + + testWidgets('photo gallery stays outside its caption bubble', (tester) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: + 'Photos\n![one](https://example.com/one.png)\n![two](https://example.com/two.png)', + bubble: true, + ), + ), + ); + await tester.pumpAndSettle(); + final gallery = find.byKey(const ValueKey('message-media-carousel')); + expect(gallery, findsOneWidget); + expect(bubbles, findsOneWidget); + expect(find.descendant(of: bubbles, matching: gallery), findsNothing); + expect( + tester.getTopLeft(gallery).dy, + greaterThanOrEqualTo(tester.getBottomLeft(bubbles).dy), + ); + }); + + testWidgets('neutral bubbles hug short text and distinguish own messages', ( + tester, + ) async { + for (final own in [false, true]) { + await tester.pumpWidget( + _testable( + MessageContent(content: 'Hello', bubble: true, ownMessage: own), + ), + ); + await tester.pumpAndSettle(); + expect(bubbles, findsOneWidget); + expect(tester.getSize(bubbles).width, lessThan(160)); + final decoration = tester.widget( + find.descendant( + of: bubbles, + matching: find.byType(LocalSendBubbleSurface), + ), + ); + expect(decoration.color, isNotNull); + expect( + find.descendant(of: bubbles, matching: _findRich('Hello')), + findsWidgets, + ); + } + }); + + testWidgets('attachments stay outside bubbles and text order is preserved', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: + 'Before\n\n[file.txt](https://example.com/file.txt)\n\nAfter', + tags: [ + ['imeta', 'url https://example.com/file.txt', 'm text/plain'], + ], + bubble: true, + ), + ), + ); + await tester.pumpAndSettle(); + expect(bubbles, findsNWidgets(2)); + expect( + find.descendant(of: bubbles, matching: _findRich('file.txt')), + findsNothing, + ); + expect( + tester.getTopLeft(_findRich('Before')).dy, + lessThan(tester.getTopLeft(_findRich('file.txt')).dy), + ); + expect( + tester.getTopLeft(_findRich('file.txt')).dy, + lessThan(tester.getTopLeft(_findRich('After')).dy), + ); + }); + + testWidgets('fenced attachment examples remain in the text bubble', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: '```text\n![example](https://example.com/image.png)\n```', + bubble: true, + ), + ), + ); + await tester.pumpAndSettle(); + expect(bubbles, findsOneWidget); + expect( + find.descendant( + of: bubbles, + matching: _findRich('![example](https://example.com/image.png)'), + ), + findsWidgets, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('bubble text reflows at narrow width and large text', ( + tester, + ) async { + _setSurfaceSize(tester, const Size(320, 800)); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + _testable( + MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: const SizedBox( + width: 230, + child: MessageContent( + content: + 'A longer message that wraps across multiple lines without clipping.', + bubble: true, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(bubbles).width, lessThanOrEqualTo(230)); + expect(tester.getSize(bubbles).height, greaterThan(80)); + expect(tester.takeException(), isNull); + }); +} diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 4cba2278ef7..6cdf1e327a6 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -1,3 +1,4 @@ +import 'package:buzz/features/channels/local_send_bubble_surface.dart'; import 'dart:async'; import 'dart:convert'; import 'package:http/testing.dart'; @@ -34,10 +35,13 @@ import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/buzz_loading_indicator.dart'; +part 'message_content_bubble_cases.dart'; + Widget _testable( Widget child, { List overrides = const [], bool disableAnimations = false, + bool dark = false, VideoPreviewFrameLoader? videoPreviewFrameLoader, }) { return ProviderScope( @@ -48,7 +52,7 @@ Widget _testable( ...overrides, ], child: MaterialApp( - theme: AppTheme.light(), + theme: dark ? AppTheme.dark() : AppTheme.light(), // Use MaterialApp.builder so the MediaQuery override (including // disableAnimations) applies to every pushed route, not just the // home scaffold. Navigator-pushed routes (e.g. MediaVideoViewerPage) @@ -373,6 +377,7 @@ class _TestChannelsNotifier extends ChannelsNotifier { } void main() { + bubbleContentTests(); test('wide voice-note waveforms distribute bars across their full width', () { const width = 320.0; const sampleCount = 48; diff --git a/mobile/test/features/channels/message_footer_layout_test.dart b/mobile/test/features/channels/message_footer_layout_test.dart new file mode 100644 index 00000000000..39b1cf3bf0a --- /dev/null +++ b/mobile/test/features/channels/message_footer_layout_test.dart @@ -0,0 +1,120 @@ +import 'package:buzz/features/channels/message_footer_layout.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('footer global transforms and semantics include overlap offset', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: MessageFooterLayout( + overlap: 8, + footer: Semantics( + label: 'Reaction footer', + child: const SizedBox( + key: ValueKey('footer'), + width: 100, + height: 32, + ), + ), + child: const SizedBox( + key: ValueKey('content'), + width: 200, + height: 60, + ), + ), + ), + ), + ); + expect( + tester.getTopLeft(find.byKey(const ValueKey('footer'))), + tester.getTopLeft(find.byKey(const ValueKey('content'))) + + const Offset(0, 52), + ); + final node = tester.getSemantics(find.bySemanticsLabel('Reaction footer')); + expect(node.transform?.getTranslation().y, 52); + semantics.dispose(); + }); + + for (final reduced in [false, true]) { + testWidgets('reaction spacing animates both ways, reduced=$reduced', ( + tester, + ) async { + Widget view(bool reaction) => MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduced), + child: Align( + alignment: Alignment.topLeft, + child: MessageFooterLayout( + animateSize: true, + overlap: reaction ? 8 : 0, + footer: SizedBox(width: 100, height: reaction ? 32 : 0), + child: const SizedBox(width: 200, height: 60), + ), + ), + ), + ); + double height() => + tester.getSize(find.byType(MessageFooterLayout)).height; + await tester.pumpWidget(view(false)); + await tester.pumpWidget(view(true)); + await tester.pump(const Duration(milliseconds: 100)); + expect( + height(), + reduced ? equals(84) : allOf(greaterThan(60), lessThan(84)), + ); + await tester.pumpAndSettle(); + expect(height(), 84); + await tester.pumpWidget(view(false)); + await tester.pump(const Duration(milliseconds: 100)); + expect( + height(), + reduced ? equals(60) : allOf(greaterThan(60), lessThan(84)), + ); + await tester.pumpAndSettle(); + expect(height(), 60); + }); + } + + testWidgets( + 'overlapping reaction area is tappable and reserves its protrusion', + (tester) async { + var taps = 0; + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: MessageFooterLayout( + overlap: 8, + footer: GestureDetector( + onTap: () => taps++, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + key: ValueKey('reactions'), + width: 100, + height: 32, + ), + ), + child: const SizedBox( + key: ValueKey('body'), + width: 200, + height: 60, + ), + ), + ), + ), + ); + final body = tester.getRect(find.byKey(const ValueKey('body'))); + final badge = tester.getRect(find.byKey(const ValueKey('reactions'))); + expect(badge.top, body.bottom - 8); + expect(tester.getSize(find.byType(MessageFooterLayout)).height, 84); + await tester.tapAt(Offset(badge.left + 10, badge.top + 2)); + expect(taps, 1); + }, + ); +} diff --git a/mobile/test/features/channels/reaction_row_test.dart b/mobile/test/features/channels/reaction_row_test.dart index 3fc32577308..1875d28b30b 100644 --- a/mobile/test/features/channels/reaction_row_test.dart +++ b/mobile/test/features/channels/reaction_row_test.dart @@ -1,5 +1,13 @@ +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_render.dart'; +import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; +import 'package:buzz/shared/relay/media_image.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:buzz/shared/emoji/emoji_avatar.dart'; import 'dart:async'; +import 'dart:ui' show SemanticsAction; +import 'package:buzz/shared/theme/buzz_icons.dart'; import 'package:buzz/features/channels/channel_identity_names_provider.dart'; import 'package:buzz/shared/identity_names/identity_names.dart'; import 'package:buzz/features/channels/message_long_press_region.dart'; @@ -19,6 +27,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../helpers/widget_helpers.dart'; +part 'reaction_row_test/combined_tests.dart'; + const _alice = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; @@ -66,6 +76,7 @@ Future _pumpRow( required List reactions, void Function(String emoji)? onToggle, bool showAddButton = false, + bool combined = false, VoidCallback? onAddReaction, String messageId = _messageId, VoidCallback? onMessageLongPress, @@ -84,6 +95,7 @@ Future _pumpRow( onLongPress: (_) => onMessageLongPress?.call(), child: ReactionRow( messageId: messageId, + combined: combined, channelId: 'channel', reactions: reactions, onToggle: onToggle ?? (_) {}, @@ -111,7 +123,22 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { Future preload(List pubkeys) async => true; } +class _RecordingReactionActions implements ChannelActions { + final removed = []; + final added = []; + @override + Future removeReaction(String eventId, String emoji) async => + removed.add(eventId); + @override + Future addReaction(String eventId, String emoji) async => + added.add(eventId); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + void main() { + combinedReactionTests(); + testWidgets('removing the sole reaction closes the sheet', (tester) async { final reactions = ValueNotifier>([ _reaction(reactedByCurrentUser: true), @@ -169,72 +196,71 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets( - 'native toggles use the current callback and stream updated counts', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - final completion = Completer>(); - Map? payload; - Map? updated; - final toggles = []; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + testWidgets('native combined tray toggles and streams updated counts', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final completion = Completer>(); + Map? payload; + Map? updated; + final toggles = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (call) async { + if (call.method == 'reactions') { + payload = call.arguments as Map; + return completion.future; + } + if (call.method == 'updateReactions') updated = call.arguments as Map; + return {}; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( NativeMessagePresentation.channel, - (call) async { - if (call.method == 'reactions') { - payload = call.arguments as Map; - return completion.future; - } - if (call.method == 'updateReactions') updated = call.arguments as Map; - return {}; - }, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ), - ); - await _pumpRow( - tester, - reactions: [_reaction()], - onToggle: (emoji) => toggles.add('old:$emoji'), - ); - await tester.longPress( - find.byKey(const ValueKey('reaction-pill-$_fire')), - ); - await tester.pumpAndSettle(); - await _pumpRow( - tester, - reactions: [_reaction(count: 2, reactedByCurrentUser: true)], - onToggle: (emoji) => toggles.add('new:$emoji'), - ); - expect((updated!['reactions'] as List).single, containsPair('count', 2)); - expect( - (updated!['reactions'] as List).single, - containsPair('selected', true), - ); - expect(updated!['requestId'], payload!['requestId']); - await tester.binding.defaultBinaryMessenger.handlePlatformMessage( - NativeMessagePresentation.channel.name, - const StandardMethodCodec().encodeMethodCall( - MethodCall('toggleReaction', { - 'requestId': payload!['requestId'], - 'emoji': _fire, - }), - ), - (_) {}, - ); - await tester.pump(); - expect(toggles, ['new:$_fire']); - await tester.pumpWidget(const SizedBox()); - await tester.pumpAndSettle(); - expect(updated!['reactions'], isEmpty); - completion.complete({}); - await tester.pumpAndSettle(); - debugDefaultTargetPlatformOverride = null; - }, - ); + null, + ), + ); + await _pumpRow( + tester, + combined: true, + reactions: [_reaction()], + onToggle: (emoji) => toggles.add('old:$emoji'), + ); + await tester.tap(find.byKey(const ValueKey('combined-reaction-tray'))); + await tester.pumpAndSettle(); + await _pumpRow( + tester, + combined: true, + reactions: [_reaction(count: 2, reactedByCurrentUser: true)], + onToggle: (emoji) => toggles.add('new:$emoji'), + ); + expect((updated!['reactions'] as List).single, containsPair('count', 2)); + expect( + (updated!['reactions'] as List).single, + containsPair('selected', true), + ); + expect(updated!['requestId'], payload!['requestId']); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + NativeMessagePresentation.channel.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall('toggleReaction', { + 'requestId': payload!['requestId'], + 'emoji': _fire, + }), + ), + (_) {}, + ); + await tester.pump(); + expect(toggles, ['new:$_fire']); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + expect(updated!['reactions'], isEmpty); + completion.complete({}); + await tester.pumpAndSettle(); + debugDefaultTargetPlatformOverride = null; + }); testWidgets( 'native reactor labels retain channel qualifications and live updates', diff --git a/mobile/test/features/channels/reaction_row_test/combined_tests.dart b/mobile/test/features/channels/reaction_row_test/combined_tests.dart new file mode 100644 index 00000000000..058f6c62fe9 --- /dev/null +++ b/mobile/test/features/channels/reaction_row_test/combined_tests.dart @@ -0,0 +1,297 @@ +part of '../reaction_row_test.dart'; + +void combinedReactionTests() { + for (final selected in [false, true]) { + for (final count in [1, 2]) { + testWidgets( + 'combined tray has one actionable semantics label, selected=$selected count=$count', + (tester) async { + final semantics = tester.ensureSemantics(); + + await _pumpRow( + tester, + combined: true, + reactions: [ + _reaction(count: count, reactedByCurrentUser: selected), + ], + onToggle: (_) {}, + ); + final label = + 'View $count ${count == 1 ? 'reaction' : 'reactions'}${selected ? ', including yours' : ''}'; + final tray = find.bySemanticsLabel(label); + expect(tray, findsOneWidget); + final node = tester.getSemantics(tray); + expect(node.label, label); + expect( + node.getSemanticsData().hasAction(SemanticsAction.tap), + isTrue, + ); + expect(node.childrenCount, 0); + semantics.dispose(); + }, + ); + } + } + + testWidgets('Android combined tray removes own reaction and stays live', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final actions = _RecordingReactionActions(); + Future draw(bool selected) async { + final message = TimelineMessage( + id: _messageId, + pubkey: _alice, + createdAt: 1, + content: '', + reactions: [ + TimelineReaction( + emoji: _fire, + count: selected ? 2 : 1, + reactedByCurrentUser: selected, + currentUserReactionId: selected ? 'own-reaction' : null, + userPubkeys: selected ? [_alice, 'other'] : ['other'], + ), + ], + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + channelActionsProvider.overrideWithValue(actions), + userCacheProvider.overrideWith(() => _FakeUserCacheNotifier({})), + ], + child: Consumer( + builder: (context, ref, _) => ReactionRow( + combined: true, + messageId: message.id, + channelId: 'channel', + reactions: message.reactions, + onToggle: (emoji) => toggleReaction(ref, message, emoji), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + await draw(true); + await tester.tap(find.byKey(const ValueKey('combined-reaction-tray'))); + await tester.pumpAndSettle(); + final chip = find.byKey(const ValueKey('reaction-toggle-$_fire')); + expect(tester.widget(chip).selected, isTrue); + await tester.tap(chip); + await tester.pump(); + expect(actions.removed, ['own-reaction']); + expect(actions.added, isEmpty); + await draw(false); + expect(tester.widget(chip).selected, isFalse); + expect( + find.byKey(const ValueKey('reaction-details-sheet')), + findsOneWidget, + ); + expect(find.text('2'), findsNothing); + expect(find.text('1'), findsNWidgets(2)); + debugDefaultTargetPlatformOverride = null; + }); + + for (final loadCustom in [false, true]) { + for (final scale in [1.0, 3.0]) { + testWidgets( + 'mixed reaction glyphs stay centered, image=$loadCustom scale=$scale', + (tester) async { + final imageBytes = (await rootBundle.load( + 'assets/images/buzz-icon.png', + )).buffer.asUint8List(); + final client = MockClient( + (_) async => loadCustom + ? http.Response.bytes( + imageBytes, + 200, + headers: {'content-type': 'image/png'}, + ) + : http.Response('missing', 404), + ); + addTearDown(client.close); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [mediaHttpClientProvider.overrideWithValue(client)], + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: ReactionRow( + combined: true, + messageId: 'alignment', + channelId: 'channel', + onToggle: (_) {}, + reactions: [ + for (final emoji in [ + '🙂', + '👀', + '❤️', + '👩‍👩‍👧‍👦', + '🇺🇸', + '✅', + '🎥', + '🎯', + '🙌', + ]) + _reaction(emoji: emoji), + TimelineReaction( + emoji: ':custom:', + emojiUrl: + 'https://example.com/align-$loadCustom-$scale.png', + count: 1, + reactedByCurrentUser: false, + userPubkeys: const [_alice], + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + if (loadCustom) { + final source = tester.widget( + find.descendant( + of: find.byType(CustomEmojiImage), + matching: find.byType(Image), + ), + ); + await tester.runAsync( + () => precacheImage( + source.image, + tester.element(find.byType(CustomEmojiImage)), + ), + ); + await tester.pumpAndSettle(); + final image = tester.widget( + find.descendant( + of: find.byType(CustomEmojiImage), + matching: find.byType(RawImage), + ), + ); + expect(image.image, isNotNull); + } else { + expect(find.text(':custom:'), findsOneWidget); + } + final tray = find.byKey(const ValueKey('combined-reaction-tray')); + final center = tester.getCenter(tray).dy; + for (final glyph in find.byType(NativeEmojiGlyph).evaluate()) { + final finder = find.byWidget(glyph.widget); + expect(tester.getSize(finder), const Size.square(20)); + expect(tester.getCenter(finder).dy, closeTo(center, 0.01)); + final paintedText = find.descendant( + of: finder, + matching: find.byType(Text), + ); + // This checks the device-calibrated optical correction, not the + // pixel bounds of the font (host rendering differs from iOS). + final opticalLift = defaultTargetPlatform == TargetPlatform.iOS + ? 2.0 + : 0.0; + expect( + tester.getCenter(paintedText).dy, + closeTo(center - opticalLift, 0.01), + ); + } + final customBox = find + .ancestor( + of: find.byType(CustomEmojiImage), + matching: find.byType(SizedBox), + ) + .first; + expect(tester.getSize(customBox), const Size.square(20)); + expect(tester.getCenter(customBox).dy, closeTo(center, 0.01)); + expect(tester.getCenter(find.text('10')).dy, closeTo(center, 0.01)); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant({ + TargetPlatform.iOS, + TargetPlatform.android, + }), + ); + } + } + + testWidgets( + 'combined capsule shows total count and opens details without toggling', + (tester) async { + final toggled = []; + var added = false; + await _pumpRow( + tester, + combined: true, + reactions: [ + _reaction(count: 3), + _reaction(emoji: _eyes), + ], + onToggle: toggled.add, + showAddButton: true, + onAddReaction: () => added = true, + ); + expect(find.text('1'), findsNothing); + expect(find.text('4'), findsOneWidget); + expect(find.byKey(const ValueKey('add-reaction-pill')), findsNothing); + expect( + tester + .getSize(find.byKey(const ValueKey('combined-reaction-tray'))) + .width, + lessThan(100), + ); + await tester.tap(find.byKey(const ValueKey('combined-reaction-tray'))); + await tester.pumpAndSettle(); + final sheet = find.byKey(const ValueKey('reaction-details-sheet')); + expect(sheet, findsOneWidget); + expect(toggled, isEmpty); + await tester.tap(find.byKey(const ValueKey('reaction-sheet-add'))); + await tester.pumpAndSettle(); + expect(added, isTrue); + }, + ); + + testWidgets('native add action waits for reaction sheet dismissal', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final completion = Completer>(); + Map? payload; + var added = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (call) async { + if (call.method == 'reactions') { + payload = call.arguments as Map; + return completion.future; + } + return {}; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ), + ); + await _pumpRow( + tester, + combined: true, + reactions: [_reaction()], + showAddButton: true, + onAddReaction: () => added++, + ); + await tester.tap(find.byKey(const ValueKey('combined-reaction-tray'))); + await tester.pump(); + expect(payload!['canAddReaction'], isTrue); + expect(payload!['addReactionIconCodePoint'], BuzzIcons.smilePlus.codePoint); + await NativeMessagePresentation.present('updateProfiles', { + 'requestId': payload!['requestId'], + 'profiles': {}, + }); + expect(added, 0); + completion.complete({'action': 'addReaction'}); + await tester.pumpAndSettle(); + expect(added, 1); + debugDefaultTargetPlatformOverride = null; + }); +} diff --git a/mobile/test/features/channels/timeline_message_test.dart b/mobile/test/features/channels/timeline_message_test.dart index 7db7e3e4aa7..8f0798a8ced 100644 --- a/mobile/test/features/channels/timeline_message_test.dart +++ b/mobile/test/features/channels/timeline_message_test.dart @@ -244,7 +244,7 @@ void main() { actorPubkey: 'pk1', targetPubkey: 'pk2', ); - expect(event.describe(resolve), 'Bob was added by Alice'); + expect(event.describe(resolve), 'Alice added Bob to the channel'); }); test('member_left', () { diff --git a/mobile/test/shared/widgets/ios_scroll_to_top_test.dart b/mobile/test/shared/widgets/ios_scroll_to_top_test.dart new file mode 100644 index 00000000000..52c141deb2f --- /dev/null +++ b/mobile/test/shared/widgets/ios_scroll_to_top_test.dart @@ -0,0 +1,166 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:buzz/shared/widgets/frosted_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future tapStatusBar(WidgetTester tester) async { + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/status_bar', + const JSONMethodCodec().encodeMethodCall( + const MethodCall('handleScrollToTop'), + ), + (_) {}, + ); + await tester.pumpAndSettle(); +} + +void main() { + for (final reduced in [false, true]) { + testWidgets( + 'status tap scrolls an explicit controller to top, reduced=$reduced', + (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _testApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: reduced), + child: child!, + ), + home: FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Test')), + body: ListView.builder( + controller: controller, + itemCount: 100, + itemBuilder: (_, index) => + SizedBox(height: 60, child: Text('Row $index')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + controller.jumpTo(900); + await tester.pumpAndSettle(); + await tapStatusBar(tester); + expect(controller.offset, 0); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + } + + testWidgets( + 'status tap does not scroll a page under a modal', + (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + late BuildContext pageContext; + await tester.pumpWidget( + _testApp( + home: FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Test')), + body: Builder( + builder: (context) { + pageContext = context; + return ListView.builder( + controller: controller, + itemCount: 100, + itemBuilder: (_, index) => + SizedBox(height: 60, child: Text('Row $index')), + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + controller.jumpTo(900); + await tester.pumpAndSettle(); + showModalBottomSheet( + context: pageContext, + builder: (_) => const SizedBox(height: 200), + ); + await tester.pumpAndSettle(); + await tapStatusBar(tester); + expect(controller.offset, 900); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + testWidgets( + 'status tap expands a pinned title and returns its list to top', + (tester) async { + await tester.pumpWidget( + _testApp( + home: FrostedScaffold( + nativePinnedBody: true, + appBar: const FrostedAppBar( + title: Text('Title'), + nativeLargeTitle: true, + ), + body: ListView.builder( + itemCount: 100, + itemBuilder: (_, index) => + SizedBox(height: 60, child: Text('Pinned $index')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -900)); + await tester.pumpAndSettle(); + final nested = tester.state( + find.byType(NestedScrollView), + ); + expect(nested.innerController.offset, greaterThan(0)); + await tapStatusBar(tester); + expect(nested.innerController.offset, 0); + expect(nested.outerController.offset, 0); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'status tap leaves an inactive tab alone', + (tester) async { + final controllers = [ScrollController(), ScrollController()]; + addTearDown(() { + for (final controller in controllers) { + controller.dispose(); + } + }); + await tester.pumpWidget( + _testApp( + home: IndexedStack( + index: 1, + children: [ + for (var index = 0; index < 2; index++) + FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Tab')), + body: ListView.builder( + controller: controllers[index], + itemCount: 100, + itemBuilder: (_, row) => + SizedBox(height: 60, child: Text('Row $row')), + ), + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + for (final controller in controllers) { + controller.jumpTo(900); + } + await tester.pumpAndSettle(); + await tapStatusBar(tester); + expect(controllers[0].offset, 900); + expect(controllers[1].offset, 0); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); +} + +Widget _testApp({Widget? home, TransitionBuilder? builder}) => ProviderScope( + child: MaterialApp(home: home, builder: builder), +); From a83ea69b722a8dcfcacf7b560157651d208a1018 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 14 Oct 2026 16:37:59 -0400 Subject: [PATCH 011/144] fix(mobile): replay native navigation state when returning Home (#8314) Restore the iOS navigation bar after any covering route pops, including opaque custom Settings and community routes. Use the shared app RouteObserver and didPopNext, then replay scroll depth and request native layout after Flutter paints the uncovered view. Voice recording retains the same observer through its existing alias. Regression coverage uses the real HomePage with both MaterialPageRoute and opaque PageRouteBuilder, at scrolled and top-of-list positions. The custom-route case fails before this change. Validation: 107 focused tests and analysis passed. Full mobile runs stalled under local disk pressure, so full-suite validation is incomplete. On-device verification of this follow-up remains pending. --------- Signed-off-by: kenny lopez --- mobile/lib/app.dart | 4 +- .../channels/voice_note_recording.dart | 3 +- .../shared/widgets/app_route_observer.dart | 4 + .../shared/widgets/ios_navigation_bar.dart | 51 +++++ .../features/channels/channels_page_test.dart | 200 ++++++++++++++++-- 5 files changed, 236 insertions(+), 26 deletions(-) create mode 100644 mobile/lib/shared/widgets/app_route_observer.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 03c73bb9155..3c16eb5dba7 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -27,7 +27,7 @@ import 'features/pairing/pairing_provider.dart'; import 'features/channels/agent_activity/observer_subscription.dart'; import 'features/channels/channel_detail_page.dart'; import 'features/channels/deep_link_dispatcher.dart'; -import 'features/channels/voice_note_recording.dart'; +import 'shared/widgets/app_route_observer.dart'; import 'features/profile/user_status_cache_provider.dart'; import 'features/profile/settings_profile_header.dart'; import 'features/profile/set_status_sheet.dart'; @@ -392,7 +392,7 @@ class App extends HookConsumerWidget { return MaterialApp( navigatorKey: _mobileRootNavigatorKey, - navigatorObservers: [voiceNoteRouteObserver], + navigatorObservers: [appRouteObserver], title: 'Buzz', theme: AppTheme.light( colorScheme: lightScheme, diff --git a/mobile/lib/features/channels/voice_note_recording.dart b/mobile/lib/features/channels/voice_note_recording.dart index c34565c3415..772232978b2 100644 --- a/mobile/lib/features/channels/voice_note_recording.dart +++ b/mobile/lib/features/channels/voice_note_recording.dart @@ -11,6 +11,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; import '../../shared/relay/media_image.dart'; +import '../../shared/widgets/app_route_observer.dart'; /// Maximum duration accepted for a recorded voice note. const voiceNoteMaxDuration = Duration(minutes: 5); @@ -25,7 +26,7 @@ const voiceNoteMaxDownloadBytes = 32 * 1024 * 1024; const voiceNotePlaybackRates = [1, 1.5, 2, 0.5]; /// Route observer used to cancel recording when its composer is covered. -final voiceNoteRouteObserver = RouteObserver>(); +final voiceNoteRouteObserver = appRouteObserver; /// Returns the playback rate following [current] in the supported rate cycle. double nextVoiceNotePlaybackRate(double current) { diff --git a/mobile/lib/shared/widgets/app_route_observer.dart b/mobile/lib/shared/widgets/app_route_observer.dart new file mode 100644 index 00000000000..74a8bbd7cc4 --- /dev/null +++ b/mobile/lib/shared/widgets/app_route_observer.dart @@ -0,0 +1,4 @@ +import 'package:flutter/widgets.dart'; + +/// Shared route lifecycle for native navigation and recording cleanup. +final appRouteObserver = RouteObserver>(); diff --git a/mobile/lib/shared/widgets/ios_navigation_bar.dart b/mobile/lib/shared/widgets/ios_navigation_bar.dart index 081a94722d4..42cf258d176 100644 --- a/mobile/lib/shared/widgets/ios_navigation_bar.dart +++ b/mobile/lib/shared/widgets/ios_navigation_bar.dart @@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../relay/relay.dart'; import 'avatar_image.dart'; +import 'app_route_observer.dart'; import 'ios_navigation_metrics.dart'; export 'ios_navigation_metrics.dart'; @@ -343,6 +344,47 @@ class IosNavigationBar extends HookConsumerWidget { return () => offset?.removeListener(sync); }, [channel.value, offset, collapseRange]); + final route = ModalRoute.of(context); + useEffect(() { + var active = true; + Future restoreOnReveal() async { + final current = channel.value; + if (!active || + !context.mounted || + route?.isCurrent != true || + current == null) { + return; + } + try { + // Home stays mounted while another route covers its platform view. + // Returning does not emit a scroll notification, so replay the live + // depth before asking UIKit to lay out the revealed navigation bar. + await current.invokeMethod( + 'scroll', + (offset?.value ?? 0).clamp(0.0, collapseRange), + ); + if (!active || route?.isCurrent != true) return; + await current.invokeMethod('prepareForReveal'); + } on PlatformException catch (error) { + debugPrint('Native navigation reveal failed: $error'); + } + } + + final observer = _NavigationRevealObserver(() { + // didPopNext is independent of route transition compatibility. Defer + // until Flutter has painted the uncovered platform view for this frame. + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(restoreOnReveal()); + }); + WidgetsBinding.instance.ensureVisualUpdate(); + }); + if (route != null) appRouteObserver.subscribe(observer, route); + return () { + active = false; + appRouteObserver.unsubscribe(observer); + }; + }, [channel.value, offset, collapseRange, route]); + return UiKitView( key: viewKey, viewType: viewType, @@ -354,3 +396,12 @@ class IosNavigationBar extends HookConsumerWidget { ); } } + +class _NavigationRevealObserver extends RouteAware { + _NavigationRevealObserver(this.onReveal); + + final VoidCallback onReveal; + + @override + void didPopNext() => onReveal(); +} diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 1a543e3fd1c..0a34bf29576 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -1,3 +1,5 @@ +import 'package:buzz/features/home/home_page.dart'; +import 'package:buzz/shared/widgets/app_route_observer.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/shared/community/paired_community_landing.dart'; import 'dart:async'; @@ -80,6 +82,7 @@ void main() { ValueChanged? onSettingsTransitionProgress, ValueListenable? tabReselection, _FakeProfileNotifier? profile, + bool realHome = false, }) { return ProviderScope( overrides: [ @@ -100,6 +103,7 @@ void main() { ...overrides, ], child: MaterialApp( + navigatorObservers: [appRouteObserver], theme: brightness == Brightness.dark ? AppTheme.dark() : AppTheme.light(topSectionGradient: topSectionGradient), @@ -112,30 +116,35 @@ void main() { ), child: child!, ), - home: Stack( - children: [ - ChannelsPage( - settingsPageBuilder: _buildSettingsPage, - communityInvitePageBuilder: (_) => - const Scaffold(body: Text('Invite destination')), - communityAppearancePageBuilder: (_) => - const Scaffold(body: Text('Appearance destination')), - onSettingsTransitionProgress: - onSettingsTransitionProgress ?? (_) {}, - tabReselection: tabReselection, - ), - const Positioned.fill( - child: ChannelQuickActionsLauncher( - visible: true, - navigationBarHeight: 60, - navigationBarBottomGap: 12, - navigationBarWidth: 218, - systemBottomInset: 0, - rightInset: 16, + home: realHome + ? HomePage( + settingsPageBuilder: _buildSettingsPage, + hasUnreadInbox: false, + ) + : Stack( + children: [ + ChannelsPage( + settingsPageBuilder: _buildSettingsPage, + communityInvitePageBuilder: (_) => + const Scaffold(body: Text('Invite destination')), + communityAppearancePageBuilder: (_) => + const Scaffold(body: Text('Appearance destination')), + onSettingsTransitionProgress: + onSettingsTransitionProgress ?? (_) {}, + tabReselection: tabReselection, + ), + const Positioned.fill( + child: ChannelQuickActionsLauncher( + visible: true, + navigationBarHeight: 60, + navigationBarBottomGap: 12, + navigationBarWidth: 218, + systemBottomInset: 0, + rightInset: 16, + ), + ), + ], ), - ), - ], - ), ), ); } @@ -666,6 +675,151 @@ void main() { expect(appBar.frostedBlurSigma, 23.12); }); + for (final customRoute in [false, true]) { + testWidgets( + 'Home replays native scroll state on return: custom=$customRoute', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(390, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final messenger = tester.binding.defaultBinaryMessenger; + final calls = >{}; + messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( + call, + ) async { + if (call.method == 'create') { + final args = call.arguments as Map; + if (args['viewType'] == IosNavigationBar.viewType) { + final id = args['id'] as int; + calls[id] = []; + messenger.setMockMethodCallHandler( + MethodChannel('${IosNavigationBar.viewType}/$id'), + (call) async { + calls[id]!.add(call); + return null; + }, + ); + } + } + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler( + SystemChannels.platform_views, + null, + ); + for (final id in calls.keys) { + messenger.setMockMethodCallHandler( + MethodChannel('${IosNavigationBar.viewType}/$id'), + null, + ); + } + }); + await tester.pumpWidget( + buildTestable( + realHome: true, + overrides: [ + channelsProvider.overrideWith( + () => _FakeNotifier( + List.generate( + 40, + (i) => Channel( + id: 'channel-$i', + description: '', + createdBy: 'test', + createdAt: DateTime(2026), + memberCount: 1, + visibility: 'open', + name: 'channel-$i', + channelType: 'stream', + isMember: true, + ), + ), + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + final homeCalls = calls.values.single; + final position = tester + .state( + find + .descendant( + of: find.byType(CustomScrollView), + matching: find.byType(Scrollable), + ) + .first, + ) + .position; + position.jumpTo(200); + await tester.pumpAndSettle(); + final scrolledOffset = + homeCalls.where((call) => call.method == 'scroll').last.arguments + as double; + expect(scrolledOffset, greaterThan(0)); + final navigator = tester.state( + find.byType(Navigator).first, + ); + unawaited( + navigator.push( + customRoute + ? PageRouteBuilder( + opaque: true, + pageBuilder: (_, _, _) => + const Scaffold(body: Text('Custom route')), + ) + : MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Channel')), + ), + ), + ); + await tester.pumpAndSettle(); + homeCalls.clear(); + navigator.pop(); + await tester.pumpAndSettle(); + expect(position.pixels, 200); + expect( + homeCalls + .where((call) => call.method == 'scroll') + .map((call) => call.arguments), + contains(scrolledOffset), + ); + expect(homeCalls.last.method, 'prepareForReveal'); + // The replay must use the live offset, including returning at the top. + position.jumpTo(0); + await tester.pumpAndSettle(); + unawaited( + navigator.push( + customRoute + ? PageRouteBuilder( + opaque: true, + pageBuilder: (_, _, _) => + const Scaffold(body: Text('Custom route')), + ) + : MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Channel')), + ), + ), + ); + await tester.pumpAndSettle(); + homeCalls.clear(); + navigator.pop(); + await tester.pumpAndSettle(); + expect( + homeCalls + .where((call) => call.method == 'scroll') + .map((call) => call.arguments), + contains(0.0), + ); + await tester.pumpWidget(const SizedBox()); + debugDefaultTargetPlatformOverride = null; + }, + ); + } + testWidgets('scrolls Home to the top when its tab is selected again', ( tester, ) async { From b3795236ccde4cefaebfd3da06ce4c838b76e78a Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 14 Oct 2026 17:18:38 -0400 Subject: [PATCH 012/144] fix(relay): reject unreadable write targets as missing (#8249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 A sender who cannot use or read a referenced event, artifact or workflow now gets exactly the response it would get for an ID that does not exist. #8197 (merged) set this rule for deletions; this applies it to the other writes that look up a target, plus the workflow read and webhook endpoints. Artifact `create` and workflow definition saves are the exceptions, described below. Access is checked before any message that depends on the target, readability goes through `event_visible_to_reader` (including #8197's private-deletion rule), and soft-deleted targets keep matching missing ones. | Path | Checks, in order | Response for any unusable target | |---|---|---| | Reaction (kind 7) | target lookup → `event_visible_to_reader` → every channel gate for the derived channel, including archived | `invalid: reaction target event not found` | | Edit (kind 40003) | target in the edit's `h` channel and readable → author / agent-owner check, including an author since removed from a private channel | `invalid: edit target not found or not editable by you` | | Forum vote (kind 45002) | target in the vote's `h` channel and readable → kind check | `invalid: vote target event not found` | | Report by event (kind 1984 `e`) | `event_visible_to_reader` → token channel scope → channel membership or open | `invalid: report target event not found` | | Reply (NIP-10 and workflow `send_message` replies) | parent in the reply's channel and readable | `reply parent not found` | | Artifact update / delete / restore / move | head exists and is homed in `h` (move: in the source the sender can write) → revision, type, deleted and root checks | `conflict: artifact head unavailable` | | Workflow trigger (kind 46020) | workflow exists and sender is its owner | `invalid: workflow not found or not triggerable by you` | | `GET /workflows/{id}/runs` and `/runs/{run_id}/approvals` | workflow exists, has a channel, and the caller can read it | `404 {"error":"workflow not found"}` | | `POST /hooks/{id}` | workflow exists and the provided secret matches its stored secret → definition and trigger-type checks | `404 {"error":"workflow not found"}` | Two of these reject writes that used to be accepted: a reaction to an event the sender cannot read (such as someone else's author-only event), and a report on an event the reporter cannot read. Reports by pubkey and by blob are unchanged. In an archived channel, writes whose channel comes from their target (reactions and deletions) get that target's not-found response, and an artifact move out of an archived source channel gets `conflict: artifact head unavailable` like any other source the sender cannot write; every other write keeps `invalid: channel is archived`. A webhook call with an absent or wrong secret, or to a workflow without a webhook secret, now gets `404` instead of `401`/`400`. Artifact `create` and workflow definition saves still report a taken ID, as NIP-AR allows for artifacts. Removed messages, none of which any client depends on from these endpoints: `edit target event not found`, `must be event author to edit`, `target event belongs to a different channel`, `target event has no channel`, `parent event belongs to a different channel`, `parent event has no channel association`, `artifact home changed`, `workflow is not accessible`, `workflow is not channel-scoped`, the webhook's `authentication failed` and `webhook secret required but not configured`, `forbidden: not authorized to trigger this workflow` for non-owners (it remains for an owner whose channel authority lapsed), and `restricted: not a channel member` for an edit by an author removed from a private channel. `e2e_existence_oracle` checks each event write over HTTP and WS, and the workflow read and webhook endpoints over HTTP, against missing, soft-deleted, other-channel, private-channel, archived, author-only and wrong-kind targets as applicable, with a positive control per path. It runs in the Backend Integration job alongside `e2e_author_only_deletion`. `removed_author_edit_does_not_reveal_target` checks that a removed author's edits of their remaining message, their deleted message and a missing ID get identical answers. In the PostgreSQL suite, `head_outside_authorized_channel_matches_missing_head` covers the artifact transaction, `workflow_reply_to_unusable_parent_matches_missing` covers workflow replies through `RelayActionSink::send_message`, `scoped_token_report_does_not_reveal_target` and `scoped_token_reaction_does_not_reveal_target` cover channel-scoped tokens, and `channelless_workflow_runs_read_as_missing` covers the runs read for a workflow with no channel. --------- Signed-off-by: Will Pfleger Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- .github/workflows/_ci-relay.yml | 3 +- crates/buzz-db/src/store/artifact.rs | 21 +- .../src/store/artifact_postgres_tests.rs | 101 +++ .../src/api/artifact_postgres_tests.rs | 23 +- crates/buzz-relay/src/api/bridge.rs | 107 ++- crates/buzz-relay/src/api/workflows.rs | 28 +- crates/buzz-relay/src/handlers/artifact.rs | 14 +- .../src/handlers/command_executor.rs | 11 +- crates/buzz-relay/src/handlers/ingest.rs | 339 ++++++-- crates/buzz-relay/src/handlers/report.rs | 48 +- crates/buzz-relay/src/workflow_sink.rs | 121 +++ .../tests/e2e_existence_oracle.rs | 823 ++++++++++++++++++ 12 files changed, 1500 insertions(+), 139 deletions(-) create mode 100644 crates/buzz-test-client/tests/e2e_existence_oracle.rs diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index dff24c5f75d..a0fa0a4ae83 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -111,6 +111,7 @@ jobs: --lib \ --test e2e_event_reminder \ --test e2e_author_only_deletion \ + --test e2e_existence_oracle \ --archive-file target/ci/backend-integration-tests.tar.zst - name: Build PostgreSQL test archive if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && matrix.artifact == 'postgres-tests' @@ -581,7 +582,7 @@ jobs: run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'binary(e2e_event_reminder) | binary(e2e_author_only_deletion)' \ + -E 'binary(e2e_event_reminder) | binary(e2e_author_only_deletion) | binary(e2e_existence_oracle)' \ --run-ignored ignored-only env: RELAY_URL: ws://localhost:3000 diff --git a/crates/buzz-db/src/store/artifact.rs b/crates/buzz-db/src/store/artifact.rs index 988fb861358..a8a49ac602b 100644 --- a/crates/buzz-db/src/store/artifact.rs +++ b/crates/buzz-db/src/store/artifact.rs @@ -25,6 +25,10 @@ pub enum ArtifactOutcome { Rejected(&'static str), } +/// Conflict for a non-create revision whose artifact is missing or lives +/// outside every channel the sender was authorized for. +pub const HEAD_UNAVAILABLE: &str = "artifact head unavailable"; + fn invalid(message: &str) -> DbError { DbError::InvalidData(message.into()) } @@ -133,7 +137,19 @@ impl Db { return Ok(ArtifactOutcome::Conflict("artifact identity is taken")) } (None, ArtifactOp::Create) => {} - (None, _) => return Ok(ArtifactOutcome::Conflict("artifact head unavailable")), + (None, _) => return Ok(ArtifactOutcome::Conflict(HEAD_UNAVAILABLE)), + // The caller authorized writes only in `h`, or for a move in the + // source it read before this transaction. A head anywhere else + // answers like a missing one, before any check that describes it. + (Some(_), op) + if source + != match op { + ArtifactOp::Move => authorized_source, + _ => Some(env.home), + } => + { + return Ok(ArtifactOutcome::Conflict(HEAD_UNAVAILABLE)) + } (Some(head), op) => { let current: Vec = head.get("event_id"); if env.prev.as_deref() != Some(current.as_slice()) { @@ -152,9 +168,6 @@ impl Db { "only move changes home and move must change home", )); } - if op == ArtifactOp::Move && authorized_source != source { - return Ok(ArtifactOutcome::Conflict("artifact home changed")); - } if op == ArtifactOp::Delete && env.root != old_root { return Ok(ArtifactOutcome::Rejected("delete preserves root")); } diff --git a/crates/buzz-db/src/store/artifact_postgres_tests.rs b/crates/buzz-db/src/store/artifact_postgres_tests.rs index 4ac03a06f4b..f1b0af4362f 100644 --- a/crates/buzz-db/src/store/artifact_postgres_tests.rs +++ b/crates/buzz-db/src/store/artifact_postgres_tests.rs @@ -436,3 +436,104 @@ async fn quiescing_community_rejects_artifact_at_admission_before_coordinate_loc "a rejected artifact must persist nothing" ); } + +/// A non-create revision whose head lives outside the channel the caller +/// authorized answers exactly like a missing artifact, before the revision, +/// type, deleted and root checks could describe the head. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn head_outside_authorized_channel_matches_missing_head() { + let f = Fixture::new().await; + let d = Uuid::new_v4(); + let create = f.revision(d, "create", f.a, None, &f.owner, vec![]); + assert!(matches!( + f.accept(&create, None).await, + ArtifactOutcome::Accepted(_) + )); + let gone = Uuid::new_v4(); + let deleted_d = Uuid::new_v4(); + let deleted_create = f.revision(deleted_d, "create", f.a, None, &f.owner, vec![]); + assert!(matches!( + f.accept(&deleted_create, None).await, + ArtifactOutcome::Accepted(_) + )); + let deleted = f.revision( + deleted_d, + "delete", + f.a, + Some(&deleted_create), + &f.owner, + vec![], + ); + assert!(matches!( + f.accept(&deleted, None).await, + ArtifactOutcome::Accepted(_) + )); + let retyped = { + let mut tags: Vec> = vec![ + vec!["ar".into(), "1".into()], + vec!["d".into(), d.to_string()], + vec!["h".into(), f.b.to_string()], + vec!["type".into(), "buzz.note".into()], + vec!["op".into(), "update".into()], + vec!["title".into(), "Test".into()], + ]; + tags.push(vec!["prev".into(), create.id.to_hex()]); + EventBuilder::new(Kind::Custom(45010), "secret") + .tags(tags.into_iter().map(|t| Tag::parse(t).unwrap())) + .sign_with_keys(&f.peer) + .unwrap() + }; + let cases = [ + ( + "missing", + f.revision(gone, "update", f.b, Some(&create), &f.peer, vec![]), + None, + ), + ( + "current prev", + f.revision(d, "update", f.b, Some(&create), &f.peer, vec![]), + None, + ), + ( + "stale prev", + f.revision(d, "update", f.b, Some(&deleted), &f.peer, vec![]), + None, + ), + ("other type", retyped, None), + ( + "delete", + f.revision(d, "delete", f.b, Some(&create), &f.peer, vec![]), + None, + ), + ( + "restore deleted", + f.revision(deleted_d, "restore", f.b, Some(&deleted), &f.peer, vec![]), + None, + ), + ( + "update deleted", + f.revision(deleted_d, "update", f.b, Some(&deleted), &f.peer, vec![]), + None, + ), + ( + "move from unauthorized source", + f.revision(d, "move", f.b, Some(&create), &f.peer, vec![]), + Some(f.b), + ), + ]; + for (case, event, source) in cases { + match f.accept(&event, source).await { + ArtifactOutcome::Conflict(reason) => { + assert_eq!(reason, super::artifact::HEAD_UNAVAILABLE, "{case}") + } + other => panic!("{case}: {other:?}"), + } + } + // Positive control: the same revision homed where the head lives. + let update = f.revision(d, "update", f.a, Some(&create), &f.peer, vec![]); + assert!(matches!( + f.accept(&update, None).await, + ArtifactOutcome::Accepted(_) + )); +} diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests.rs index e60010cc8ac..b962d3f1c7b 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests.rs @@ -311,21 +311,36 @@ async fn writes_use_channel_gates_for_home_and_move_source() { .as_array() .unwrap() .contains(&json!(["prev", peer_edit.id.to_hex()]))); + // An unwritable source answers exactly like a move of a missing artifact. + let missing_move = + |key: &Keys| f.revision_as(key, f.home, Uuid::new_v4(), "move", Some(&moved)); + let (status, body) = f.try_publish(&missing_move(&f.peer)).await; + let peer_missing = (status, without_event_id(body)); + assert!(!peer_missing.0.is_success(), "{peer_missing:?}"); // The peer can write the destination but not the private source. let back = f.revision_as(&f.peer, f.home, d, "move", Some(&moved)); let (status, body) = f.try_publish(&back).await; - assert!(!status.is_success() || body["accepted"] == false, "{body}"); - assert!(body.to_string().contains("not a channel member"), "{body}"); + assert_eq!((status, without_event_id(body)), peer_missing); sqlx::query("UPDATE channels SET archived_at=now() WHERE community_id=$1 AND id=$2") .bind(f.community.as_uuid()) .bind(f.private) .execute(&f.pool) .await .unwrap(); + let (status, body) = f.try_publish(&missing_move(&f.owner)).await; + let owner_missing = (status, without_event_id(body)); + assert!(!owner_missing.0.is_success(), "{owner_missing:?}"); let archived_source = f.revision_as(&f.owner, f.home, d, "move", Some(&moved)); let (status, body) = f.try_publish(&archived_source).await; - assert!(!status.is_success() || body["accepted"] == false, "{body}"); - assert!(body.to_string().contains("archived"), "{body}"); + assert_eq!((status, without_event_id(body)), owner_missing); +} + +/// Drop the per-event id so rejections of different events compare exactly. +fn without_event_id(mut body: Value) -> Value { + if let Some(object) = body.as_object_mut() { + object.remove("event_id"); + } + body } /// Re-sign an artifact revision with extra tags and an explicit timestamp. diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index be6743599b6..4e90a20e159 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2449,17 +2449,10 @@ pub async fn workflow_webhook( .await .map_err(|_| not_found("workflow not found"))?; - let def: buzz_workflow::WorkflowDef = serde_json::from_value(workflow.definition.clone()) - .map_err(|e| super::internal_error(&format!("corrupt workflow definition: {e}")))?; - - if !matches!(def.trigger, buzz_workflow::TriggerDef::Webhook) { - return Err(api_error( - StatusCode::BAD_REQUEST, - "workflow does not have a webhook trigger", - )); - } - // Verify webhook secret. Prefer header (not logged by proxies); fall back to query param. + // Until the caller proves the secret, every failure answers like a missing + // workflow, so the response never reveals that the workflow exists, its + // trigger type, or whether a secret was configured. let stored_secret = crate::webhook_secret::extract_secret(&workflow.definition); let provided_secret = headers .get("x-webhook-secret") @@ -2467,20 +2460,21 @@ pub async fn workflow_webhook( .map(|s| s.to_string()) .or_else(|| query.secret.clone()) .unwrap_or_default(); + if !stored_secret + .is_some_and(|secret| crate::webhook_secret::verify_secret(&provided_secret, &secret)) + { + tracing::warn!("webhook: missing or invalid secret for workflow {id}"); + return Err(not_found("workflow not found")); + } - match &stored_secret { - Some(secret) => { - if !crate::webhook_secret::verify_secret(&provided_secret, secret) { - tracing::warn!("webhook: invalid secret for workflow {id}"); - return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed")); - } - } - None => { - return Err(api_error( - StatusCode::UNAUTHORIZED, - "webhook secret required but not configured — re-save the workflow to generate one", - )); - } + let def: buzz_workflow::WorkflowDef = serde_json::from_value(workflow.definition.clone()) + .map_err(|e| super::internal_error(&format!("corrupt workflow definition: {e}")))?; + + if !matches!(def.trigger, buzz_workflow::TriggerDef::Webhook) { + return Err(api_error( + StatusCode::BAD_REQUEST, + "workflow does not have a webhook trigger", + )); } // Parse optional JSON body as trigger context. @@ -4441,6 +4435,73 @@ pub(crate) mod postgres_tests { (status, String::from_utf8_lossy(&bytes).into_owned()) } + /// A workflow row without a channel has no readers, so even its owner + /// reads its runs exactly like a workflow that does not exist. Ingest + /// refuses channelless definitions, so the row is seeded directly. + /// Mutation: let a `None` channel through `authorize_workflow_read` → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channelless_workflow_runs_read_as_missing() { + use axum::body::Body; + use axum::http::{header, Request}; + use tower::ServiceExt; + + let state = bridge_handler_test_state() + .await + .expect("local Postgres not reachable"); + let host = format!("channelless-{}.local", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let owner = Keys::generate(); + state + .db + .ensure_user(community, owner.public_key().as_bytes()) + .await + .expect("ensure user"); + let workflow = state + .db + .create_workflow( + community, + None, + owner.public_key().as_bytes(), + "channelless", + "{}", + &[0u8; 32], + ) + .await + .expect("seed workflow"); + let read = |id: uuid::Uuid| { + let state = state.clone(); + let host = host.clone(); + let owner = owner.public_key().to_hex(); + async move { + let resp = crate::router::build_router(state) + .oneshot( + Request::builder() + .uri(format!("/workflows/{id}/runs")) + .header(header::HOST, host) + .header("x-pubkey", owner) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("oneshot"); + let status = resp.status(); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("body"); + (status, body) + } + }; + let missing = read(uuid::Uuid::new_v4()).await; + assert_eq!(missing.0, axum::http::StatusCode::NOT_FOUND, "{missing:?}"); + assert_eq!(read(workflow).await, missing); + } + /// Collect buzz_events_rejected_total with (transport, reason) labels from /// a DebuggingRecorder snapshot. fn http_reject_counts( diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index d957965bca7..b6a1f98e001 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -102,19 +102,19 @@ async fn authorize_workflow_read( .await .map_err(|e| e.into_response())?; - let workflow = state - .db - .get_workflow(tenant.community(), workflow_id) - .await - .map_err(|error| match error { - buzz_db::error::DbError::NotFound(_) => { - api_error(StatusCode::NOT_FOUND, "workflow not found").into_response() - } - other => internal_error(&format!("get workflow for run read: {other}")).into_response(), - })?; - let channel_id = workflow.channel_id.ok_or_else(|| { - api_error(StatusCode::FORBIDDEN, "workflow is not channel-scoped").into_response() - })?; + // A workflow the caller cannot read answers exactly like a missing one, so + // the response never reveals which workflow IDs exist. + let not_found = || api_error(StatusCode::NOT_FOUND, "workflow not found").into_response(); + let workflow = match state.db.get_workflow(tenant.community(), workflow_id).await { + Ok(workflow) => workflow, + Err(buzz_db::error::DbError::NotFound(_)) => return Err(not_found()), + Err(other) => { + return Err( + internal_error(&format!("get workflow for run read: {other}")).into_response(), + ) + } + }; + let channel_id = workflow.channel_id.ok_or_else(not_found)?; let accessible = state .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) .await @@ -122,7 +122,7 @@ async fn authorize_workflow_read( internal_error(&format!("workflow channel access lookup: {error}")).into_response() })?; if !accessible.contains(&channel_id) { - return Err(api_error(StatusCode::FORBIDDEN, "workflow is not accessible").into_response()); + return Err(not_found()); } Ok(tenant) diff --git a/crates/buzz-relay/src/handlers/artifact.rs b/crates/buzz-relay/src/handlers/artifact.rs index 3e186f93382..772e032e75f 100644 --- a/crates/buzz-relay/src/handlers/artifact.rs +++ b/crates/buzz-relay/src/handlers/artifact.rs @@ -24,10 +24,14 @@ pub(crate) async fn accept( .artifact_home(tenant.community(), env.id) .await .map_err(|e| IngestError::Internal(format!("artifact storage: {e}")))? - .ok_or_else(|| { - IngestError::CanvasConflict("conflict: artifact head unavailable".into()) + .ok_or_else(head_unavailable)?; + // A source the sender cannot write answers like a missing artifact. + super::ingest::check_channel_write(tenant, state, auth, source) + .await + .map_err(|e| match e { + IngestError::Internal(_) => e, + _ => head_unavailable(), })?; - super::ingest::check_channel_write(tenant, state, auth, source).await?; Some(source) } else { None @@ -64,6 +68,10 @@ pub(crate) async fn accept( }) } +fn head_unavailable() -> IngestError { + IngestError::CanvasConflict(format!("conflict: {}", buzz_db::artifact::HEAD_UNAVAILABLE)) +} + /// Live delivery only; unlike kind 9 there are no audit, workflow, or thread /// side effects. Missed deliveries are recovered by replaying stored events. async fn publish(state: &Arc, tenant: &TenantContext, stored: &StoredEvent) { diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 460323882a4..bab57a3809e 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -809,6 +809,9 @@ async fn handle_workflow_def( }) } +/// Trigger rejection for a missing workflow and for one the sender does not own. +const WORKFLOW_NOT_FOUND: &str = "invalid: workflow not found or not triggerable by you"; + async fn handle_workflow_trigger( tenant: &TenantContext, state: &Arc, @@ -835,15 +838,15 @@ async fn handle_workflow_trigger( .db .get_workflow(community_id, workflow_id) .await - .map_err(|_| IngestError::Rejected("invalid: workflow not found".into()))?; + .map_err(|_| IngestError::Rejected(WORKFLOW_NOT_FOUND.into()))?; // 3. Manual triggers execute with the workflow owner's authority, so only // the owner may start them. Channel membership alone is insufficient: a // member could otherwise invoke another user's webhook or message actions. + // Anyone else is told the workflow is not found, so the response never + // reveals whether a workflow ID exists. if workflow.owner_pubkey != self_bytes { - return Err(IngestError::Rejected( - "forbidden: not authorized to trigger this workflow".into(), - )); + return Err(IngestError::Rejected(WORKFLOW_NOT_FOUND.into())); } // SEC-006: manual triggers must honor the workflow's lifecycle state and diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 0324d6f2cc5..9e8311e8f6e 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -628,16 +628,24 @@ pub(crate) fn extract_channel_id(event: &Event) -> Option { pub(crate) enum ReactionChannelResult { Channel(Uuid), NoChannel, + /// Missing, soft-deleted, or not readable by the reactor. NotFound, NoTarget, DbError(String), } -/// Derive channel_id from the target event for NIP-25 reactions. +/// Rejection for a reaction whose target is missing or unusable by the sender. +/// Every such target gets this text, so it never reveals whether, or where, +/// the target exists. +const REACTION_TARGET_NOT_FOUND: &str = "reaction target event not found"; + +/// Derive channel_id from the target event for NIP-25 reactions. A target +/// `reader` may not read resolves as `NotFound`. pub(crate) async fn derive_reaction_channel( community_id: CommunityId, db: &buzz_db::Db, event: &Event, + reader: &[u8], ) -> ReactionChannelResult { let target_hex = match event.tags.iter().rev().find_map(|tag| { if tag.kind().to_string() == "e" { @@ -665,6 +673,9 @@ pub(crate) async fn derive_reaction_channel( .get_event_by_id_for_event_write(community_id, &id_bytes) .await { + Ok(Some(target)) if !super::req::event_visible_to_reader(&target.event, reader) => { + ReactionChannelResult::NotFound + } Ok(Some(target)) => match target.channel_id { Some(ch_id) => ReactionChannelResult::Channel(ch_id), None => ReactionChannelResult::NoChannel, @@ -921,6 +932,18 @@ impl ThreadMetadataOwned { } } +/// Whether `target` is in `channel_id` and readable by `reader`. Write paths +/// that reference an event reject a target failing this exactly as a missing +/// one, so the rejection never reveals whether, or where, the target exists. +fn target_readable_in_channel( + target: &buzz_core::StoredEvent, + channel_id: Uuid, + reader: &[u8], +) -> bool { + target.channel_id == Some(channel_id) + && super::req::event_visible_to_reader(&target.event, reader) +} + /// Resolve NIP-10 thread ancestry from e-tags. pub(crate) async fn resolve_nip10_thread_meta( community_id: CommunityId, @@ -947,18 +970,12 @@ pub(crate) async fn resolve_nip10_thread_meta( .get_thread_metadata_by_event(community_id, &parent_bytes), ); + let reader = effective_message_author(event, &state.relay_keypair.public_key()); let parent_event = parent_event_result .map_err(|e| format!("db error looking up parent: {e}"))? + .filter(|parent| target_readable_in_channel(parent, channel_id, &reader)) .ok_or_else(|| "reply parent not found".to_string())?; - match parent_event.channel_id { - Some(parent_ch) if parent_ch != channel_id => { - return Err("parent event belongs to a different channel".to_string()); - } - None => return Err("parent event has no channel association".to_string()), - _ => {} - } - let parent_created = chrono::DateTime::from_timestamp(parent_event.event.created_at.as_secs() as i64, 0) .unwrap_or_else(Utc::now); @@ -1130,6 +1147,7 @@ pub(crate) async fn resolve_relay_reply_thread_meta( community_id: CommunityId, parent_hex: &str, channel_id: Uuid, + reader: &[u8], state: &AppState, ) -> Result { let parent_bytes = @@ -1146,16 +1164,9 @@ pub(crate) async fn resolve_relay_reply_thread_meta( let parent_event = parent_event_result .map_err(|e| format!("db error looking up parent: {e}"))? + .filter(|parent| target_readable_in_channel(parent, channel_id, reader)) .ok_or_else(|| "reply parent not found".to_string())?; - match parent_event.channel_id { - Some(parent_ch) if parent_ch != channel_id => { - return Err("parent event belongs to a different channel".to_string()); - } - None => return Err("parent event has no channel association".to_string()), - _ => {} - } - let parent_created = chrono::DateTime::from_timestamp(parent_event.event.created_at.as_secs() as i64, 0) .unwrap_or_else(Utc::now); @@ -1253,6 +1264,10 @@ pub(crate) fn effective_message_author(event: &Event, relay_pubkey: &nostr::Publ event.pubkey.to_bytes().to_vec() } +/// Rejection for an edit whose target is missing or not editable by the +/// sender, so the error never reveals whether, or where, the target exists. +const EDIT_TARGET_DENIED: &str = "edit target not found or not editable by you"; + /// Validate kind:40003 edit ownership — event.pubkey must match target's effective author, /// or the actor must be the owning human of the agent that authored the target message. async fn validate_edit_ownership( @@ -1280,27 +1295,21 @@ async fn validate_edit_ownership( let target_bytes = hex::decode(&target_hex).map_err(|_| "invalid target event ID".to_string())?; + // Kind 40003 skips the generic membership gate, so this validator is the + // only authority: a target outside the edit's `h` channel, unreadable, or + // not the actor's to edit is denied exactly like a missing one. + let edit_channel_id = + extract_channel_id(event).ok_or_else(|| "missing h tag for edit".to_string())?; + let actor = event.pubkey.to_bytes().to_vec(); let target_event = state .db .get_event_by_id_for_event_write(community_id, &target_bytes) .await .map_err(|e| format!("db error: {e}"))? - .ok_or_else(|| "edit target event not found".to_string())?; - - // Verify target belongs to the same channel as the edit event. - let edit_channel_id = extract_channel_id(event); - match (edit_channel_id, target_event.channel_id) { - (Some(edit_ch), Some(target_ch)) if edit_ch != target_ch => { - return Err("target event belongs to a different channel".to_string()); - } - (Some(_), None) => { - return Err("target event has no channel".to_string()); - } - _ => {} // Same channel or no channel context — OK - } + .filter(|target| target_readable_in_channel(target, edit_channel_id, &actor)) + .ok_or_else(|| EDIT_TARGET_DENIED.to_string())?; let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); - let actor = event.pubkey.to_bytes().to_vec(); if author == actor { // Author editing their own message: re-gate on membership/open visibility so that // a removed private-channel member cannot mutate old messages after access is revoked. @@ -1317,7 +1326,7 @@ async fn validate_edit_ownership( .map(|ch| ch.visibility == "open") .unwrap_or(false); if !is_open { - return Err("restricted: not a channel member".to_string()); + return Err(EDIT_TARGET_DENIED.to_string()); } } } @@ -1329,7 +1338,7 @@ async fn validate_edit_ownership( .await .map_err(|e| format!("db error checking agent ownership: {e}"))?; if !is_owner { - return Err("must be event author to edit".to_string()); + return Err(EDIT_TARGET_DENIED.to_string()); } } Ok(()) @@ -1358,6 +1367,9 @@ async fn validate_forum_vote_target( } }) .ok_or_else(|| "missing e tag for vote target".to_string())?; + let vote_channel_id = + extract_channel_id(event).ok_or_else(|| "missing h tag for vote".to_string())?; + let reader = effective_message_author(event, &state.relay_keypair.public_key()); let target_bytes = hex::decode(&target_hex).map_err(|_| "invalid target event ID".to_string())?; @@ -1366,24 +1378,15 @@ async fn validate_forum_vote_target( .get_event_by_id_for_event_write(community_id, &target_bytes) .await .map_err(|e| format!("db error: {e}"))? + // Readability first: the kind diagnostic below would otherwise reveal + // an inaccessible event's existence and kind. + .filter(|target| target_readable_in_channel(target, vote_channel_id, &reader)) .ok_or_else(|| "vote target event not found".to_string())?; let target_kind = event_kind_u32(&target_event.event); if target_kind != KIND_FORUM_POST && target_kind != KIND_FORUM_COMMENT { return Err("vote target must be a forum post or comment".to_string()); } - - // Verify target belongs to the same channel as the vote event. - let vote_channel_id = extract_channel_id(event); - match (vote_channel_id, target_event.channel_id) { - (Some(vote_ch), Some(target_ch)) if vote_ch != target_ch => { - return Err("target event belongs to a different channel".to_string()); - } - (Some(_), None) => { - return Err("target event has no channel".to_string()); - } - _ => {} - } Ok(()) } @@ -2569,7 +2572,7 @@ async fn ingest_event_inner( // report; that is tolerated because reports are non-actioning signals and // remain visible only to moderators. if kind_u32 == KIND_REPORT { - super::report::handle_report_event(tenant, &event, state) + super::report::handle_report_event(tenant, &event, state, auth.channel_ids()) .await .map_err(IngestError::Rejected)?; return Ok(IngestResult { @@ -2614,25 +2617,27 @@ async fn ingest_event_inner( } } - // A kind 5 targeting an event derives its channel from that target, so - // every channel gate below would reveal the target's existence or - // location. Their rejections all collapse to the shared denial. - let deletion_targets_event = kind_u32 == KIND_DELETION - && !crate::handlers::side_effects::extract_target_event_ids(&event).is_empty(); - let deletion_denied = || { - IngestError::Rejected(format!( - "invalid: {}", - crate::handlers::side_effects::DELETION_TARGET_DENIED - )) + // A reaction, or a kind 5 targeting an event, derives its channel from + // that target, so every channel gate below would reveal the target's + // existence or location. Their rejections all collapse to the kind's + // not-found denial. + let target_denial = if kind_u32 == KIND_REACTION { + Some(REACTION_TARGET_NOT_FOUND) + } else if kind_u32 == KIND_DELETION + && !crate::handlers::side_effects::extract_target_event_ids(&event).is_empty() + { + Some(crate::handlers::side_effects::DELETION_TARGET_DENIED) + } else { + None }; + let target_denied = |reason: &str| IngestError::Rejected(format!("invalid: {reason}")); let mut channel_id = if kind_u32 == KIND_REACTION { - match derive_reaction_channel(tenant.community(), &state.db, &event).await { + let reader = effective_message_author(&event, &state.relay_keypair.public_key()); + match derive_reaction_channel(tenant.community(), &state.db, &event, &reader).await { ReactionChannelResult::Channel(ch_id) => Some(ch_id), ReactionChannelResult::NoChannel => None, ReactionChannelResult::NotFound => { - return Err(IngestError::Rejected( - "invalid: reaction target event not found".into(), - )); + return Err(target_denied(REACTION_TARGET_NOT_FOUND)); } ReactionChannelResult::NoTarget => { return Err(IngestError::Rejected( @@ -2663,7 +2668,9 @@ async fn ingest_event_inner( .map_err(|e| { IngestError::Internal(format!("error: looking up deletion target: {e}")) })? - .ok_or_else(deletion_denied)? + .ok_or_else(|| { + target_denied(crate::handlers::side_effects::DELETION_TARGET_DENIED) + })? .channel_id } None => None, @@ -2683,15 +2690,12 @@ async fn ingest_event_inner( } if let Some(ch_id) = channel_id { - check_token_channel_access(&auth, ch_id).map_err(|e| { - if deletion_targets_event { - deletion_denied() - } else { - IngestError::AuthFailed(e) - } + check_token_channel_access(&auth, ch_id).map_err(|e| match target_denial { + Some(reason) => target_denied(reason), + None => IngestError::AuthFailed(e), })?; - } else if deletion_targets_event && auth.channel_ids().is_some() { - return Err(deletion_denied()); + } else if let (Some(reason), Some(_)) = (target_denial, auth.channel_ids()) { + return Err(target_denied(reason)); } else if auth.channel_ids().is_some() { // Channel-scoped tokens cannot publish global events — that would bypass // the token's channel restriction. This covers kind:1 (global text notes), @@ -2774,12 +2778,9 @@ async fn ingest_event_inner( }, state_for_request(tenant, auth.pubkey()), ); - auth_result.map_err(|e| { - if deletion_targets_event { - deletion_denied() - } else { - IngestError::Rejected(e) - } + auth_result.map_err(|e| match target_denial { + Some(reason) => target_denied(reason), + None => IngestError::Rejected(e), })?; } } @@ -2920,12 +2921,13 @@ async fn ingest_event_inner( if kind_u32 == KIND_DELETION { // The target's archived channel blocks the deletion, so it must // reject before the validator's target-kind diagnostics. - if deletion_targets_event - && channel_row + if let Some(reason) = target_denial { + if channel_row .as_ref() .is_some_and(|ch| ch.archived_at.is_some()) - { - return Err(deletion_denied()); + { + return Err(target_denied(reason)); + } } crate::handlers::side_effects::validate_standard_deletion_event(tenant, &event, state) .await @@ -2943,7 +2945,10 @@ async fn ingest_event_inner( if !is_unarchive { if let Some(channel) = &channel_row { if channel.archived_at.is_some() { - return Err(IngestError::Rejected("invalid: channel is archived".into())); + return Err(match target_denial { + Some(reason) => target_denied(reason), + None => IngestError::Rejected("invalid: channel is archived".into()), + }); } } } @@ -3327,9 +3332,9 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Internal(format!("error: {e}")))? { buzz_db::ReactionEventInsertOutcome::TargetMissing => { - return Err(IngestError::Rejected( - "invalid: reaction target event not found".into(), - )); + return Err(IngestError::Rejected(format!( + "invalid: {REACTION_TARGET_NOT_FOUND}" + ))); } buzz_db::ReactionEventInsertOutcome::Duplicate => { return Ok(IngestResult { @@ -6917,6 +6922,174 @@ mod postgres_tests { assert_eq!(stored.channel_id, Some(channels[0])); } + /// A community with `author`'s live message in an open channel, an + /// author global note, and a second open channel for out-of-scope tokens. + struct ScopedTargetFixture { + state: Arc, + tenant: TenantContext, + tracer: Arc, + author: nostr::Keys, + sender: nostr::Keys, + target_channel: Uuid, + other_channel: Uuid, + live: Event, + global: Event, + } + + impl ScopedTargetFixture { + async fn new(label: &str) -> Self { + use buzz_db::channel::{ChannelType, ChannelVisibility}; + let state = ingest_state().await; + let host = format!("{label}-{}.test", Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let tenant = TenantContext::resolved(community, &host); + let tracer: Arc = Arc::new(VecTracer::default()); + let (author, sender) = (nostr::Keys::generate(), nostr::Keys::generate()); + let mut channels = Vec::new(); + for creator in [&author, &sender] { + let channel_id = Uuid::new_v4(); + state + .db + .create_channel_with_id( + community, + channel_id, + &format!("{label}-{}", channel_id.simple()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + creator.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create test channel"); + channels.push(channel_id); + } + let publish = |kind: u16, tags: Vec| { + EventBuilder::new(Kind::Custom(kind), Uuid::new_v4().to_string()) + .tags(tags) + .sign_with_keys(&author) + .expect("sign") + }; + let live = publish( + 9, + vec![nostr::Tag::parse(["h", &channels[0].to_string()]).unwrap()], + ); + let global = publish(1, vec![]); + for event in [live.clone(), global.clone()] { + let auth = IngestAuth::Http { + pubkey: author.public_key(), + scopes: vec![Scope::MessagesWrite], + auth_method: HttpAuthMethod::Nip98, + }; + ingest_event_inner(&state, &tracer, &tenant, event, auth) + .await + .expect("store target"); + } + Self { + state, + tenant, + tracer, + author, + sender, + target_channel: channels[0], + other_channel: channels[1], + live, + global, + } + } + + fn scoped(&self, channel: Uuid) -> IngestAuth { + IngestAuth::Nip42 { + pubkey: self.sender.public_key(), + scopes: vec![Scope::MessagesWrite], + channel_ids: Some(vec![channel]), + conn_id: Uuid::new_v4(), + } + } + + fn sign(&self, kind: u16, tags: Vec>) -> Event { + EventBuilder::new(Kind::Custom(kind), "+") + .tags(tags.into_iter().map(|t| nostr::Tag::parse(t).unwrap())) + .sign_with_keys(&self.sender) + .expect("sign") + } + + async fn submit( + &self, + event: Event, + auth: IngestAuth, + ) -> Result { + ingest_event_inner(&self.state, &self.tracer, &self.tenant, event, auth).await + } + } + + /// A report sent with a token scoped to another channel answers exactly + /// like a report on a missing event. + /// Mutation: drop the `token_channels` check in `reporter_can_read` → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn scoped_token_report_does_not_reveal_target() { + let f = ScopedTargetFixture::new("scoped-report").await; + let author = f.author.public_key().to_hex(); + let report = |target: String| { + f.sign( + KIND_REPORT as u16, + vec![ + vec!["e".into(), target, "spam".into()], + vec!["p".into(), author.clone()], + ], + ) + }; + for target in ["a".repeat(64), f.live.id.to_hex()] { + match f + .submit(report(target.clone()), f.scoped(f.other_channel)) + .await + { + Err(IngestError::Rejected(msg)) => { + assert_eq!(msg, "invalid: report target event not found", "{target}") + } + Err(other) => panic!("target {target}: {other:?}"), + Ok(_) => panic!("target {target}: accepted"), + } + } + f.submit(report(f.live.id.to_hex()), f.scoped(f.target_channel)) + .await + .expect("in-scope token reports the message"); + } + + /// A reaction sent with a token scoped to another channel, to a channel + /// message or to a global note, answers exactly like one to a missing event. + /// Mutation: drop the `(Some(reason), Some(_))` arm, or map the token + /// check to its own error → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn scoped_token_reaction_does_not_reveal_target() { + let f = ScopedTargetFixture::new("scoped-reaction").await; + let react = |target: String| f.sign(KIND_REACTION as u16, vec![vec!["e".into(), target]]); + for target in ["a".repeat(64), f.live.id.to_hex(), f.global.id.to_hex()] { + match f + .submit(react(target.clone()), f.scoped(f.other_channel)) + .await + { + Err(IngestError::Rejected(msg)) => assert_eq!( + msg, + format!("invalid: {REACTION_TARGET_NOT_FOUND}"), + "{target}" + ), + Err(other) => panic!("target {target}: {other:?}"), + Ok(_) => panic!("target {target}: accepted"), + } + } + f.submit(react(f.live.id.to_hex()), f.scoped(f.target_channel)) + .await + .expect("in-scope token reacts to the message"); + } + // ── Owner-aware ban/timeout coverage ───────────────────────────────────── /// Fresh community plus an agent owned by `owner` (users.agent_owner_pubkey). diff --git a/crates/buzz-relay/src/handlers/report.rs b/crates/buzz-relay/src/handlers/report.rs index fccf8eb42b5..2e9e1aa4d78 100644 --- a/crates/buzz-relay/src/handlers/report.rs +++ b/crates/buzz-relay/src/handlers/report.rs @@ -20,8 +20,10 @@ use std::sync::Arc; use buzz_core::tenant::TenantContext; +use buzz_core::StoredEvent; use buzz_db::moderation::{NewReport, ReportTarget}; use nostr::Event; +use uuid::Uuid; use crate::state::AppState; @@ -41,10 +43,13 @@ pub const REPORT_TYPES: &[&str] = &[ /// Rejections use client-safe `invalid:`/`restricted:` reasons. On success /// the report is queued (idempotently, keyed by the signed event id) and the /// event itself is **not** stored or broadcast as a regular event. +/// +/// `token_channels` is the sender's channel-scoped token restriction, if any. pub async fn handle_report_event( tenant: &TenantContext, event: &Event, state: &Arc, + token_channels: Option<&[Uuid]>, ) -> Result<(), String> { let parsed = parse_report(event)?; let reporter_pubkey = event.pubkey.to_bytes(); @@ -55,9 +60,20 @@ pub async fn handle_report_event( .db .get_event_by_id(tenant.community(), &event_id) .await - .map_err(|e| format!("error: database error resolving report target: {e}"))? - .ok_or_else(|| "invalid: report target event not found".to_string())?; - (ReportTarget::Event(event_id), stored.channel_id) + .map_err(|e| format!("error: database error resolving report target: {e}"))?; + // A target the reporter cannot read is rejected exactly like a + // missing one, so the response never reveals whether it exists. + let readable = match &stored { + Some(stored) => { + reporter_can_read(tenant, state, stored, &reporter_pubkey, token_channels) + .await? + } + None => false, + }; + match stored { + Some(stored) if readable => (ReportTarget::Event(event_id), stored.channel_id), + _ => return Err("invalid: report target event not found".to_string()), + } } ParsedReportTarget::Blob { sha256, .. } => { let sha_hex = hex::encode(&sha256); @@ -93,6 +109,32 @@ pub async fn handle_report_event( Ok(()) } +/// Whether `reporter` may read `target`: the event-level read gate, plus the +/// channel's membership-or-open rule and the token's channel scope for a +/// channel event. +async fn reporter_can_read( + tenant: &TenantContext, + state: &AppState, + target: &StoredEvent, + reporter: &[u8], + token_channels: Option<&[Uuid]>, +) -> Result { + if !super::req::event_visible_to_reader(&target.event, reporter) { + return Ok(false); + } + let Some(channel_id) = target.channel_id else { + return Ok(true); + }; + if token_channels.is_some_and(|allowed| !allowed.contains(&channel_id)) { + return Ok(false); + } + match super::ingest::check_channel_membership(tenant, state, channel_id, reporter, None).await { + Ok(()) => Ok(true), + Err(reason) if reason.starts_with("error:") => Err(reason), + Err(_) => Ok(false), + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct ParsedReport<'a> { target: ParsedReportTarget, diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index e069d7a37dd..e757b49d80c 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -320,6 +320,7 @@ impl ActionSink for RelayActionSink { tenant.community(), parent_hex, channel_uuid, + &author_pubkey_bytes, &state, ) .await @@ -1401,4 +1402,124 @@ mod postgres_tests { "expected InvalidInput, got {err:?}" ); } + + /// A workflow reply to a parent the owner cannot use, in another channel + /// or unreadable to them, fails exactly like a reply to a missing parent. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_reply_to_unusable_parent_matches_missing() { + let state = test_state().await; + let owner = nostr::Keys::generate(); + let owner_hex = owner.public_key().to_hex(); + let stranger = nostr::Keys::generate(); + let host = format!("wf-unusable-{}.example", uuid::Uuid::new_v4().simple()); + let community = match state + .db + .create_community_with_owner(&host, &owner_hex) + .await + .expect("create community") + { + CreateCommunityWithOwnerResult::Created(rec) => rec.id, + other => panic!("expected fresh community, got {other:?}"), + }; + let open_channel = |name: &'static str| { + let state = Arc::clone(&state); + let owner = owner.public_key().to_bytes(); + async move { + state + .db + .create_channel( + community, + name, + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner, + None, + ) + .await + .expect("create channel") + } + }; + let channel = open_channel("wf-unusable").await; + let elsewhere = open_channel("wf-elsewhere").await; + let insert = |keys: &nostr::Keys, kind: u32, channel_id: Uuid, extra: Vec| { + let state = Arc::clone(&state); + let mut tags = vec![Tag::parse(["h", &channel_id.to_string()]).expect("h tag")]; + tags.extend(extra); + let event = EventBuilder::new(Kind::from(kind as u16), "parent") + .tags(tags) + .sign_with_keys(keys) + .expect("sign parent"); + async move { + state + .db + .insert_event(community, &event, Some(channel_id)) + .await + .expect("insert parent"); + event.id.to_hex() + } + }; + // A kind 5 whose `k` tag names an author-only kind is readable only by + // its author. Production ingest never stores one channel-scoped (its + // target is global), so this is the closest state that isolates the + // resolver's visibility check and the reader identity it is given. + let author_only_deletion = |keys: &nostr::Keys| { + let target = nostr::Keys::generate().public_key().to_hex(); + insert( + keys, + buzz_core::kind::KIND_DELETION, + channel.id, + vec![ + Tag::parse(["e", &target]).expect("e tag"), + Tag::parse(["k", "30300"]).expect("k tag"), + ], + ) + }; + let other_channel = insert(&owner, KIND_STREAM_MESSAGE, elsewhere.id, vec![]).await; + let unreadable = author_only_deletion(&stranger).await; + let owner_only = author_only_deletion(&owner).await; + + let sink = RelayActionSink::new(&state); + let reply = |author_hex: String, parent: String| { + let sink = &sink; + let channel = channel.id.to_string(); + async move { + sink.send_message( + community, + &channel, + "reply", + "reply", + &author_hex, + Some(&parent), + ) + .await + } + }; + let missing = nostr::Keys::generate().public_key().to_hex(); + let other_owner_hex = nostr::Keys::generate().public_key().to_hex(); + for (case, author, parent) in [ + ("missing", &owner_hex, missing), + ("other channel", &owner_hex, other_channel), + ("unreadable", &owner_hex, unreadable), + ( + "readable only by another owner", + &other_owner_hex, + owner_only.clone(), + ), + ] { + match reply(author.clone(), parent).await { + Err(ActionSinkError::InvalidInput(msg)) => { + assert_eq!(msg, "reply parent not found", "{case}") + } + other => panic!("{case}: expected the missing-parent error, got {other:?}"), + } + } + + // Positive control: the resolver reads as the workflow owner, who + // alone can see their author-only parent. + reply(owner_hex.clone(), owner_only) + .await + .expect("owner replies to a parent only they can read"); + } } diff --git a/crates/buzz-test-client/tests/e2e_existence_oracle.rs b/crates/buzz-test-client/tests/e2e_existence_oracle.rs new file mode 100644 index 00000000000..762d6fe4724 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_existence_oracle.rs @@ -0,0 +1,823 @@ +//! End-to-end tests: a write that references an event, artifact or workflow +//! the sender cannot use or read gets exactly the response it would get for +//! an ID that does not exist. +//! +//! Each test sends the same write as an unauthorized sender against targets +//! that are missing, soft-deleted, in another channel, in a private channel +//! the sender has not joined, author-only, or of the wrong kind, over HTTP and +//! WS, and requires one identical HTTP status, HTTP error and WS `OK` +//! message. A positive control shows the legitimate write still succeeds. +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! RELAY_URL=ws://localhost:3001 cargo test -p buzz-test-client --test e2e_existence_oracle -- --ignored +//! ``` + +use std::time::Duration; + +use buzz_test_client::BuzzTestClient; +use nostr::{EventBuilder, EventId, Keys, Kind, Tag}; +use reqwest::Client; +use serde_json::Value; + +const KIND_EVENT_REMINDER: u16 = 30300; +const MISSING: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string()) +} + +fn relay_http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +fn http_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("failed to build HTTP client") +} + +fn tag(parts: &[&str]) -> Tag { + Tag::parse(parts.to_vec()).expect("valid tag") +} + +fn sign(keys: &Keys, kind: u16, content: &str, tags: Vec) -> nostr::Event { + EventBuilder::new(Kind::Custom(kind), content) + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + +async fn submit(client: &Client, keys: &Keys, event: &nostr::Event) -> (bool, String) { + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", keys.public_key().to_hex()) + .json(event) + .send() + .await + .expect("submit event"); + let ok = resp.status().is_success(); + let body: Value = resp.json().await.expect("parse response"); + if ok { + ( + body["accepted"].as_bool().unwrap_or(false), + body["message"].as_str().unwrap_or("").to_string(), + ) + } else { + (false, body["error"].as_str().unwrap_or("").to_string()) + } +} + +async fn submit_ok(client: &Client, keys: &Keys, event: &nostr::Event) { + let (accepted, msg) = submit(client, keys, event).await; + assert!(accepted, "setup event rejected: {msg}"); +} + +/// Submit `event` as `sender` over HTTP and WS and return how each rejected +/// it: the HTTP status and error, and the WS `OK` message. +async fn rejection(client: &Client, sender: &Keys, event: nostr::Event) -> (u16, String, String) { + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", sender.public_key().to_hex()) + .json(&event) + .send() + .await + .expect("submit event"); + let status = resp.status().as_u16(); + let body: Value = resp.json().await.expect("parse response"); + let http_msg = body["error"] + .as_str() + .or_else(|| body["message"].as_str()) + .unwrap_or("") + .to_string(); + let mut ws = BuzzTestClient::connect(&relay_url(), sender) + .await + .expect("connect"); + let ok = ws.send_event(event).await.expect("OK"); + ws.disconnect().await.expect("disconnect"); + assert!(!ok.accepted, "WS must reject the event"); + (status, http_msg, ok.message) +} + +/// Every case must be rejected over HTTP and WS with `expected`, and with +/// one HTTP status across all cases. +async fn assert_uniform( + client: &Client, + sender: &Keys, + expected: &str, + cases: Vec<(&str, nostr::Event)>, +) { + let mut status_seen = None; + let mut mismatches = Vec::new(); + for (case, event) in cases { + let (status, http_msg, ws_msg) = rejection(client, sender, event).await; + if status < 400 && http_msg.is_empty() { + mismatches.push(format!("{case}: HTTP accepted")); + } + if http_msg != expected { + mismatches.push(format!("{case}: HTTP {http_msg:?}")); + } + if ws_msg != expected { + mismatches.push(format!("{case}: WS {ws_msg:?}")); + } + if *status_seen.get_or_insert(status) != status { + mismatches.push(format!("{case}: HTTP status {status}")); + } + } + assert!( + mismatches.is_empty(), + "expected {expected:?}:\n{}", + mismatches.join("\n") + ); +} + +async fn create_channel(client: &Client, owner: &Keys, visibility: &str) -> String { + let id = uuid::Uuid::new_v4().to_string(); + let event = sign( + owner, + 9007, + "", + vec![ + tag(&["h", &id]), + tag(&["name", &format!("eo-{id}")]), + tag(&["channel_type", "stream"]), + tag(&["visibility", visibility]), + ], + ); + submit_ok(client, owner, &event).await; + id +} + +async fn add_member(client: &Client, owner: &Keys, channel: &str, member: &Keys) { + let event = sign( + owner, + 9000, + "", + vec![ + tag(&["h", channel]), + tag(&["p", &member.public_key().to_hex()]), + ], + ); + submit_ok(client, owner, &event).await; +} + +async fn post(client: &Client, author: &Keys, kind: u16, channel: &str) -> nostr::Event { + let event = sign( + author, + kind, + &uuid::Uuid::new_v4().to_string(), + vec![tag(&["h", channel])], + ); + submit_ok(client, author, &event).await; + event +} + +async fn delete(client: &Client, author: &Keys, target: &nostr::Event) { + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags(vec![tag(&["e", &target.id.to_hex()])]) + .sign_with_keys(author) + .unwrap(); + submit_ok(client, author, &deletion).await; +} + +/// The author's targets in every place an outsider cannot use them. +struct Targets { + /// An open channel the outsider has not joined. + open: String, + live: nostr::Event, + deleted: nostr::Event, + hidden: nostr::Event, + reminder: nostr::Event, +} + +async fn targets(client: &Client, author: &Keys, kind: u16) -> Targets { + let open = create_channel(client, author, "open").await; + let private = create_channel(client, author, "private").await; + let live = post(client, author, kind, &open).await; + let deleted = post(client, author, kind, &open).await; + delete(client, author, &deleted).await; + let hidden = post(client, author, kind, &private).await; + let reminder = sign( + author, + KIND_EVENT_REMINDER, + "nip44-ciphertext-placeholder", + vec![ + tag(&["d", &uuid::Uuid::new_v4().to_string()]), + tag(&["alt", "Encrypted reminder"]), + ], + ); + submit_ok(client, author, &reminder).await; + Targets { + open, + live, + deleted, + hidden, + reminder, + } +} + +fn hex(event: &nostr::Event) -> String { + event.id.to_hex() +} + +/// Reactions derive their channel from the target, so a target the reactor +/// cannot use, including someone else's author-only event, is rejected as +/// missing. Reacting to another user's author-only event used to be stored. +#[tokio::test] +#[ignore] +async fn reaction_does_not_reveal_target() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let t = targets(&client, &author, 9).await; + // A live message the reactor could read, in an archived channel they belong to. + let archived = create_channel(&client, &author, "open").await; + add_member(&client, &author, &archived, &other).await; + let in_archive = post(&client, &author, 9, &archived).await; + let archive = sign( + &author, + 9002, + "", + vec![tag(&["h", &archived]), tag(&["archived", "true"])], + ); + submit_ok(&client, &author, &archive).await; + let react = |target: &str| sign(&other, 7, "+", vec![tag(&["e", target])]); + assert_uniform( + &client, + &other, + "invalid: reaction target event not found", + vec![ + ("missing", react(MISSING)), + ("soft-deleted", react(&hex(&t.deleted))), + ("private channel", react(&hex(&t.hidden))), + ("author-only", react(&hex(&t.reminder))), + ("archived channel", react(&hex(&in_archive))), + ], + ) + .await; + + // Positive controls: the author reacts to their reminder, and a member + // reacts in the open channel. + submit_ok( + &client, + &author, + &sign(&author, 7, "+", vec![tag(&["e", &hex(&t.reminder)])]), + ) + .await; + add_member(&client, &author, &t.open, &other).await; + submit_ok(&client, &other, &react(&hex(&t.live))).await; +} + +/// An edit of a target that is not the sender's, or not in the edit's +/// channel, or unreadable, is rejected exactly like a missing target. +#[tokio::test] +#[ignore] +async fn edit_does_not_reveal_target() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let t = targets(&client, &author, 9).await; + let own = create_channel(&client, &other, "open").await; + add_member(&client, &other, &own, &author).await; + let not_yours = post(&client, &author, 9, &own).await; + let edit = |target: &str| { + sign( + &other, + 40003, + "edited", + vec![tag(&["e", target]), tag(&["h", &own])], + ) + }; + assert_uniform( + &client, + &other, + "invalid: edit target not found or not editable by you", + vec![ + ("missing", edit(MISSING)), + ("soft-deleted", edit(&hex(&t.deleted))), + ("other channel", edit(&hex(&t.live))), + ("private channel", edit(&hex(&t.hidden))), + ("no channel / author-only", edit(&hex(&t.reminder))), + ("not yours", edit(&hex(¬_yours))), + ], + ) + .await; + + // Positive control: the sender edits their own message. + let mine = post(&client, &other, 9, &own).await; + submit_ok(&client, &other, &edit(&hex(&mine))).await; +} + +/// An author removed from a private channel cannot tell whether their old +/// message still exists: editing it is rejected exactly like editing a +/// missing or soft-deleted one. +#[tokio::test] +#[ignore] +async fn removed_author_edit_does_not_reveal_target() { + let client = http_client(); + let owner = Keys::generate(); + let removed = Keys::generate(); + let private = create_channel(&client, &owner, "private").await; + add_member(&client, &owner, &private, &removed).await; + let kept = post(&client, &removed, 9, &private).await; + let deleted = post(&client, &removed, 9, &private).await; + delete(&client, &removed, &deleted).await; + let remove = sign( + &owner, + 9001, + "", + vec![ + tag(&["h", &private]), + tag(&["p", &removed.public_key().to_hex()]), + ], + ); + submit_ok(&client, &owner, &remove).await; + let edit = |target: &str| { + sign( + &removed, + 40003, + "edited", + vec![tag(&["e", target]), tag(&["h", &private])], + ) + }; + assert_uniform( + &client, + &removed, + "invalid: edit target not found or not editable by you", + vec![ + ("missing", edit(MISSING)), + ("soft-deleted", edit(&hex(&deleted))), + ("existing, author removed", edit(&hex(&kept))), + ], + ) + .await; +} + +/// A vote's channel and readability check runs before its kind check, so an +/// unusable target never reveals its kind. +#[tokio::test] +#[ignore] +async fn forum_vote_does_not_reveal_target() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let t = targets(&client, &author, 45001).await; + let wrong_kind = post(&client, &author, 9, &t.open).await; + let own = create_channel(&client, &other, "open").await; + let vote = |target: &str| { + sign( + &other, + 45002, + "+", + vec![tag(&["e", target]), tag(&["h", &own])], + ) + }; + assert_uniform( + &client, + &other, + "invalid: vote target event not found", + vec![ + ("missing", vote(MISSING)), + ("soft-deleted", vote(&hex(&t.deleted))), + ("other channel", vote(&hex(&t.live))), + ("private channel", vote(&hex(&t.hidden))), + ("author-only", vote(&hex(&t.reminder))), + ("other channel, wrong kind", vote(&hex(&wrong_kind))), + ], + ) + .await; + + // Positive control: a vote on a forum post in the vote's channel. + let mine = post(&client, &other, 45001, &own).await; + submit_ok(&client, &other, &vote(&hex(&mine))).await; +} + +/// A report on an event the reporter cannot read is rejected exactly like a +/// report on a missing event. Such reports used to be queued. +#[tokio::test] +#[ignore] +async fn report_does_not_reveal_target() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let t = targets(&client, &author, 9).await; + let report = |target: &str| { + sign( + &other, + 1984, + "", + vec![ + tag(&["e", target, "spam"]), + tag(&["p", &author.public_key().to_hex()]), + ], + ) + }; + assert_uniform( + &client, + &other, + "invalid: report target event not found", + vec![ + ("missing", report(MISSING)), + ("soft-deleted", report(&hex(&t.deleted))), + ("private channel", report(&hex(&t.hidden))), + ("author-only", report(&hex(&t.reminder))), + ], + ) + .await; + + // Positive controls: a readable event, and a report by pubkey. + submit_ok(&client, &other, &report(&hex(&t.live))).await; + let by_pubkey = sign( + &other, + 1984, + "", + vec![tag(&["p", &author.public_key().to_hex(), "spam"])], + ); + submit_ok(&client, &other, &by_pubkey).await; +} + +/// A reply whose parent is outside the reply's channel or unreadable is +/// rejected exactly like a reply to a missing parent. +#[tokio::test] +#[ignore] +async fn reply_does_not_reveal_parent() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let t = targets(&client, &author, 9).await; + let own = create_channel(&client, &other, "open").await; + let reply = |parent: &str| { + sign( + &other, + 9, + "reply", + vec![tag(&["e", parent, "", "reply"]), tag(&["h", &own])], + ) + }; + let (_, expected, _) = rejection(&client, &other, reply(MISSING)).await; + assert!( + expected.contains("reply parent not found"), + "missing parent: {expected:?}" + ); + assert_uniform( + &client, + &other, + &expected, + vec![ + ("missing", reply(MISSING)), + ("soft-deleted", reply(&hex(&t.deleted))), + ("other channel", reply(&hex(&t.live))), + ("private channel", reply(&hex(&t.hidden))), + ("no channel / author-only", reply(&hex(&t.reminder))), + ], + ) + .await; + + // Positive control: a reply in the parent's channel. + let mine = post(&client, &other, 9, &own).await; + submit_ok(&client, &other, &reply(&hex(&mine))).await; +} + +fn artifact_revision( + author: &Keys, + artifact: &str, + channel: &str, + op: &str, + kind_type: &str, + prev: Option<&EventId>, +) -> nostr::Event { + let mut tags = vec![ + tag(&["ar", "1"]), + tag(&["d", artifact]), + tag(&["h", channel]), + tag(&["type", kind_type]), + tag(&["op", op]), + ]; + if op != "delete" { + tags.push(tag(&["title", "Task"])); + } + tags.extend(prev.map(|p| tag(&["prev", &p.to_hex()]))); + sign(author, 45010, "", tags) +} + +fn d_tag(event: &nostr::Event) -> String { + event + .tags + .iter() + .find_map(|t| { + let s = t.as_slice(); + (s.first().map(String::as_str) == Some("d")).then(|| s[1].clone()) + }) + .expect("d tag") +} + +/// Any artifact head outside a channel the sender can write answers +/// `head unavailable`, before the revision, type and deleted checks. +#[tokio::test] +#[ignore] +async fn artifact_revision_does_not_reveal_head() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let open = create_channel(&client, &author, "open").await; + let private = create_channel(&client, &author, "private").await; + let own = create_channel(&client, &other, "open").await; + let create = |channel: &str| { + artifact_revision( + &author, + &uuid::Uuid::new_v4().to_string(), + channel, + "create", + "buzz.task", + None, + ) + }; + let live = create(&open); + submit_ok(&client, &author, &live).await; + let hidden = create(&private); + submit_ok(&client, &author, &hidden).await; + let gone = create(&open); + submit_ok(&client, &author, &gone).await; + let gone_delete = artifact_revision( + &author, + &d_tag(&gone), + &open, + "delete", + "buzz.task", + Some(&gone.id), + ); + submit_ok(&client, &author, &gone_delete).await; + + let rev = |head: &nostr::Event, channel: &str, op: &str, ty: &str, prev: &EventId| { + artifact_revision(&other, &d_tag(head), channel, op, ty, Some(prev)) + }; + let missing = artifact_revision( + &other, + &uuid::Uuid::new_v4().to_string(), + &own, + "update", + "buzz.task", + Some(&live.id), + ); + assert_uniform( + &client, + &other, + "conflict: artifact head unavailable", + vec![ + ("missing", missing), + ( + "other channel, current prev", + rev(&live, &own, "update", "buzz.task", &live.id), + ), + ( + "other channel, stale prev", + rev(&live, &own, "update", "buzz.task", &hidden.id), + ), + ( + "other channel, wrong type", + rev(&live, &own, "update", "buzz.note", &live.id), + ), + ( + "other channel, delete", + rev(&live, &own, "delete", "buzz.task", &live.id), + ), + ( + "private channel, update", + rev(&hidden, &own, "update", "buzz.task", &hidden.id), + ), + ( + "private channel, move", + rev(&hidden, &own, "move", "buzz.task", &hidden.id), + ), + ( + "deleted, restore", + rev(&gone, &own, "restore", "buzz.task", &gone_delete.id), + ), + ( + "deleted, update", + rev(&gone, &own, "update", "buzz.task", &gone_delete.id), + ), + ], + ) + .await; + + // Positive control: the sender revises an artifact in their channel. + let mine = artifact_revision( + &other, + &uuid::Uuid::new_v4().to_string(), + &own, + "create", + "buzz.task", + None, + ); + submit_ok(&client, &other, &mine).await; + submit_ok( + &client, + &other, + &rev(&mine, &own, "update", "buzz.task", &mine.id), + ) + .await; +} + +/// Triggering someone else's workflow is rejected exactly like triggering a +/// workflow that does not exist. +#[tokio::test] +#[ignore] +async fn workflow_trigger_does_not_reveal_workflow() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let channel = create_channel(&client, &author, "open").await; + add_member(&client, &author, &channel, &other).await; + let define = |id: &str| { + sign( + &author, + 30620, + "name: existence-oracle\ntrigger:\n on: message_posted\nsteps:\n - id: pause\n action: delay\n duration: 1s\n", + vec![tag(&["d", id]), tag(&["h", &channel])], + ) + }; + let live = uuid::Uuid::new_v4().to_string(); + submit_ok(&client, &author, &define(&live)).await; + let deleted = uuid::Uuid::new_v4().to_string(); + submit_ok(&client, &author, &define(&deleted)).await; + let coordinate = format!("30620:{}:{deleted}", author.public_key().to_hex()); + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags(vec![tag(&["a", &coordinate])]) + .sign_with_keys(&author) + .unwrap(); + submit_ok(&client, &author, &deletion).await; + + let trigger = |keys: &Keys, id: &str| sign(keys, 46020, "{}", vec![tag(&["d", id])]); + assert_uniform( + &client, + &other, + "invalid: workflow not found or not triggerable by you", + vec![ + ( + "missing", + trigger(&other, &uuid::Uuid::new_v4().to_string()), + ), + ("soft-deleted", trigger(&other, &deleted)), + ("someone else's", trigger(&other, &live)), + ], + ) + .await; + + // Positive control: the owner triggers their workflow. + submit_ok(&client, &author, &trigger(&author, &live)).await; +} + +/// GET `path` as `reader` and return the HTTP status and parsed body. +async fn read_as(client: &Client, reader: &Keys, path: &str) -> (u16, Value) { + let resp = client + .get(format!("{}{path}", relay_http_url())) + .header("X-Pubkey", reader.public_key().to_hex()) + .send() + .await + .expect("read workflow"); + let status = resp.status().as_u16(); + (status, resp.json().await.expect("parse response")) +} + +/// Reading the runs or approvals of a workflow in a channel the caller cannot +/// read answers exactly like reading a workflow that does not exist. +#[tokio::test] +#[ignore] +async fn workflow_read_does_not_reveal_workflow() { + let client = http_client(); + let owner = Keys::generate(); + let outsider = Keys::generate(); + let channel = create_channel(&client, &owner, "private").await; + let live = uuid::Uuid::new_v4().to_string(); + let secret = save_webhook_workflow(&client, &owner, &channel, &live).await; + let run = start_webhook_run(&client, &live, &secret).await; + let missing = uuid::Uuid::new_v4().to_string(); + let paths = |id: &str| { + [ + format!("/workflows/{id}/runs"), + format!("/workflows/{id}/runs/{run}/approvals"), + ] + }; + + for (missing_path, live_path) in paths(&missing).iter().zip(paths(&live).iter()) { + let expected = read_as(&client, &outsider, missing_path).await; + assert_eq!(expected.0, 404, "{missing_path}: {:?}", expected.1); + assert_eq!( + read_as(&client, &outsider, live_path).await, + expected, + "{live_path} must answer like a missing workflow" + ); + } + + // Positive control: the owner reads their workflow's run and its approvals. + let [runs_path, approvals_path] = paths(&live); + let (status, body) = read_as(&client, &owner, &runs_path).await; + assert_eq!(status, 200, "owner runs read failed: {body}"); + assert!( + body["runs"] + .as_array() + .is_some_and(|runs| runs.iter().any(|r| r["id"] == run.as_str())), + "owner runs must include {run}: {body}" + ); + let (status, body) = read_as(&client, &owner, &approvals_path).await; + assert_eq!(status, 200, "owner approvals read failed: {body}"); + assert!(body["approvals"].is_array()); +} + +/// Save a webhook-triggered workflow `id` in `channel` and return its secret. +async fn save_webhook_workflow(client: &Client, owner: &Keys, channel: &str, id: &str) -> String { + let definition = sign( + owner, + 30620, + "name: existence-oracle\ntrigger:\n on: webhook\nsteps:\n - id: pause\n action: delay\n duration: 1s\n", + vec![tag(&["d", id]), tag(&["h", channel])], + ); + let (accepted, message) = submit(client, owner, &definition).await; + assert!(accepted, "webhook workflow rejected: {message}"); + let response: Value = serde_json::from_str( + message + .strip_prefix("response:") + .expect("workflow save response"), + ) + .expect("parse workflow save response"); + response["webhook_secret"] + .as_str() + .expect("webhook secret") + .to_string() +} + +/// Call webhook `id` with the right secret, assert it starts a run exactly as +/// documented, and return the run id. +async fn start_webhook_run(client: &Client, id: &str, secret: &str) -> String { + let (status, body) = call_webhook(client, id, Some(secret)).await; + assert_eq!(status, 202, "valid webhook call failed: {body}"); + body["run_id"] + .as_str() + .and_then(|run| uuid::Uuid::parse_str(run).ok()) + .unwrap_or_else(|| panic!("webhook response lacks a run_id: {body}")) + .to_string() +} + +/// POST the public webhook endpoint for `id` with an optional secret header and +/// return the HTTP status and parsed body. +async fn call_webhook(client: &Client, id: &str, secret: Option<&str>) -> (u16, Value) { + let mut request = client.post(format!("{}/hooks/{id}", relay_http_url())); + if let Some(secret) = secret { + request = request.header("x-webhook-secret", secret); + } + let resp = request.send().await.expect("call webhook"); + let status = resp.status().as_u16(); + (status, resp.json().await.expect("parse response")) +} + +/// Until a caller proves the webhook secret, the unauthenticated webhook +/// endpoint answers every workflow exactly like a missing one, so it never +/// reveals that a workflow exists or what its trigger is. +#[tokio::test] +#[ignore] +async fn webhook_does_not_reveal_workflow() { + let client = http_client(); + let owner = Keys::generate(); + let channel = create_channel(&client, &owner, "open").await; + let webhook = uuid::Uuid::new_v4().to_string(); + let secret = save_webhook_workflow(&client, &owner, &channel, &webhook).await; + let other_trigger = uuid::Uuid::new_v4().to_string(); + let message_posted = sign( + &owner, + 30620, + "name: existence-oracle\ntrigger:\n on: message_posted\nsteps:\n - id: pause\n action: delay\n duration: 1s\n", + vec![tag(&["d", &other_trigger]), tag(&["h", &channel])], + ); + submit_ok(&client, &owner, &message_posted).await; + + let missing = uuid::Uuid::new_v4().to_string(); + let expected = call_webhook(&client, &missing, Some(&secret)).await; + assert_eq!(expected.0, 404, "missing workflow: {:?}", expected.1); + for (case, id, provided) in [ + ( + "non-webhook workflow", + &other_trigger, + Some(secret.as_str()), + ), + ("absent secret", &webhook, None), + ("wrong secret", &webhook, Some("wrong-secret")), + ] { + assert_eq!( + call_webhook(&client, id, provided).await, + expected, + "{case} must answer like a missing workflow" + ); + } + + // Positive control: the right secret starts a run. + start_webhook_run(&client, &webhook, &secret).await; +} From 2f79374f4867035c33dace91c683440e7e011826 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 14 Oct 2026 17:25:46 -0400 Subject: [PATCH 013/144] feat(relay): channel labels (#8175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary A plugin or agent often needs to tag its own channels and then find them again, for example "my attention-config channel" or "all channels of this workspace". Today a channel has no labels: the relay ignores any `t` tags in create or edit requests. This PR adds channel labels. - A create request can carry up to 8 labels as `["t", "