From 8e7b275cb07fc1abea55b34e8db90136d039bd08 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 3 Sep 2026 17:32:10 -0400 Subject: [PATCH 001/283] fix(scripts): copy global-agent-config.json in buzz-adopt-prod-agents (#7303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Extend `scripts/buzz-adopt-prod-agents.sh` to copy `agents/global-agent-config.json` from the prod app-data store to dev as part of the existing atomic bundle commit, and document what the script cannot copy (baked build-time defaults). ## Why `global-agent-config.json` holds the owner's global agent defaults (`env_vars`, `provider`, `model`, `preferred_runtime`). Previously the script carried the dev copy across verbatim — it was absent from the bundle drop list and not in `RECORD_FILES`. After a `just reset`, the dev build inherited stale global defaults (or none at all) instead of mirroring prod, inconsistent with how all other bundle files are handled. The "WHAT THIS DOES NOT DO" header lacked any mention of baked build-time defaults, which are a separate (and common) source of prod/dev parity confusion. ## Changes - **`RECORD_FILES`** — add `global-agent-config.json`. The file has no volatile fields (`GlobalAgentConfig` struct = `env_vars`/`provider`/`model`/`preferred_runtime`), so it joins the verbatim-copy path already used by `teams.json` — no new transform. - **Stage-seed block** — add `global-agent-config.json` to the `rm -rf` drop list so the prod overlay replaces it cleanly; remove its stale mention in the "carried verbatim" comment. - **Docs/comments** — update "4-file bundle" → "5-file bundle" in the DMG concurrent-run warning and the step 2 comment; list the file explicitly in the step 2 header comment. - **"WHAT THIS DOES NOT DO" header** — add a bullet explaining that baked build-time agent defaults (`BUZZ_BUILD_BUZZ_AGENT_PROVIDER` / `BUZZ_BUILD_AGENT_ENV` via `option_env!` in `build.rs`) are compiled into the binary, not stored in app-data. Includes the correct `just production` invocation pattern and a `.env` warning. - **Preflight symlink checks and dry-run output** iterate `RECORD_FILES` and pick up the new entry automatically — no other changes needed. Verified: `bash -n` clean, `shellcheck` clean, `--dry-run --force` shows `[dry-run] stage global-agent-config.json (0600)` in the expected position. --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- scripts/buzz-adopt-prod-agents.sh | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/scripts/buzz-adopt-prod-agents.sh b/scripts/buzz-adopt-prod-agents.sh index 01e02022e03..3e2ba59c3f7 100755 --- a/scripts/buzz-adopt-prod-agents.sh +++ b/scripts/buzz-adopt-prod-agents.sh @@ -34,6 +34,17 @@ # the post-reset empty-dev-keyring state) with the crash-safe # marker-before-delete ordering already shipped. The plaintext file exists # only until that first boot consumes it. +# - It does NOT copy baked build-time agent defaults. The prod DMG bakes a +# provider and default env vars at compile time via `option_env!` in +# `build.rs` (driven by `BUZZ_BUILD_BUZZ_AGENT_PROVIDER` and +# `BUZZ_BUILD_AGENT_ENV`). These are compiled into the binary, not stored in +# app-data — there is no file here to copy. Dev parity means setting those +# env vars when invoking the dev build: +# export BUZZ_BUILD_BUZZ_AGENT_PROVIDER="databricks_v2" +# export BUZZ_BUILD_AGENT_ENV=$'DATABRICKS_HOST=...\nDATABRICKS_MODEL=...' +# just production +# Do NOT add these to .env — `set dotenv-load := true` in the Justfile +# would bake them into every recipe in that checkout, including test runs. # # FLAGS # --dry-run Print every action; write nothing. @@ -70,7 +81,7 @@ KEYCHAIN_ACCT="${BUZZ_KEYCHAIN_ACCT:-secrets}" DRY_RUN=0 FORCE=0 -RECORD_FILES=(managed-agents.json personas.json teams.json) +RECORD_FILES=(managed-agents.json personas.json teams.json global-agent-config.json) # --- helpers ---------------------------------------------------------------- say() { printf '%s\n' "$*"; } @@ -251,7 +262,7 @@ if [[ ${#dev_blocking[@]} -gt 0 ]]; then fi if [[ $dmg_running -eq 1 ]]; then info "installed DMG is running — allowed (read-only source)" - warn "Do NOT create/archive agents or edit teams in the DMG while this runs; the 4-file bundle is read as one snapshot." + warn "Do NOT create/archive agents or edit teams in the DMG while this runs; the 5-file bundle is read as one snapshot." else info "no running Buzz process detected" fi @@ -326,8 +337,9 @@ if [[ $DRY_RUN -eq 0 && -d "$DEV_DIR/agents" ]] && ! have python3; then fi # --- step 2: restore records (exact bundle, one atomic commit) -------------- -# The four boot-critical paths (managed-agents.json, personas.json, teams.json, -# teams/) are read as one related state, so the commit must be all-or-nothing. +# The five boot-critical paths (managed-agents.json, personas.json, teams.json, +# global-agent-config.json, teams/) are read as one related state, so the +# commit must be all-or-nothing. # Phase 1 builds a COMPLETE replacement agents/ in a staging dir beside the live # one — the prod bundle plus any unrelated live entries carried across verbatim, # every transform validated, zero live-path mutation. Phase 2 commits it in ONE @@ -372,16 +384,17 @@ else STAGE_TEMPS+=("$stage_agents") # Seed the stage with the entire live agents/ (a single checked copy — under - # `set -e` a partial copy aborts before any commit), then drop the four bundle + # `set -e` a partial copy aborts before any commit), then drop the five bundle # names FROM THE STAGE so the prod overlay below replaces them cleanly. Any - # other live entry (logs/, agent-pids/, global-agent-config.json, retention - # stores, …) is carried across verbatim and preserved by the directory swap. + # other live entry (logs/, agent-pids/, retention stores, …) is carried across + # verbatim and preserved by the directory swap. if [[ -d "$DEV_DIR/agents" ]]; then cp -Rp "$DEV_DIR/agents/." "$stage_agents/" rm -rf -- \ "$stage_agents/managed-agents.json" \ "$stage_agents/personas.json" \ "$stage_agents/teams.json" \ + "$stage_agents/global-agent-config.json" \ "$stage_agents/teams" fi From 75f101d8b4f5b4b26f9891b73b87c67fedac25a0 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 3 Sep 2026 17:41:22 -0400 Subject: [PATCH 002/283] chore(release): release Buzz Desktop version 0.5.21 (#7301) ## Buzz Desktop release v0.5.21 - **Frozen main:** `88687876f7808a2fd742b7eb2e4b9f87d999ad8d` - **Reviewed candidate:** `47e4e0347fb4142df55b03e0e3dc537fe33a5d62` - **Previous desktop release:** `desktop-v0.5.20` - **Proposed immutable tag:** `desktop-v0.5.21` This PR may be **squash merged** after the Desktop Release Candidate check and all protected-branch checks pass. Merging authorizes publication of the exact reviewed candidate; later or unrelated changes on `main` cannot alter it. The checked-in changelog accounts for every non-merge commit in the release range. The Desktop tag points to the reviewed candidate commit, not the later squash commit. Publication remains bound to that immutable candidate tag. Signed-off-by: Will Pfleger Co-authored-by: Release Automation --- .release/desktop-candidate.json | 14 ++-- CHANGELOG.md | 122 ++++++++++++++++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 133 insertions(+), 11 deletions(-) diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 4751a7d3517..63ea79696cd 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.20", - "base_sha": "8471049c430073474939336dfc6aa98272bc8762", - "previous_tag": "desktop-v0.5.19", - "previous_base_sha": "ee6ca5fa28bce04dfecb6717de65b08a57f2ac47", - "previous_merge_sha": "7a1b7d8e09f96c10b6617a66bb8589e9984ffb3c", - "tag": "desktop-v0.5.20", - "commit_count": 4 + "version": "0.5.21", + "base_sha": "88687876f7808a2fd742b7eb2e4b9f87d999ad8d", + "previous_tag": "desktop-v0.5.20", + "previous_base_sha": "8471049c430073474939336dfc6aa98272bc8762", + "previous_merge_sha": "52621c09bea503f4d5860030dfabfaf9ade71bfa", + "tag": "desktop-v0.5.21", + "commit_count": 112 } diff --git a/CHANGELOG.md b/CHANGELOG.md index eec43c38716..8c988447b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,127 @@ # Changelog +## v0.5.21 + +### Desktop and shared changes + +- fix(desktop): wrap message tables within the available pane ([#7279](https://github.com/block/buzz/pull/7279)) ([`6cf514ed90981d1db4acdb97a272543ecca83993`](https://github.com/block/buzz/commit/6cf514ed90981d1db4acdb97a272543ecca83993)) +- 🤖 fix(desktop): harden smoke E2E tests against Bestie overlay and toast timing ([#7270](https://github.com/block/buzz/pull/7270)) ([`7a9a5233d9d755e715be0c585cf7850e935d28cf`](https://github.com/block/buzz/commit/7a9a5233d9d755e715be0c585cf7850e935d28cf)) +- Show status and huddle indicators beside names ([#7112](https://github.com/block/buzz/pull/7112)) ([`c6ca9d94d94230a315257a87beb8dc116fec56cc`](https://github.com/block/buzz/commit/c6ca9d94d94230a315257a87beb8dc116fec56cc)) +- perf(desktop): publish mention sends before waking agents ([#7154](https://github.com/block/buzz/pull/7154)) ([`40220d5618fc7dbe06421f358053ca77ed0f67d7`](https://github.com/block/buzz/commit/40220d5618fc7dbe06421f358053ca77ed0f67d7)) +- fix(desktop): unify owned-agent cloud provenance markers ([#7129](https://github.com/block/buzz/pull/7129)) ([`5073e075d7e7d95558c57f24d86d5db6196491c4`](https://github.com/block/buzz/commit/5073e075d7e7d95558c57f24d86d5db6196491c4)) +- fix(desktop): derive agent availability from relay presence ([#7127](https://github.com/block/buzz/pull/7127)) ([`d5a73b9f35feaa81ef9afb13d1ec8943e8ec6ca9`](https://github.com/block/buzz/commit/d5a73b9f35feaa81ef9afb13d1ec8943e8ec6ca9)) +- fix(desktop): preserve spacing after multi-word mentions ([#7128](https://github.com/block/buzz/pull/7128)) ([`44d19f59a593dfb541bf40dcadbed313ca4a8f97`](https://github.com/block/buzz/commit/44d19f59a593dfb541bf40dcadbed313ca4a8f97)) +- feat(cli): add buzz gifs command group and NIP-30 emoji tags on messages ([#7259](https://github.com/block/buzz/pull/7259)) ([`47d068e2109d077414cbf2f4f1c927f6d051037a`](https://github.com/block/buzz/commit/47d068e2109d077414cbf2f4f1c927f6d051037a)) +- feat(desktop): add persistent Bestie experience ([#7223](https://github.com/block/buzz/pull/7223)) ([`ac5a18697c8294e9237f505a2e01ec6fc374849a`](https://github.com/block/buzz/commit/ac5a18697c8294e9237f505a2e01ec6fc374849a)) +- fix(desktop): harden profile batch and thread-reply fetches against relay slowness ([#7188](https://github.com/block/buzz/pull/7188)) ([`6f6093243eaea5160db79b7e054f06dfffd207aa`](https://github.com/block/buzz/commit/6f6093243eaea5160db79b7e054f06dfffd207aa)) +- fix(composer): align wrapped inline chip fragments ([#7242](https://github.com/block/buzz/pull/7242)) ([`b6dc533b7c2aadb70a8f011a88a5bd2f06d800f2`](https://github.com/block/buzz/commit/b6dc533b7c2aadb70a8f011a88a5bd2f06d800f2)) +- fix(desktop): keep explicit agent profiles bound to their exact key ([#7131](https://github.com/block/buzz/pull/7131)) ([`83e596298449ab1cb57481221d0e41ce69e52814`](https://github.com/block/buzz/commit/83e596298449ab1cb57481221d0e41ce69e52814)) +- fix(desktop): discover authenticated owned relay agents ([#7122](https://github.com/block/buzz/pull/7122)) ([`434dafe958d82ca07d2b42b0f7d023ab923c2ff9`](https://github.com/block/buzz/commit/434dafe958d82ca07d2b42b0f7d023ab923c2ff9)) +- feat(agents): harness-agnostic effort write path and spawn bridge ([#4625](https://github.com/block/buzz/pull/4625)) ([`0dbd036f5bff33e7ade75e7639f3218d424a6e73`](https://github.com/block/buzz/commit/0dbd036f5bff33e7ade75e7639f3218d424a6e73)) +- fix(desktop): retain automatic mentions only in threads ([#7144](https://github.com/block/buzz/pull/7144)) ([`1c8321cd08feb597f8bcff5195c21148fb3e98ed`](https://github.com/block/buzz/commit/1c8321cd08feb597f8bcff5195c21148fb3e98ed)) +- feat: add databricks fable 5.1 model capabilities ([#7213](https://github.com/block/buzz/pull/7213)) ([`560fea7b4fc5e96a4de45d988e039ffb3fd66662`](https://github.com/block/buzz/commit/560fea7b4fc5e96a4de45d988e039ffb3fd66662)) +- feat(desktop): add Pi agent preset ([#7208](https://github.com/block/buzz/pull/7208)) ([`42aeb1571cc1f1ed4d73f05b420474be970021ab`](https://github.com/block/buzz/commit/42aeb1571cc1f1ed4d73f05b420474be970021ab)) +- feat(buzz-agent): add DatabricksAuthCoordinator single-flight OAuth ([#5545](https://github.com/block/buzz/pull/5545)) ([`5aed49b505a7e27f3b0e34dafa53d6c4e8cdcd64`](https://github.com/block/buzz/commit/5aed49b505a7e27f3b0e34dafa53d6c4e8cdcd64)) +- fix(desktop): preserve keyring identity during recovery ([#7203](https://github.com/block/buzz/pull/7203)) ([`e5a7e26a1008227ff6a0b9430a6df2470475bec1`](https://github.com/block/buzz/commit/e5a7e26a1008227ff6a0b9430a6df2470475bec1)) +- ci: relax file-size ceilings by surface ([#6485](https://github.com/block/buzz/pull/6485)) ([`4794a5cecf058ebbe2b367f40b1ce6ff80791e91`](https://github.com/block/buzz/commit/4794a5cecf058ebbe2b367f40b1ce6ff80791e91)) +- fix(dev-mcp): extend shell timeout cap to 20 minutes and align outer budgets ([#7185](https://github.com/block/buzz/pull/7185)) ([`cae158ce7fd72dee1f34d264f6790711d82d46bf`](https://github.com/block/buzz/commit/cae158ce7fd72dee1f34d264f6790711d82d46bf)) +- feat(buzz-auth): add production NIP-FI federated assertion runtime ([#7109](https://github.com/block/buzz/pull/7109)) ([`70895b355fcea9f99894b426c020052b715bd368`](https://github.com/block/buzz/commit/70895b355fcea9f99894b426c020052b715bd368)) +- Hide download action on voice notes ([#7182](https://github.com/block/buzz/pull/7182)) ([`ac4aa94b05417c2830e5489ddda0c87f6a9d1dbf`](https://github.com/block/buzz/commit/ac4aa94b05417c2830e5489ddda0c87f6a9d1dbf)) +- Add voice notes to desktop messages ([#6978](https://github.com/block/buzz/pull/6978)) ([`114dbf745f1fe0bdbecb70e07a07f03c23687bdc`](https://github.com/block/buzz/commit/114dbf745f1fe0bdbecb70e07a07f03c23687bdc)) +- feat(desktop): add thread-scoped ACP session experiment ([#6909](https://github.com/block/buzz/pull/6909)) ([`59328d5ae38a51a618dd2fddd7faf1343d42096f`](https://github.com/block/buzz/commit/59328d5ae38a51a618dd2fddd7faf1343d42096f)) +- fix(desktop): scope composer autocomplete to focus ([#6860](https://github.com/block/buzz/pull/6860)) ([`571c1902d0ca55cfd4ccf6b91eeb731909cc10be`](https://github.com/block/buzz/commit/571c1902d0ca55cfd4ccf6b91eeb731909cc10be)) +- feat(desktop): add isolated named demo builds ([#6407](https://github.com/block/buzz/pull/6407)) ([`4a9de1a3a121285ef475d630b2b5764044c02cde`](https://github.com/block/buzz/commit/4a9de1a3a121285ef475d630b2b5764044c02cde)) +- fix(model-capabilities): humanize databricks goose model names ([#7135](https://github.com/block/buzz/pull/7135)) ([`0affe527e880ddd300ae7d4dac3f0600c79b4f5e`](https://github.com/block/buzz/commit/0affe527e880ddd300ae7d4dac3f0600c79b4f5e)) +- fix(desktop): back split thread headers ([#7137](https://github.com/block/buzz/pull/7137)) ([`b47b5a50127c080a74ed4de19a43534cd9a7b563`](https://github.com/block/buzz/commit/b47b5a50127c080a74ed4de19a43534cd9a7b563)) +- add public descriptions to agent personas ([#7126](https://github.com/block/buzz/pull/7126)) ([`cb3144999bebc4939cb15b2200b373281d493b52`](https://github.com/block/buzz/commit/cb3144999bebc4939cb15b2200b373281d493b52)) +- feat(desktop): add protected-build Bestie experiment ([#6902](https://github.com/block/buzz/pull/6902)) ([`5673c3307e248530beeea060da437350d3939a4b`](https://github.com/block/buzz/commit/5673c3307e248530beeea060da437350d3939a4b)) +- fix(relay): reject a frame on its own acknowledgement channel ([#6961](https://github.com/block/buzz/pull/6961)) ([`2f3dd850db3afe27e56f18cbcd3548eabdd9b9c2`](https://github.com/block/buzz/commit/2f3dd850db3afe27e56f18cbcd3548eabdd9b9c2)) +- feat: render agent avatars as squircles ([#7106](https://github.com/block/buzz/pull/7106)) ([`bc006f67087b049e2f9c4d8a2f26faceff628225`](https://github.com/block/buzz/commit/bc006f67087b049e2f9c4d8a2f26faceff628225)) +- Enforce NIP-OA authorization time bounds ([#7004](https://github.com/block/buzz/pull/7004)) ([`e47690c21b519128b40c917456b3e3f33f018fe4`](https://github.com/block/buzz/commit/e47690c21b519128b40c917456b3e3f33f018fe4)) +- feat(desktop): use segmented controls for channel creation ([#6845](https://github.com/block/buzz/pull/6845)) ([`c3132c3ee982d194cd0198ad07b57ec8bd726e4e`](https://github.com/block/buzz/commit/c3132c3ee982d194cd0198ad07b57ec8bd726e4e)) +- feat(buzz-agent): surface stop reason and silent-turn WARN in telemetry ([#7038](https://github.com/block/buzz/pull/7038)) ([`eed74bde2f4797714335ac10c56c0b0244c1def4`](https://github.com/block/buzz/commit/eed74bde2f4797714335ac10c56c0b0244c1def4)) +- fix(desktop): surface channel history load failures ([#7013](https://github.com/block/buzz/pull/7013)) ([`00e61eafa917d296104006576b7a2ddbfd58bb5a`](https://github.com/block/buzz/commit/00e61eafa917d296104006576b7a2ddbfd58bb5a)) +- fix(composer): polish automatic mentions ([#6956](https://github.com/block/buzz/pull/6956)) ([`8dbc65d9e2c80d9d8516e17b751c46e0568100e6`](https://github.com/block/buzz/commit/8dbc65d9e2c80d9d8516e17b751c46e0568100e6)) +- fix(desktop): resolve bundled sidecar on cheap path and bound login-shell spawns ([#6904](https://github.com/block/buzz/pull/6904)) ([`2c99ee7af5a20d239e95ec5368407887449795c9`](https://github.com/block/buzz/commit/2c99ee7af5a20d239e95ec5368407887449795c9)) +- refactor(db): extract domain stores from database runtime ([#6987](https://github.com/block/buzz/pull/6987)) ([`a3730784fc851bb1125b40cca9b0a30788a293c1`](https://github.com/block/buzz/commit/a3730784fc851bb1125b40cca9b0a30788a293c1)) +- feat(desktop): add team sharing to community catalog ([#3995](https://github.com/block/buzz/pull/3995)) ([`2f66ee24a0bf5efa958378069a6b155e99a1d068`](https://github.com/block/buzz/commit/2f66ee24a0bf5efa958378069a6b155e99a1d068)) +- fix(desktop): complete project empty and context states ([#6980](https://github.com/block/buzz/pull/6980)) ([`a89f5df4c52c3aa5875d9fccb0eaf2222aee4084`](https://github.com/block/buzz/commit/a89f5df4c52c3aa5875d9fccb0eaf2222aee4084)) +- fix(desktop): resolve exact typed mentions on space ([#6862](https://github.com/block/buzz/pull/6862)) ([`b49675894b39f87215e4dfc8c1ad4a3c28c6097e`](https://github.com/block/buzz/commit/b49675894b39f87215e4dfc8c1ad4a3c28c6097e)) +- perf(desktop): restore project context during startup ([#6939](https://github.com/block/buzz/pull/6939)) ([`973386351646d01efca2735ad4921c0d219cff85`](https://github.com/block/buzz/commit/973386351646d01efca2735ad4921c0d219cff85)) +- fix(desktop): lift right auxiliary pane above shared header backdrop ([#6966](https://github.com/block/buzz/pull/6966)) ([`350caf1de3136f6ec473a69d394544e99dfd4d85`](https://github.com/block/buzz/commit/350caf1de3136f6ec473a69d394544e99dfd4d85)) +- feat(desktop): implement 30178 team catalog backend ([#5112](https://github.com/block/buzz/pull/5112)) ([`a7c7414a7fadfe1585b8a90af0f29d6593bb6126`](https://github.com/block/buzz/commit/a7c7414a7fadfe1585b8a90af0f29d6593bb6126)) +- feat(model-capabilities): humanize Databricks UC model families ([#6955](https://github.com/block/buzz/pull/6955)) ([`ae0ecd9a676c692876116fee7265cba5f18cbd90`](https://github.com/block/buzz/commit/ae0ecd9a676c692876116fee7265cba5f18cbd90)) +- feat(agent): discover Databricks Unity Catalog models ([#6918](https://github.com/block/buzz/pull/6918)) ([`be8d7756d5f17285f1e8a4b77d67f7d7cfb6f9e7`](https://github.com/block/buzz/commit/be8d7756d5f17285f1e8a4b77d67f7d7cfb6f9e7)) +- preserve channel description paragraph breaks ([#6946](https://github.com/block/buzz/pull/6946)) ([`69096c9a8db726eebd83422cdc5e6c72ed9d4f90`](https://github.com/block/buzz/commit/69096c9a8db726eebd83422cdc5e6c72ed9d4f90)) +- fix(projects): allow owners to delete agent projects ([#6533](https://github.com/block/buzz/pull/6533)) ([`9176e34405ae59fdefc50dc585454b7e9cb3833a`](https://github.com/block/buzz/commit/9176e34405ae59fdefc50dc585454b7e9cb3833a)) +- Fade expanded video controls on hover ([#6926](https://github.com/block/buzz/pull/6926)) ([`27c1a7c91b658535c8a655dbe8f2421af9099b80`](https://github.com/block/buzz/commit/27c1a7c91b658535c8a655dbe8f2421af9099b80)) +- fix(client): resurface hidden DMs from live activity ([#6885](https://github.com/block/buzz/pull/6885)) ([`4d77175c78f4df48e13398b25b271e3ac1b31dd3`](https://github.com/block/buzz/commit/4d77175c78f4df48e13398b25b271e3ac1b31dd3)) +- fix(desktop): keep the draft space when typing right after a mention pick ([#6875](https://github.com/block/buzz/pull/6875)) ([`c856be0fb954c9e5267d622841098c24e3381e8f`](https://github.com/block/buzz/commit/c856be0fb954c9e5267d622841098c24e3381e8f)) +- broker: define the agent-to-broker action contract ([#6742](https://github.com/block/buzz/pull/6742)) ([`b622003f74aa5bf9b659786452813299a25e4897`](https://github.com/block/buzz/commit/b622003f74aa5bf9b659786452813299a25e4897)) +- fix(desktop): keep project sheets independent from threads ([#6901](https://github.com/block/buzz/pull/6901)) ([`cada3028ceda7ebdae61329f15b5364ca0d23dd4`](https://github.com/block/buzz/commit/cada3028ceda7ebdae61329f15b5364ca0d23dd4)) +- fix(desktop): accent-colored mention badges that count thread mentions ([#6900](https://github.com/block/buzz/pull/6900)) ([`ceb8ba605f43728bdab64aa461211baeeb54364a`](https://github.com/block/buzz/commit/ceb8ba605f43728bdab64aa461211baeeb54364a)) +- fix(desktop): show edited head content in thread panel ([#6887](https://github.com/block/buzz/pull/6887)) ([`eb8e97cfe74afbe560d92f3a8f470967a992767f`](https://github.com/block/buzz/commit/eb8e97cfe74afbe560d92f3a8f470967a992767f)) +- fix(desktop-tooltip): increase surface contrast ([#6897](https://github.com/block/buzz/pull/6897)) ([`03bfc837cbe463de9ef59a86742825a4863fb603`](https://github.com/block/buzz/commit/03bfc837cbe463de9ef59a86742825a4863fb603)) +- Apply access policy when reusing channel agents ([#6838](https://github.com/block/buzz/pull/6838)) ([`01c87a350b0fda89c8d333a5dd6c0e9d89d0c03f`](https://github.com/block/buzz/commit/01c87a350b0fda89c8d333a5dd6c0e9d89d0c03f)) +- feat(sidebar): prioritize unread DMs in overflow navigation ([#6842](https://github.com/block/buzz/pull/6842)) ([`4f554b6385065b698db819498a6f359b17a45459`](https://github.com/block/buzz/commit/4f554b6385065b698db819498a6f359b17a45459)) +- feat(projects): add agent and CLI project-home support ([#6590](https://github.com/block/buzz/pull/6590)) ([`de188ebf06f701ca0f47c51f859da21e19a35f04`](https://github.com/block/buzz/commit/de188ebf06f701ca0f47c51f859da21e19a35f04)) +- feat(desktop): restore message quick reactions ([#6892](https://github.com/block/buzz/pull/6892)) ([`22cdda441d19da1b15dd7db4d932d2f067cd87b3`](https://github.com/block/buzz/commit/22cdda441d19da1b15dd7db4d932d2f067cd87b3)) +- Use paired tags for standing & per-turn context ([#6701](https://github.com/block/buzz/pull/6701)) ([`f177f4909d55ad48a36e86a682cd5be4f006f0fe`](https://github.com/block/buzz/commit/f177f4909d55ad48a36e86a682cd5be4f006f0fe)) +- fix(cli): preserve signatures in event reads ([#6884](https://github.com/block/buzz/pull/6884)) ([`583af02299e20cbd8603044c7844bc128e4e06cd`](https://github.com/block/buzz/commit/583af02299e20cbd8603044c7844bc128e4e06cd)) +- fix(desktop): stop pulsing addressed agents on send ([#6873](https://github.com/block/buzz/pull/6873)) ([`db514b153a8cb17631d6e80b91c95fdc45deb147`](https://github.com/block/buzz/commit/db514b153a8cb17631d6e80b91c95fdc45deb147)) +- fix(desktop): prioritize sidebar channel status ([#6861](https://github.com/block/buzz/pull/6861)) ([`4f529a45f219f55bc6038f304cd69e3f51ec0533`](https://github.com/block/buzz/commit/4f529a45f219f55bc6038f304cd69e3f51ec0533)) +- feat(desktop): hyperlink selected composer text on link paste ([#6684](https://github.com/block/buzz/pull/6684)) ([`e8172b5baccd0f4a711f5e19a3bc4313708f1c78`](https://github.com/block/buzz/commit/e8172b5baccd0f4a711f5e19a3bc4313708f1c78)) + +### Other repository changes + +- feat(relay): add early startup lifecycle logs ([#7258](https://github.com/block/buzz/pull/7258)) ([`88687876f7808a2fd742b7eb2e4b9f87d999ad8d`](https://github.com/block/buzz/commit/88687876f7808a2fd742b7eb2e4b9f87d999ad8d)) +- docs(nip-fi): add Blossom kind-24242 media possession-proof exception ([#7278](https://github.com/block/buzz/pull/7278)) ([`752cbfc0375efc8bac3d5cc2a5716250bc0be234`](https://github.com/block/buzz/commit/752cbfc0375efc8bac3d5cc2a5716250bc0be234)) +- Add mobile voice notes ([#7121](https://github.com/block/buzz/pull/7121)) ([`6f5ec4a731901bf3ff2a370c7ccbfbc3fba93368`](https://github.com/block/buzz/commit/6f5ec4a731901bf3ff2a370c7ccbfbc3fba93368)) +- docs(nip-fi): document Git smart-HTTP credential exemption ([#7268](https://github.com/block/buzz/pull/7268)) ([`c328202cb08cac5b8c1610d2d373e69889772b4e`](https://github.com/block/buzz/commit/c328202cb08cac5b8c1610d2d373e69889772b4e)) +- docs(nip-fi): adopt deny-until-TTL and extend enforcement to HTTP ingress ([#7254](https://github.com/block/buzz/pull/7254)) ([`187df22252fa24cce2f3295fb9df9f4dc211b30a`](https://github.com/block/buzz/commit/187df22252fa24cce2f3295fb9df9f4dc211b30a)) +- Add operation-aware database pool acquisition metrics ([#7195](https://github.com/block/buzz/pull/7195)) ([`91ab9d31b8f7249ff1db141ae9d8bb3f2a20bcdd`](https://github.com/block/buzz/commit/91ab9d31b8f7249ff1db141ae9d8bb3f2a20bcdd)) +- chore(db): drop Phase-A NIP-FI relay-side authority ledger ([#7221](https://github.com/block/buzz/pull/7221)) ([`04babf02655440b4dfd37f2e2df605ead0a030d8`](https://github.com/block/buzz/commit/04babf02655440b4dfd37f2e2df605ead0a030d8)) +- fix(acp): replace real user name in base prompt mention example ([#7250](https://github.com/block/buzz/pull/7250)) ([`2af9773d6271ce62cbe5dfbd28fbe5dd79372465`](https://github.com/block/buzz/commit/2af9773d6271ce62cbe5dfbd28fbe5dd79372465)) +- ci: split CI into reusable workflows ([#7168](https://github.com/block/buzz/pull/7168)) ([`0e878664b08cdf7fb2d89d940bc2aa92cdc485f7`](https://github.com/block/buzz/commit/0e878664b08cdf7fb2d89d940bc2aa92cdc485f7)) +- docs(nip-fi): rewrite NIP-FI as stateless OSS Buzz spec v2 ([#7214](https://github.com/block/buzz/pull/7214)) ([`d4420eb47f9d48bd81358f928dfe6dee6804ac13`](https://github.com/block/buzz/commit/d4420eb47f9d48bd81358f928dfe6dee6804ac13)) +- feat(relay): add detailed readiness metrics ([#7149](https://github.com/block/buzz/pull/7149)) ([`beb76406c12ab8a7af9b2fcf7547c353e3369c34`](https://github.com/block/buzz/commit/beb76406c12ab8a7af9b2fcf7547c353e3369c34)) +- feat(mobile): prepare `buzz-push-gateway` for deployment ([#7158](https://github.com/block/buzz/pull/7158)) ([`42b42447b0dc47c3e1a4d95caab893567dcd1677`](https://github.com/block/buzz/commit/42b42447b0dc47c3e1a4d95caab893567dcd1677)) +- fix(mobile): isolate extension linker flags; complete iOS build in CI ([#7187](https://github.com/block/buzz/pull/7187)) ([`b270437a62bc1049b27745799dc44268d0c23489`](https://github.com/block/buzz/commit/b270437a62bc1049b27745799dc44268d0c23489)) +- chore(ci): lower Codex security review effort ([#7179](https://github.com/block/buzz/pull/7179)) ([`4365883151698cd30e31cf4091629543b61c2478`](https://github.com/block/buzz/commit/4365883151698cd30e31cf4091629543b61c2478)) +- fix(dev): keep the canonical profile when launching from desktop/ ([#7143](https://github.com/block/buzz/pull/7143)) ([`e17a0d44c379b2ae40b91b8ba43f9a9c5bede90f`](https://github.com/block/buzz/commit/e17a0d44c379b2ae40b91b8ba43f9a9c5bede90f)) +- ci: run PostgreSQL tests in isolated lane ([#6730](https://github.com/block/buzz/pull/6730)) ([`bd73490418266f267d9bb3bdf13e64582adc8e80`](https://github.com/block/buzz/commit/bd73490418266f267d9bb3bdf13e64582adc8e80)) +- feat(db): add NIP-FI identity and final-admission schema foundation ([#6994](https://github.com/block/buzz/pull/6994)) ([`9ab163190b4d4994a742a66d5fa4cae23c35fa4e`](https://github.com/block/buzz/commit/9ab163190b4d4994a742a66d5fa4cae23c35fa4e)) +- feat(buzz-acp): give each channel thread its own agent session ([#6732](https://github.com/block/buzz/pull/6732)) ([`674c173eb7b2805864fd8375297c849c3a758f69`](https://github.com/block/buzz/commit/674c173eb7b2805864fd8375297c849c3a758f69)) +- docs: add review-proven failure-path & async-state rules to AGENTS.md ([#7061](https://github.com/block/buzz/pull/7061)) ([`4952f5810290d00a1dd9413235b39e9a7154acf6`](https://github.com/block/buzz/commit/4952f5810290d00a1dd9413235b39e9a7154acf6)) +- fix(acp): wake agents from workflow messages ([#6953](https://github.com/block/buzz/pull/6953)) ([`93237b4a745b14cb8bfe292053bf34adef652d29`](https://github.com/block/buzz/commit/93237b4a745b14cb8bfe292053bf34adef652d29)) +- fix(ci): salvage Codex review output on PTY-shutdown hang ([#7042](https://github.com/block/buzz/pull/7042)) ([`17ecf0b8c9c0f4bb9d098f25c2a2ece6a3500a4c`](https://github.com/block/buzz/commit/17ecf0b8c9c0f4bb9d098f25c2a2ece6a3500a4c)) +- fix: retrieving cold memories; add regression task ([#6950](https://github.com/block/buzz/pull/6950)) ([`f463e726dd173146c3c2bcbf7fdba03c9790dd3a`](https://github.com/block/buzz/commit/f463e726dd173146c3c2bcbf7fdba03c9790dd3a)) +- feat(db): configurable writer session timeouts (lock, idle-txn, statement) ([#6229](https://github.com/block/buzz/pull/6229)) ([`3ed623bb217bf9697b0ce4562529254977e0ea04`](https://github.com/block/buzz/commit/3ed623bb217bf9697b0ce4562529254977e0ea04)) +- perf(mobile): reduce cold startup and channel rendering delays ([#6996](https://github.com/block/buzz/pull/6996)) ([`b593c7d7feb6bf4207dbf3439906c25a714b703f`](https://github.com/block/buzz/commit/b593c7d7feb6bf4207dbf3439906c25a714b703f)) +- feat(mobile): push notifications MVP ([#6269](https://github.com/block/buzz/pull/6269)) ([`c432a111ca9ddd31a85e1312d5995f8b92191b82`](https://github.com/block/buzz/commit/c432a111ca9ddd31a85e1312d5995f8b92191b82)) +- Refresh mobile utility surfaces and theme picker ([#6944](https://github.com/block/buzz/pull/6944)) ([`ed11c8d8bf0a17402be5cf243724f89471530d2f`](https://github.com/block/buzz/commit/ed11c8d8bf0a17402be5cf243724f89471530d2f)) +- Fix mobile jump-to-latest flicker ([#6807](https://github.com/block/buzz/pull/6807)) ([`58cc4b7e9be71223f7898ea5f930a4248830ccfb`](https://github.com/block/buzz/commit/58cc4b7e9be71223f7898ea5f930a4248830ccfb)) +- refactor(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery ([#3777](https://github.com/block/buzz/pull/3777)) ([`86b9142a09f2af3ba2fff7effa6a6cd53b40f51c`](https://github.com/block/buzz/commit/86b9142a09f2af3ba2fff7effa6a6cd53b40f51c)) +- refactor(db): split channel membership store ([#6782](https://github.com/block/buzz/pull/6782)) ([`e76c81968b65b0755b83efdd59dc3375c59ddf40`](https://github.com/block/buzz/commit/e76c81968b65b0755b83efdd59dc3375c59ddf40)) +- feat(auth): add NIP-FI canonical assertion verifier and contracts ([#6776](https://github.com/block/buzz/pull/6776)) ([`953a1798b23b04289b3a225f463e60a172c1aac3`](https://github.com/block/buzz/commit/953a1798b23b04289b3a225f463e60a172c1aac3)) +- fix(ci): bump Codex CLI to 0.150.1 to unhang security review jobs ([#6962](https://github.com/block/buzz/pull/6962)) ([`80177e4c8e97e7bf1f1a3760c4e3503aace22860`](https://github.com/block/buzz/commit/80177e4c8e97e7bf1f1a3760c4e3503aace22860)) +- test(db): use canonical channel roster fixtures ([#6819](https://github.com/block/buzz/pull/6819)) ([`57216c942f171db305135bcb6a4ede2d87e0be8a`](https://github.com/block/buzz/commit/57216c942f171db305135bcb6a4ede2d87e0be8a)) +- fix(cli): enrich template cardinality error with per-candidate presence and profile hints ([#4825](https://github.com/block/buzz/pull/4825)) ([`0808ab485c39c3c12ef02af2188c65a5145eb99d`](https://github.com/block/buzz/commit/0808ab485c39c3c12ef02af2188c65a5145eb99d)) +- Fix Codex security review authorization ([#6913](https://github.com/block/buzz/pull/6913)) ([`745ff6066c92372ea1ee6a5fe05862cdd9b81303`](https://github.com/block/buzz/commit/745ff6066c92372ea1ee6a5fe05862cdd9b81303)) +- fix(db): disable heartbeat vacuum truncation ([#6898](https://github.com/block/buzz/pull/6898)) ([`0ccf934b88f610f5f235862ecd51e2dcbae2cb74`](https://github.com/block/buzz/commit/0ccf934b88f610f5f235862ecd51e2dcbae2cb74)) +- chore(deps): update rui314/setup-mold digest to 7e4f20a ([#6663](https://github.com/block/buzz/pull/6663)) ([`c363ce13c07cb454d6fa92b96d65cb52322c13e9`](https://github.com/block/buzz/commit/c363ce13c07cb454d6fa92b96d65cb52322c13e9)) +- chore(deps): update dependency vitest to v4.1.11 ([#6667](https://github.com/block/buzz/pull/6667)) ([`525007af4b2f644ce07fab92ef4863c46cbae8a3`](https://github.com/block/buzz/commit/525007af4b2f644ce07fab92ef4863c46cbae8a3)) +- chore(deps): update dependency @tanstack/react-virtual to v3.14.10 ([#6666](https://github.com/block/buzz/pull/6666)) ([`30596d3addf69a6954d034ce28f886539604f2c8`](https://github.com/block/buzz/commit/30596d3addf69a6954d034ce28f886539604f2c8)) +- chore(deps): update ubuntu:24.04 docker digest to 33ceb71 ([#6664](https://github.com/block/buzz/pull/6664)) ([`82e748085e3f06b8e297387b33f7615a6db23501`](https://github.com/block/buzz/commit/82e748085e3f06b8e297387b33f7615a6db23501)) +- fix(db): exclude kind:30179 ciphertext from brownfield FTS ([#6822](https://github.com/block/buzz/pull/6822)) ([`b3baa56ba83d01a0b7292d81aae9370f5616c62b`](https://github.com/block/buzz/commit/b3baa56ba83d01a0b7292d81aae9370f5616c62b)) +- Add gated security reviews ([#6816](https://github.com/block/buzz/pull/6816)) ([`cdab765748ddfeba10830a9a5331b6157bd321df`](https://github.com/block/buzz/commit/cdab765748ddfeba10830a9a5331b6157bd321df)) +- Add Buzz benchmark evaluation layers ([#6823](https://github.com/block/buzz/pull/6823)) ([`79df468255b50f6aa7bd9cb19a622a4a37b88dac`](https://github.com/block/buzz/commit/79df468255b50f6aa7bd9cb19a622a4a37b88dac)) +- Deduplicate ACP thread prompt context ([#6706](https://github.com/block/buzz/pull/6706)) ([`8f3566ad9ebbd225e53ce66a7072ceee074eb25a`](https://github.com/block/buzz/commit/8f3566ad9ebbd225e53ce66a7072ceee074eb25a)) +- refactor(db): finish replaceable event store extraction ([#6777](https://github.com/block/buzz/pull/6777)) ([`ef0d2025683869418e8eee22ac5b5ac16c5198b7`](https://github.com/block/buzz/commit/ef0d2025683869418e8eee22ac5b5ac16c5198b7)) +- Fix Admin feedback filter overflow ([#6825](https://github.com/block/buzz/pull/6825)) ([`dcee330b5ca984de8657a6d8cf36e34e1d825d4a`](https://github.com/block/buzz/commit/dcee330b5ca984de8657a6d8cf36e34e1d825d4a)) + +[Compare desktop-v0.5.20...desktop-v0.5.21](https://github.com/block/buzz/compare/desktop-v0.5.20...desktop-v0.5.21) + ## v0.5.20 ### Desktop and shared changes diff --git a/desktop/package.json b/desktop/package.json index 3d6024e1053..b37a71488a4 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.20", + "version": "0.5.21", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 7a3669f440e..6b5d521753a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1082,7 +1082,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.20" +version = "0.5.21" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 0bc32817881..4302f07e475 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.20" +version = "0.5.21" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 05dc5553397..5bc5b9b3685 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.20", + "version": "0.5.21", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From 2ac0aa1dd18c0b9d4fa722658c4692b0c79a496f Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Thu, 3 Sep 2026 19:26:21 -0400 Subject: [PATCH 003/283] Collapse contiguous join messages (#7262) --- .../lib/membershipGroupPayload.test.mjs | 155 +++++ .../messages/lib/membershipGroupPayload.ts | 126 ++++ .../features/messages/lib/systemEventCopy.ts | 8 + .../messages/lib/timelineItems.test.mjs | 165 ++++- .../features/messages/lib/timelineItems.ts | 25 +- .../messages/ui/SystemMessageRow.test.mjs | 567 ++++++++++++++++++ .../features/messages/ui/SystemMessageRow.tsx | 312 +++++----- desktop/tests/e2e/mentions.spec.ts | 318 +++++++++- 8 files changed, 1469 insertions(+), 207 deletions(-) create mode 100644 desktop/src/features/messages/lib/membershipGroupPayload.test.mjs create mode 100644 desktop/src/features/messages/lib/membershipGroupPayload.ts create mode 100644 desktop/src/features/messages/ui/SystemMessageRow.test.mjs diff --git a/desktop/src/features/messages/lib/membershipGroupPayload.test.mjs b/desktop/src/features/messages/lib/membershipGroupPayload.test.mjs new file mode 100644 index 00000000000..ade4e7d68a5 --- /dev/null +++ b/desktop/src/features/messages/lib/membershipGroupPayload.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { buildGroupedMembershipPayload } from "./membershipGroupPayload.ts"; +import { buildTimelineItems } from "./timelineItems.ts"; +import { KIND_SYSTEM_MESSAGE } from "../../../shared/constants/kinds.ts"; + +const DAY_START = Math.floor(Date.UTC(2026, 5, 14, 12, 0, 0) / 1000); + +function systemEntry(id, createdAt, body) { + return { + message: { + author: "System", + body: JSON.stringify(body), + createdAt, + depth: 0, + id, + kind: KIND_SYSTEM_MESSAGE, + pubkey: "aa".repeat(32), + reactions: [], + time: "12:00 PM", + }, + summary: null, + }; +} + +// One symbol per membership mechanism the relay can emit, so the matrix covers +// self-arrival, addition (same and different actor), and departure — for two +// distinct targets, which is what makes same-target vs cross-target grouping +// observable. +const SYMBOLS = { + "self-join(a)": { type: "member_joined", actor: "aaa", target: "aaa" }, + "self-join(b)": { type: "member_joined", actor: "bbb", target: "bbb" }, + "add(a by x)": { type: "member_joined", actor: "xxx", target: "aaa" }, + "add(b by x)": { type: "member_joined", actor: "xxx", target: "bbb" }, + "add(b by y)": { type: "member_joined", actor: "yyy", target: "bbb" }, + "leave(a)": { type: "member_left", actor: "aaa" }, + "leave(b)": { type: "member_left", actor: "bbb" }, +}; + +const SYMBOL_NAMES = Object.keys(SYMBOLS); + +function sequencesOfLength(length) { + if (length === 0) return [[]]; + return sequencesOfLength(length - 1).flatMap((prefix) => + SYMBOL_NAMES.map((name) => [...prefix, name]), + ); +} + +function entriesFor(sequence) { + return sequence.map((name, index) => + systemEntry(`e${index}`, DAY_START + index * 30, SYMBOLS[name]), + ); +} + +/** + * The seam this guards: `buildTimelineItems` owns *which* contiguous membership + * events collapse into one `system-group`, and `buildGroupedMembershipPayload` + * owns *how* that group is described. They are two statements of one rule. + * + * When the builder emits a group the payload builder returns null for, + * `SystemMessageRow` falls back to the group's oldest message and every other + * event in the group vanishes from the timeline — a member who left still + * renders as present. That failure is silent, so it needs a standing guard + * rather than one case-by-case regression per shape. + */ +test("every membership group buildTimelineItems emits is describable", () => { + const undescribable = []; + let groupsChecked = 0; + + for (const length of [2, 3, 4]) { + for (const sequence of sequencesOfLength(length)) { + const { items } = buildTimelineItems(entriesFor(sequence), null); + for (const item of items) { + if (item.kind !== "system-group") continue; + groupsChecked += 1; + const payload = buildGroupedMembershipPayload( + item.entries.map((entry) => entry.message), + ); + if (!payload) { + undescribable.push( + `${sequence.join(" → ")} [group: ${item.entries + .map((entry) => entry.message.id) + .join(",")}]`, + ); + } + } + } + } + + assert.ok(groupsChecked > 0, "matrix produced no groups to check"); + assert.deepEqual( + undescribable.slice(0, 10), + [], + `${undescribable.length} grouped shape(s) render as the oldest event only`, + ); +}); + +test("a lifecycle group needs every arrival to be a self-join by the departing member", () => { + const elrond = "11".repeat(32); + const viewer = "10".repeat(32); + const message = (id, body) => ({ + author: "System", + body: JSON.stringify(body), + createdAt: 1, + depth: 0, + id, + kind: KIND_SYSTEM_MESSAGE, + reactions: [], + time: "12:00 PM", + }); + const selfJoin = (id) => + message(id, { type: "member_joined", actor: elrond, target: elrond }); + const leave = (id) => message(id, { type: "member_left", actor: elrond }); + + // Accepted: one or more equivalent self-arrivals, then that member departing. + for (const arrivals of [1, 2, 3]) { + const messages = [ + ...Array.from({ length: arrivals }, (_, index) => selfJoin(`j${index}`)), + leave("l"), + ]; + assert.equal( + buildGroupedMembershipPayload(messages)?.type, + "member_joined_then_left", + `${arrivals} self-arrival(s) + departure should be one lifecycle summary`, + ); + } + + // Rejected: anything else. Each of these would misattribute a self-join the + // member never made, or fold in an unrelated member's activity. + const rejected = { + "addition, not a self-join": [ + message("a", { type: "member_joined", actor: viewer, target: elrond }), + leave("l"), + ], + "one arrival is an addition": [ + selfJoin("j0"), + message("a", { type: "member_joined", actor: viewer, target: elrond }), + leave("l"), + ], + "a different member departs": [ + selfJoin("j0"), + message("l", { type: "member_left", actor: viewer }), + ], + "departure is not last": [leave("l"), selfJoin("j0")], + "no departure at all": [selfJoin("j0"), selfJoin("j1")], + }; + for (const [name, messages] of Object.entries(rejected)) { + assert.notEqual( + buildGroupedMembershipPayload(messages)?.type, + "member_joined_then_left", + `${name} must not render as a lifecycle summary`, + ); + } +}); diff --git a/desktop/src/features/messages/lib/membershipGroupPayload.ts b/desktop/src/features/messages/lib/membershipGroupPayload.ts new file mode 100644 index 00000000000..cb22472077e --- /dev/null +++ b/desktop/src/features/messages/lib/membershipGroupPayload.ts @@ -0,0 +1,126 @@ +/** + * Grouped-membership payload construction for system rows. + * + * `buildTimelineItems` decides *which* contiguous membership events collapse + * into one `system-group`; this module decides *how* that group is described. + * The two rules are one invariant, so they live where both the renderer and a + * lib-level test can import them — a group the builder emits but this module + * cannot describe silently degrades to "render only the oldest event", which + * drops the rest of the group from the timeline. + * + * Kept pure (no React, no DOM) so `membershipGroupPayload.test.mjs` and + * `timelineItems.test.mjs` can cross-check the two rules directly. + */ + +import type { TimelineMessage } from "@/features/messages/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** Parsed body of a kind:40099 system message, plus the synthetic group kinds. */ +export type SystemMessagePayload = { + type: string; + actor?: string; + arrivals?: Array<{ actor: string; target: string }>; + target?: string; + targets?: string[]; + topic?: string; + purpose?: string; + // Moderation tombstone fields (kind:40099 "message_deleted"). All optional and + // moderator-authored — present when a moderator removed the message, absent for + // a plain member self-delete. Reporter identity/evidence never appears here. + public_reason?: string; + reason_code?: string; + action_id?: string; +}; + +/** Parses one system message body; returns null when the body is not JSON. */ +export function parseSystemMessagePayload( + message: TimelineMessage, +): SystemMessagePayload | null { + try { + return JSON.parse(message.body) as SystemMessagePayload; + } catch { + return null; + } +} + +/** + * Derives the synthetic payload describing a grouped membership row, or null + * when the group is not a shape this module can describe. + * + * Must stay total over everything `buildTimelineItems` groups — + * `membershipGroupPayload.test.mjs` asserts that with a matrix invariant. + */ +export function buildGroupedMembershipPayload( + messages: readonly TimelineMessage[], +): SystemMessagePayload | null { + if (messages.length < 2) return null; + + const payloads = messages.map(parseSystemMessagePayload); + const joinedThenLeft = buildJoinedThenLeftPayload(payloads); + if (joinedThenLeft) return joinedThenLeft; + + const arrivals = payloads.map((payload) => { + const payloadActor = payload?.actor ? normalizePubkey(payload.actor) : null; + const payloadTarget = payload?.target + ? normalizePubkey(payload.target) + : null; + if (payload?.type !== "member_joined" || !payloadActor || !payloadTarget) { + return null; + } + return { actor: payloadActor, target: payloadTarget }; + }); + if (arrivals.some((arrival) => !arrival)) return null; + + const membershipArrivals = arrivals as Array<{ + actor: string; + target: string; + }>; + const targets = [...new Set(membershipArrivals.map(({ target }) => target))]; + return { + arrivals: membershipArrivals, + type: "members_arrived", + target: targets[0], + targets, + }; +} + +/** + * One lifecycle summary for N>=1 equivalent self-arrivals of a member followed + * by that same member departing. + * + * Mirrors `membershipChangesCanGroup`: a departure only groups with self-arrivals + * of the departing member, and the builder absorbs *every* contiguous one, not + * just the nearest. Duplicate `member_joined` events are a real relay artifact — + * `handle_put_user` re-emits one on each PUT_USER — so pinning this to a 2-event + * pair dropped the departure from the timeline entirely. + */ +function buildJoinedThenLeftPayload( + payloads: readonly (SystemMessagePayload | null)[], +): SystemMessagePayload | null { + if (payloads.length < 2) return null; + + const departure = payloads[payloads.length - 1]; + const departureActor = departure?.actor + ? normalizePubkey(departure.actor) + : null; + if (departure?.type !== "member_left" || !departureActor) return null; + + const arrivals = payloads.slice(0, -1); + const arrivalTarget = arrivals[0]?.target; + if (!arrivalTarget) return null; + + const everyArrivalIsSelfJoinByDeparture = arrivals.every((arrival) => { + const actor = arrival?.actor ? normalizePubkey(arrival.actor) : null; + const target = arrival?.target ? normalizePubkey(arrival.target) : null; + return ( + arrival?.type === "member_joined" && + actor !== null && + target !== null && + actor === target && + target === departureActor + ); + }); + if (!everyArrivalIsSelfJoinByDeparture) return null; + + return { type: "member_joined_then_left", target: arrivalTarget }; +} diff --git a/desktop/src/features/messages/lib/systemEventCopy.ts b/desktop/src/features/messages/lib/systemEventCopy.ts index 99f7316165f..0158a260393 100644 --- a/desktop/src/features/messages/lib/systemEventCopy.ts +++ b/desktop/src/features/messages/lib/systemEventCopy.ts @@ -23,6 +23,14 @@ export function addedByActionPrefix(isCurrentUser: boolean): string { return isCurrentUser ? "were added by" : "added by"; } +/** + * Generic add caption when the timeline should state that someone was added + * without attributing the action to a single actor. + */ +export function addedActionPrefix(isCurrentUser: boolean): string { + return isCurrentUser ? "were added" : "was added"; +} + /** * Caption for a channel topic or purpose change. * diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs index f7b91b6db0c..56570ed2d73 100644 --- a/desktop/src/features/messages/lib/timelineItems.test.mjs +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -155,6 +155,71 @@ test("buildTimelineItems: contiguous self-joins group across different members", ); }); +test("buildTimelineItems: mixed arrival actors collapse into one cohort", () => { + const start = dayAt(2026, 6, 14); + const entries = [ + memberAddedEntry({ + actor: "viewer", + createdAt: start, + id: "elrond", + target: "elrond", + }), + memberAddedEntry({ + actor: "elrond", + createdAt: start + 60, + id: "legolas", + target: "legolas", + }), + memberAddedEntry({ + actor: "elrond", + createdAt: start + 120, + id: "gimli", + target: "gimli", + }), + memberAddedEntry({ + actor: "viewer", + createdAt: start + 180, + id: "gandalf", + target: "gandalf", + }), + ]; + + const { items } = buildTimelineItems(entries, null); + assert.deepEqual(kinds(items), ["day-divider", "system-group"]); + const group = items.find((item) => item.kind === "system-group"); + assert.deepEqual( + group?.entries.map((groupEntry) => groupEntry.message.id), + ["elrond", "legolas", "gimli", "gandalf"], + ); + assert.equal(group?.key, "gandalf"); +}); + +test("buildTimelineItems: self-joins and additions share one arrival cohort", () => { + const start = dayAt(2026, 6, 14); + const entries = [ + memberJoinedEntry({ id: "a", target: "target-a", createdAt: start }), + memberAddedEntry({ + actor: "target-a", + id: "b", + target: "target-b", + createdAt: start + 60, + }), + memberJoinedEntry({ + id: "c", + target: "target-c", + createdAt: start + 120, + }), + ]; + + const { items } = buildTimelineItems(entries, null); + assert.deepEqual(kinds(items), ["day-divider", "system-group"]); + const group = items.find((item) => item.kind === "system-group"); + assert.deepEqual( + group?.entries.map((groupEntry) => groupEntry.message.id), + ["a", "b", "c"], + ); +}); + test("buildTimelineItems: prepending membership history preserves the loaded suffix", () => { const start = dayAt(2026, 6, 14); const loaded = [ @@ -194,28 +259,44 @@ test("buildTimelineItems: contiguous member additions extend a group outside one ); }); -test("buildTimelineItems: incompatible arrivals remain separate", () => { +test("buildTimelineItems: arrival cohorts stop at unread dividers", () => { const start = dayAt(2026, 6, 14); const entries = [ memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), memberAddedEntry({ - id: "b", actor: "actor-b", + id: "b", target: "target-b", createdAt: start + 30, }), - entry({ id: "message", createdAt: start + 60 }), - memberAddedEntry({ + memberJoinedEntry({ id: "c", - actor: "actor-b", target: "target-c", - createdAt: start + 90, + createdAt: start + 60, }), + ]; + + const { items } = buildTimelineItems(entries, "b"); + assert.deepEqual(kinds(items), [ + "day-divider", + "system", + "unread-divider", + "system-group", + ]); +}); + +test("buildTimelineItems: arrival cohorts stop at day boundaries", () => { + const entries = [ memberAddedEntry({ - id: "self-join", - actor: "target-d", - target: "target-d", - createdAt: start + 120, + actor: "viewer", + id: "a", + target: "target-a", + createdAt: dayAt(2026, 6, 14, 23, 59), + }), + memberJoinedEntry({ + id: "b", + target: "target-b", + createdAt: dayAt(2026, 6, 15, 0, 0), }), ]; @@ -223,13 +304,49 @@ test("buildTimelineItems: incompatible arrivals remain separate", () => { assert.deepEqual(kinds(items), [ "day-divider", "system", + "day-divider", "system", - "message", - "system", + ]); +}); + +test("buildTimelineItems: arrival cohorts stop at non-membership rows", () => { + const start = dayAt(2026, 6, 14); + const entries = [ + memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), + entry({ id: "message", createdAt: start + 60 }), + memberAddedEntry({ + actor: "actor-b", + id: "b", + target: "target-c", + createdAt: start + 90, + }), + memberJoinedEntry({ id: "c", target: "target-d", createdAt: start + 120 }), + ]; + + const { items } = buildTimelineItems(entries, null); + assert.deepEqual(kinds(items), [ + "day-divider", "system", + "message", + "system-group", ]); }); +test("buildTimelineItems: arrival cohorts stop at >1 hour adjacent gaps", () => { + const start = dayAt(2026, 6, 14); + const entries = [ + memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), + memberJoinedEntry({ + id: "b", + target: "target-b", + createdAt: start + 3_601, + }), + ]; + + const { items } = buildTimelineItems(entries, null); + assert.deepEqual(kinds(items), ["day-divider", "system", "system"]); +}); + test("buildTimelineItems: a member joining then leaving is one lifecycle group", () => { const start = dayAt(2026, 6, 14); const entries = [ @@ -246,6 +363,30 @@ test("buildTimelineItems: a member joining then leaving is one lifecycle group", ); }); +test("buildTimelineItems: duplicate self-joins then leaving stay one group keyed by the departure", () => { + const start = dayAt(2026, 6, 14); + const entries = [ + memberJoinedEntry({ id: "joined-1", target: "member-a", createdAt: start }), + memberJoinedEntry({ + id: "joined-2", + target: "member-a", + createdAt: start + 30, + }), + memberLeftEntry({ id: "left", target: "member-a", createdAt: start + 90 }), + ]; + + const { items } = buildTimelineItems(entries, null); + assert.deepEqual(kinds(items), ["day-divider", "system-group"]); + const group = items.find((item) => item.kind === "system-group"); + assert.deepEqual( + group?.entries.map((groupEntry) => groupEntry.message.id), + ["joined-1", "joined-2", "left"], + ); + // Anchored on the newest entry so prepending older history cannot repartition + // the loaded rows; splitting this into separate rows would change both. + assert.equal(group?.key, "left"); +}); + test("buildTimelineItems: consecutive same-author messages within the window are grouped", () => { const entries = [ entry({ id: "a", pubkey: "author-a", createdAt: dayAt(2026, 6, 14) }), diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index db5753bcf20..917350733fd 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -106,17 +106,10 @@ function membershipChangesCanGroup( first: MembershipChangePayload, second: MembershipChangePayload, ): boolean { - if (first.mode === "self-arrival") { - return ( - second.mode === "self-arrival" || - (second.mode === "departure" && first.target === second.target) - ); + if (second.mode === "departure") { + return first.mode === "self-arrival" && first.target === second.target; } - return ( - first.mode === "addition" && - second.mode === "addition" && - first.actor === second.actor - ); + return first.mode !== "departure"; } /** @@ -126,10 +119,14 @@ function membershipChangesCanGroup( * its contents, but not its identity or the virtual list's existing key suffix. * * Compatible membership activities stay together while they are contiguous. - * Self-joins and additions from one administrator each form their own summary; - * a self-join immediately followed by that member leaving becomes a single - * lifecycle summary. Each adjacent event must fall within the one-hour activity - * window, so uninterrupted activity can extend beyond an hour overall. + * Arrival cohorts are actor-neutral even when self-joins and additions mix, but + * one or more equivalent self-joins followed by that member leaving remain a + * single lifecycle summary — every contiguous self-arrival of the departing + * member is absorbed, since the relay re-emits `member_joined` on each + * PUT_USER. `buildGroupedMembershipPayload` must describe every group this + * emits; `membershipGroupPayload.test.mjs` pins that with a matrix invariant. + * Each adjacent event must fall within the one-hour activity window, so + * uninterrupted activity can extend beyond an hour overall. */ function buildMembershipGroups( entries: readonly MainTimelineEntry[], diff --git a/desktop/src/features/messages/ui/SystemMessageRow.test.mjs b/desktop/src/features/messages/ui/SystemMessageRow.test.mjs new file mode 100644 index 00000000000..fc55dd27cc5 --- /dev/null +++ b/desktop/src/features/messages/ui/SystemMessageRow.test.mjs @@ -0,0 +1,567 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + Element: dom.window.Element, + getComputedStyle: dom.window.getComputedStyle, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + MutationObserver: dom.window.MutationObserver, + Node: dom.window.Node, + SVGElement: dom.window.SVGElement, + window: dom.window, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + dom.window.HTMLElement.prototype.scrollIntoView = () => {}; +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +function systemMessage({ actor, createdAt = 1, id, reactions = [], target }) { + return { + author: "System", + body: JSON.stringify({ type: "member_joined", actor, target }), + createdAt, + depth: 0, + id, + kind: 40099, + reactions, + time: "12:00 PM", + }; +} + +function memberLeftMessage({ actor, createdAt = 1, id, reactions = [] }) { + return { + author: "System", + body: JSON.stringify({ type: "member_left", actor }), + createdAt, + depth: 0, + id, + kind: 40099, + reactions, + time: "12:00 PM", + }; +} + +function reaction(emoji, { reactedByCurrentUser = false } = {}) { + return { + emoji, + count: 1, + reactedByCurrentUser, + users: [{ avatarUrl: null, displayName: "Zed", pubkey: "99".repeat(32) }], + }; +} + +function profileFor(pubkey, displayName) { + return { + [pubkey]: { + avatarUrl: null, + displayName, + isAgent: false, + name: null, + nip05Handle: null, + ownerPubkey: null, + }, + }; +} + +function normalizeText(text) { + return text.replace(/\s+/g, " ").trim(); +} + +async function renderSystemMessageRow({ + currentPubkey = "viewer", + groupedMessages, + message = groupedMessages[0], + onToggleReaction, + profiles, +}) { + const { createElement } = await import("react"); + const { render } = await import("@testing-library/react"); + const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + ); + const { TooltipProvider } = await import("@/shared/ui/tooltip"); + const { SystemMessageRow } = await import("./SystemMessageRow.tsx"); + const queryClient = new QueryClient(); + + render( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement( + TooltipProvider, + null, + createElement(SystemMessageRow, { + currentPubkey, + groupedMessages, + message, + onToggleReaction, + profiles, + }), + ), + ), + ); +} + +test("grouped duplicate arrival targets render one unique added member", async () => { + const { screen } = await import("@testing-library/react"); + const target = "11".repeat(32); + const firstActor = "12".repeat(32); + const secondActor = "13".repeat(32); + const groupedMessages = [ + systemMessage({ actor: firstActor, createdAt: 1, id: "a", target }), + systemMessage({ actor: secondActor, createdAt: 2, id: "b", target }), + ]; + + await renderSystemMessageRow({ + groupedMessages, + profiles: { + [target]: { + avatarUrl: null, + displayName: "Elrond", + isAgent: false, + name: null, + nip05Handle: null, + ownerPubkey: null, + }, + }, + }); + + const row = screen.getByTestId("system-message-row"); + assert.equal(normalizeText(row.textContent ?? ""), "Elrond was added"); + assert.equal( + screen + .getByTestId("system-message-avatar-stack") + .getAttribute("aria-label"), + "1 channel member", + ); +}); + +test("grouped duplicate arrival targets keep viewer grammar truthful", async () => { + const { screen } = await import("@testing-library/react"); + const viewer = "10".repeat(32); + const firstActor = "11".repeat(32); + const secondActor = "12".repeat(32); + const groupedMessages = [ + systemMessage({ + actor: firstActor, + createdAt: 1, + id: "a", + target: viewer, + }), + systemMessage({ + actor: secondActor, + createdAt: 2, + id: "b", + target: viewer, + }), + ]; + + await renderSystemMessageRow({ + currentPubkey: viewer, + groupedMessages, + profiles: { + [viewer]: { + avatarUrl: null, + displayName: "Viewer", + isAgent: false, + name: null, + nip05Handle: null, + ownerPubkey: null, + }, + }, + }); + + const row = screen.getByTestId("system-message-row"); + assert.equal(normalizeText(row.textContent ?? ""), "You were added"); +}); + +test("grouped mixed additions render mechanism-truthful copy", async () => { + const { screen } = await import("@testing-library/react"); + const viewer = "10".repeat(32); + const elrond = "11".repeat(32); + const legolas = "12".repeat(32); + const gimli = "13".repeat(32); + const gandalf = "14".repeat(32); + const groupedMessages = [ + systemMessage({ actor: viewer, createdAt: 1, id: "a", target: elrond }), + systemMessage({ actor: elrond, createdAt: 2, id: "b", target: legolas }), + systemMessage({ actor: elrond, createdAt: 3, id: "c", target: gimli }), + systemMessage({ actor: viewer, createdAt: 4, id: "d", target: gandalf }), + ]; + + await renderSystemMessageRow({ + currentPubkey: viewer, + groupedMessages, + profiles: { + [elrond]: { + avatarUrl: null, + displayName: "Elrond", + isAgent: false, + name: null, + nip05Handle: null, + ownerPubkey: null, + }, + [legolas]: { + avatarUrl: null, + displayName: "Legolas", + isAgent: false, + name: null, + nip05Handle: null, + ownerPubkey: null, + }, + [gimli]: { + avatarUrl: null, + displayName: "Gimli", + isAgent: false, + name: null, + nip05Handle: null, + ownerPubkey: null, + }, + [gandalf]: { + avatarUrl: null, + displayName: "Gandalf", + isAgent: false, + name: null, + nip05Handle: null, + ownerPubkey: null, + }, + }, + }); + + const row = screen.getByTestId("system-message-row"); + assert.equal( + normalizeText(row.textContent ?? ""), + "Elrond was added along with Legolas, Gimli, and Gandalf", + ); +}); + +test("grouped self-joins render joined copy", async () => { + const { screen } = await import("@testing-library/react"); + const elrond = "11".repeat(32); + const legolas = "12".repeat(32); + const groupedMessages = [ + systemMessage({ actor: elrond, createdAt: 1, id: "a", target: elrond }), + systemMessage({ actor: legolas, createdAt: 2, id: "b", target: legolas }), + ]; + + await renderSystemMessageRow({ + groupedMessages, + profiles: { + [elrond]: { + avatarUrl: null, + displayName: "Elrond", + isAgent: false, + name: null, + nip05Handle: null, + ownerPubkey: null, + }, + [legolas]: { + avatarUrl: null, + displayName: "Legolas", + isAgent: false, + name: null, + nip05Handle: null, + ownerPubkey: null, + }, + }, + }); + + const row = screen.getByTestId("system-message-row"); + assert.equal( + normalizeText(row.textContent ?? ""), + "Elrond joined along with Legolas", + ); +}); + +test("grouped duplicate self-joins render singular joined copy", async () => { + const { screen } = await import("@testing-library/react"); + const elrond = "11".repeat(32); + const groupedMessages = [ + systemMessage({ actor: elrond, createdAt: 1, id: "a", target: elrond }), + systemMessage({ actor: elrond, createdAt: 2, id: "b", target: elrond }), + ]; + + await renderSystemMessageRow({ + groupedMessages, + profiles: { + [elrond]: { + avatarUrl: null, + displayName: "Elrond", + isAgent: false, + name: null, + nip05Handle: null, + ownerPubkey: null, + }, + }, + }); + + const row = screen.getByTestId("system-message-row"); + assert.equal(normalizeText(row.textContent ?? ""), "Elrond joined"); +}); + +test("grouped self-joins plus additions render neutral arrival copy", async () => { + const { screen } = await import("@testing-library/react"); + const viewer = "10".repeat(32); + const elrond = "11".repeat(32); + const legolas = "12".repeat(32); + const groupedMessages = [ + systemMessage({ actor: elrond, createdAt: 1, id: "a", target: elrond }), + systemMessage({ actor: viewer, createdAt: 2, id: "b", target: legolas }), + ]; + + await renderSystemMessageRow({ + currentPubkey: viewer, + groupedMessages, + profiles: { + [elrond]: { + avatarUrl: null, + displayName: "Elrond", + isAgent: false, + name: null, + nip05Handle: null, + ownerPubkey: null, + }, + [legolas]: { + avatarUrl: null, + displayName: "Legolas", + isAgent: false, + name: null, + nip05Handle: null, + ownerPubkey: null, + }, + }, + }); + + const row = screen.getByTestId("system-message-row"); + assert.equal( + normalizeText(row.textContent ?? ""), + "Elrond arrived along with Legolas", + ); +}); + +test("grouped duplicate mixed-mechanism arrivals render singular neutral copy", async () => { + const { screen } = await import("@testing-library/react"); + const viewer = "10".repeat(32); + const elrond = "11".repeat(32); + const groupedMessages = [ + systemMessage({ actor: elrond, createdAt: 1, id: "a", target: elrond }), + systemMessage({ actor: viewer, createdAt: 2, id: "b", target: elrond }), + ]; + + await renderSystemMessageRow({ + currentPubkey: viewer, + groupedMessages, + profiles: { + [elrond]: { + avatarUrl: null, + displayName: "Elrond", + isAgent: false, + name: null, + nip05Handle: null, + ownerPubkey: null, + }, + }, + }); + + const row = screen.getByTestId("system-message-row"); + assert.equal(normalizeText(row.textContent ?? ""), "Elrond arrived"); +}); + +// --- joined-then-left lifecycle groups --------------------------------------- +// +// `buildTimelineItems` groups every contiguous equivalent self-arrival with the +// matching departure, so the renderer must describe N>=1 arrivals + 1 departure. +// When it cannot, `SystemMessageRow` falls back to the group's *oldest* message +// and the departure never renders — the timeline claims a departed member is +// still present. + +test("a duplicate self-join followed by leaving renders one lifecycle summary", async () => { + const { screen } = await import("@testing-library/react"); + const elrond = "11".repeat(32); + const groupedMessages = [ + systemMessage({ actor: elrond, createdAt: 1, id: "a", target: elrond }), + systemMessage({ actor: elrond, createdAt: 2, id: "b", target: elrond }), + memberLeftMessage({ actor: elrond, createdAt: 3, id: "c" }), + ]; + + await renderSystemMessageRow({ + groupedMessages, + profiles: profileFor(elrond, "Elrond"), + }); + + const row = screen.getByTestId("system-message-row"); + assert.equal( + normalizeText(row.textContent ?? ""), + "Elrond joined, then left the channel", + ); +}); + +test("more than two duplicate self-joins before leaving still render one lifecycle summary", async () => { + const { screen } = await import("@testing-library/react"); + const elrond = "11".repeat(32); + const groupedMessages = [ + systemMessage({ actor: elrond, createdAt: 1, id: "a", target: elrond }), + systemMessage({ actor: elrond, createdAt: 2, id: "b", target: elrond }), + systemMessage({ actor: elrond, createdAt: 3, id: "c", target: elrond }), + memberLeftMessage({ actor: elrond, createdAt: 4, id: "d" }), + ]; + + await renderSystemMessageRow({ + groupedMessages, + profiles: profileFor(elrond, "Elrond"), + }); + + const row = screen.getByTestId("system-message-row"); + assert.equal( + normalizeText(row.textContent ?? ""), + "Elrond joined, then left the channel", + ); +}); + +test("a single self-join followed by leaving keeps the two-event lifecycle copy", async () => { + const { screen } = await import("@testing-library/react"); + const elrond = "11".repeat(32); + const groupedMessages = [ + systemMessage({ actor: elrond, createdAt: 1, id: "a", target: elrond }), + memberLeftMessage({ actor: elrond, createdAt: 2, id: "b" }), + ]; + + await renderSystemMessageRow({ + groupedMessages, + profiles: profileFor(elrond, "Elrond"), + }); + + const row = screen.getByTestId("system-message-row"); + assert.equal( + normalizeText(row.textContent ?? ""), + "Elrond joined, then left the channel", + ); +}); + +test("an addition before the departing member's self-join is not a lifecycle summary", async () => { + const { screen } = await import("@testing-library/react"); + const viewer = "10".repeat(32); + const elrond = "11".repeat(32); + // `buildTimelineItems` never groups this shape — an addition cannot precede a + // departure in one group. Hand-built here so the arrivals guard stays + // falsifiable: dropping it would let "was added" collapse into "joined, then + // left", attributing a self-join the member never made. + const groupedMessages = [ + systemMessage({ actor: viewer, createdAt: 1, id: "a", target: elrond }), + systemMessage({ actor: elrond, createdAt: 2, id: "b", target: elrond }), + memberLeftMessage({ actor: elrond, createdAt: 3, id: "c" }), + ]; + + await renderSystemMessageRow({ + currentPubkey: viewer, + groupedMessages, + profiles: profileFor(elrond, "Elrond"), + }); + + const row = screen.getByTestId("system-message-row"); + assert.notEqual( + normalizeText(row.textContent ?? ""), + "Elrond joined, then left the channel", + ); +}); + +test("a duplicate self-join lifecycle group surfaces reactions from every source event", async () => { + const { screen } = await import("@testing-library/react"); + const elrond = "11".repeat(32); + const groupedMessages = [ + systemMessage({ + actor: elrond, + createdAt: 1, + id: "a", + reactions: [reaction("👍")], + target: elrond, + }), + systemMessage({ + actor: elrond, + createdAt: 2, + id: "b", + reactions: [reaction("🚀")], + target: elrond, + }), + memberLeftMessage({ + actor: elrond, + createdAt: 3, + id: "c", + reactions: [reaction("🎉")], + }), + ]; + + await renderSystemMessageRow({ + groupedMessages, + onToggleReaction: async () => {}, + profiles: profileFor(elrond, "Elrond"), + }); + + for (const emoji of ["👍", "🚀", "🎉"]) { + assert.ok( + screen.queryByText(emoji), + `expected the grouped row to surface ${emoji}`, + ); + } +}); + +test("removing a reaction from a duplicate self-join lifecycle group targets every reacted source", async () => { + const { act, fireEvent, screen } = await import("@testing-library/react"); + const elrond = "11".repeat(32); + const groupedMessages = [ + systemMessage({ + actor: elrond, + createdAt: 1, + id: "a", + reactions: [reaction("👍", { reactedByCurrentUser: true })], + target: elrond, + }), + systemMessage({ actor: elrond, createdAt: 2, id: "b", target: elrond }), + memberLeftMessage({ + actor: elrond, + createdAt: 3, + id: "c", + reactions: [reaction("👍", { reactedByCurrentUser: true })], + }), + ]; + const removals = []; + + await renderSystemMessageRow({ + groupedMessages, + onToggleReaction: async (source, emoji, remove) => { + if (remove) removals.push([source.id, emoji]); + }, + profiles: profileFor(elrond, "Elrond"), + }); + + // `act` (not a bare microtask drain) so the removal's optimistic state update + // and its awaited fan-out across every reacted source both settle before the + // assertion — a bare `fireEvent` leaves that update outside act. + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Toggle 👍 reaction" })); + }); + + assert.deepEqual(removals.sort(), [ + ["a", "👍"], + ["c", "👍"], + ]); +}); diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index 3c43f4dd062..4b74930da8f 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -26,6 +26,12 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { + buildGroupedMembershipPayload, + parseSystemMessagePayload, + type SystemMessagePayload, +} from "../lib/membershipGroupPayload"; +import { + addedActionPrefix, addedByActionPrefix, describeChannelTextFieldChange, toInlineName, @@ -45,21 +51,6 @@ import { const SYSTEM_ACTION_BUTTON_CLASS = "h-6 w-6 rounded-full p-0"; const SYSTEM_ACTION_ICON_CLASS = "!h-4 !w-4"; -type SystemMessagePayload = { - type: string; - actor?: string; - target?: string; - targets?: string[]; - topic?: string; - purpose?: string; - // Moderation tombstone fields (kind:40099 "message_deleted"). All optional and - // moderator-authored — present when a moderator removed the message, absent for - // a plain member self-delete. Reporter identity/evidence never appears here. - public_reason?: string; - reason_code?: string; - action_id?: string; -}; - type SystemMessageDescription = { action: React.ReactNode; title: React.ReactNode; @@ -67,97 +58,6 @@ type SystemMessageDescription = { const MAX_VISIBLE_ADDITIONAL_MEMBER_NAMES = 3; -function parseSystemMessagePayload( - message: TimelineMessage, -): SystemMessagePayload | null { - try { - return JSON.parse(message.body) as SystemMessagePayload; - } catch { - return null; - } -} - -function buildGroupedMembershipPayload( - messages: readonly TimelineMessage[], -): SystemMessagePayload | null { - if (messages.length < 2) return null; - - const payloads = messages.map(parseSystemMessagePayload); - const joinedThenLeft = buildJoinedThenLeftPayload(payloads); - if (joinedThenLeft) return joinedThenLeft; - - const arrivals = payloads.map((payload) => { - const payloadActor = payload?.actor ? normalizePubkey(payload.actor) : null; - const payloadTarget = payload?.target - ? normalizePubkey(payload.target) - : null; - if (payload?.type !== "member_joined" || !payloadActor || !payloadTarget) { - return null; - } - return { actor: payloadActor, target: payloadTarget }; - }); - if (arrivals.some((arrival) => !arrival)) return null; - - const membershipArrivals = arrivals as { - actor: string; - target: string; - }[]; - const targets = membershipArrivals.map(({ target }) => target); - const isSelfJoinGroup = membershipArrivals.every( - ({ actor, target }) => actor === target, - ); - - if (isSelfJoinGroup) { - return { - type: "members_joined", - target: targets[0], - targets, - }; - } - - const actor = membershipArrivals[0].actor; - const isSameAdderGroup = membershipArrivals.every( - ({ actor: candidateActor, target }) => - candidateActor === actor && candidateActor !== target, - ); - if (!isSameAdderGroup) { - return null; - } - - return { - type: "members_added", - actor, - target: targets[0], - targets, - }; -} - -function buildJoinedThenLeftPayload( - payloads: readonly (SystemMessagePayload | null)[], -): SystemMessagePayload | null { - if (payloads.length !== 2) return null; - - const [arrival, departure] = payloads; - const arrivalTarget = arrival?.target - ? normalizePubkey(arrival.target) - : null; - const departureActor = departure?.actor - ? normalizePubkey(departure.actor) - : null; - if ( - arrival?.type !== "member_joined" || - departure?.type !== "member_left" || - !arrival.actor || - !arrivalTarget || - normalizePubkey(arrival.actor) !== arrivalTarget || - arrivalTarget !== departureActor - ) { - return null; - } - - return { type: "member_joined_then_left", target: arrival.target }; -} - function aggregateGroupedReactions( messages: readonly TimelineMessage[], ): TimelineReaction[] { @@ -319,7 +219,7 @@ function ProfileName({ function membershipActivityPubkeys(payload: SystemMessagePayload): string[] { const pubkeys = - payload.type === "members_added" || payload.type === "members_joined" + payload.type === "members_arrived" ? (payload.targets ?? []) : payload.type === "member_removed" ? [payload.target ?? payload.actor] @@ -458,6 +358,148 @@ function MemberNamesInlineList({ ); } +function describeGroupedArrivals({ + agentPubkeys, + currentPubkey, + isTargetCurrentUser, + membershipTitle, + payload, + personaLookup, + profiles, +}: { + agentPubkeys?: ReadonlySet; + currentPubkey: string | undefined; + isTargetCurrentUser: boolean; + membershipTitle: React.ReactNode; + payload: SystemMessagePayload; + personaLookup?: Map; + profiles: UserProfileLookup | undefined; +}): SystemMessageDescription | null { + const arrivals = payload.arrivals; + if (!arrivals?.length || !payload.targets?.length) return null; + + const sharedActor = arrivals[0].actor; + const hasSelfJoins = arrivals.some(({ actor, target }) => actor === target); + const hasAdditions = arrivals.some(({ actor, target }) => actor !== target); + const isSameAdderGroup = + hasAdditions && + arrivals.every( + ({ actor, target }) => actor === sharedActor && actor !== target, + ); + const isAllAdditionGroup = hasAdditions && !hasSelfJoins; + const isAllSelfJoinGroup = hasSelfJoins && !hasAdditions; + const additionalTargets = payload.targets.slice(1); + + if (payload.targets.length === 1) { + if (isSameAdderGroup) { + return { + title: membershipTitle, + action: ( + <> + {addedByActionPrefix(isTargetCurrentUser)}{" "} + + {resolveInlineDisplayLabel(sharedActor, currentPubkey, profiles)} + + + ), + }; + } + + if (isAllAdditionGroup) { + return { + title: membershipTitle, + action: addedActionPrefix(isTargetCurrentUser), + }; + } + + if (isAllSelfJoinGroup) { + return { + title: membershipTitle, + action: "joined", + }; + } + + return { + title: membershipTitle, + action: "arrived", + }; + } + + if (isSameAdderGroup) { + return { + title: membershipTitle, + action: ( + <> + {addedByActionPrefix(isTargetCurrentUser)}{" "} + + {resolveInlineDisplayLabel(sharedActor, currentPubkey, profiles)} + + , along with{" "} + + + ), + }; + } + + if (isAllAdditionGroup) { + return { + title: membershipTitle, + action: ( + <> + {addedActionPrefix(isTargetCurrentUser)} along with{" "} + + + ), + }; + } + + if (isAllSelfJoinGroup) { + return { + title: membershipTitle, + action: ( + <> + joined along with{" "} + + + ), + }; + } + + return { + title: membershipTitle, + action: ( + <> + arrived along with{" "} + + + ), + }; +} + function describeSystemEvent( payload: SystemMessagePayload, currentPubkey: string | undefined, @@ -509,48 +551,16 @@ function describeSystemEvent( ); switch (payload.type) { - case "members_added": - if (!payload.actor || !payload.targets?.length) return null; - return { - title: membershipTitle, - action: ( - <> - {addedByActionPrefix(isTargetCurrentUser)}{" "} - - {resolveInlineDisplayLabel( - payload.actor, - currentPubkey, - profiles, - )} - - , along with{" "} - - - ), - }; - case "members_joined": - if (!payload.targets?.length) return null; - return { - title: membershipTitle, - action: ( - <> - joined the channel along with{" "} - - - ), - }; + case "members_arrived": + return describeGroupedArrivals({ + agentPubkeys, + currentPubkey, + isTargetCurrentUser, + membershipTitle, + payload, + personaLookup, + profiles, + }); case "member_joined_then_left": if (!payload.target) return null; return { @@ -730,9 +740,7 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ return null; } const isMembershipArrival = - payload.type === "member_joined" || - payload.type === "members_added" || - payload.type === "members_joined"; + payload.type === "member_joined" || payload.type === "members_arrived"; const isMembershipActivity = isMembershipArrival || payload.type === "member_joined_then_left" || diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index d7097e9e54c..a0503880d6c 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -34,11 +34,44 @@ const PROFILE_ONLY_AGENT_PUBKEY = const OWNED_AGENT_PROFILE_PUBKEY = "1212121212121212121212121212121212121212121212121212121212121212"; const HUDDLE_EPHEMERAL_CHANNEL_ID = "3f9f2c4e-8b7a-4b1c-9d2e-5a6f7c8d9e0f"; +const ELROND_PUBKEY = "31".repeat(32); +const LEGOLAS_PUBKEY = "32".repeat(32); +const GIMLI_PUBKEY = "33".repeat(32); +const GANDALF_PUBKEY = "34".repeat(32); +const STANDARD_GROUPED_ARRIVAL_ACTOR = { + pubkey: "10".repeat(32), + displayName: "Alice Chen", +}; +const STANDARD_GROUPED_ARRIVAL_TARGETS = [ + { pubkey: "11".repeat(32), displayName: "Erica Chapman" }, + { pubkey: "12".repeat(32), displayName: "Peter Griffin" }, + { pubkey: "13".repeat(32), displayName: "Marcia Thomas" }, + { pubkey: "14".repeat(32), displayName: "Jordan Lee" }, + { pubkey: "15".repeat(32), displayName: "Olivia Park" }, + { pubkey: "16".repeat(32), displayName: "Sam Rivera" }, +]; +const JOIN_COLLAPSE_PROFILES = [ + { pubkey: ELROND_PUBKEY, displayName: "Elrond" }, + { pubkey: LEGOLAS_PUBKEY, displayName: "Legolas" }, + { pubkey: GIMLI_PUBKEY, displayName: "Gimli" }, + { pubkey: GANDALF_PUBKEY, displayName: "Gandalf" }, +]; +const JOIN_COLLAPSE_CHANNEL_NAME = "random"; const SYSTEM_MESSAGE_KIND = 40099; const DM_THREAD_AGENT_MENTION_ERROR_TEXT = "Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent."; const DM_THREAD_MEMBERS_LOADING_ERROR_TEXT = "Checking conversation members. Try again in a moment."; +const JOIN_COLLAPSE_SPLIT_TEXTS = [ + "Elrond added by you", + "Legolas added by Elrond, along with Gimli", + "Gandalf added by you", +]; +const JOIN_COLLAPSE_GROUPED_TEXT = + "Elrond was added along with Legolas, Gimli, and Gandalf"; +const JOIN_COLLAPSE_CAPTURE_WIDTH = 560; +const JOIN_COLLAPSE_CAPTURE_HEIGHT = 260; +const JOIN_COLLAPSE_CAPTURE_VERTICAL_PADDING = 24; /** Locator scoped to the mention autocomplete dropdown inside the composer. */ function autocomplete(page: import("@playwright/test").Page) { @@ -236,6 +269,91 @@ async function waitForTimelineSettled(page: import("@playwright/test").Page) { await expect(page.locator("[data-render-pending]")).toHaveCount(0); } +function normalizeVisibleText(text: string) { + return text.replace(/\s+,/g, ",").replace(/\s+/g, " ").trim(); +} + +function findDuplicateFixturePubkeys( + profiles: Array<{ pubkey: string; displayName: string }>, +) { + const namesByPubkey = new Map(); + + for (const profile of profiles) { + const names = namesByPubkey.get(profile.pubkey) ?? []; + names.push(profile.displayName); + namesByPubkey.set(profile.pubkey, names); + } + + return [...namesByPubkey.entries()] + .filter(([, names]) => names.length > 1) + .map(([pubkey, displayNames]) => ({ pubkey, displayNames })); +} + +async function collectJoinCollapseRows(page: import("@playwright/test").Page) { + const rows = page.getByTestId("system-message-row").filter({ + hasText: /Elrond|Legolas|Gimli|Gandalf/, + }); + const texts: string[] = []; + const count = await rows.count(); + for (let index = 0; index < count; index += 1) { + texts.push(normalizeVisibleText(await rows.nth(index).innerText())); + } + return { rows, texts }; +} + +async function maybeCaptureJoinCollapseTimeline( + page: import("@playwright/test").Page, +) { + const capturePath = process.env.JOIN_COLLAPSE_CAPTURE_PATH; + if (!capturePath) return; + await waitForAnimations(page); + const timeline = page.getByTestId("message-timeline"); + const timelineBox = await timeline.boundingBox(); + const { rows } = await collectJoinCollapseRows(page); + const rowBoxes = await Promise.all( + Array.from({ length: await rows.count() }, (_, index) => + rows.nth(index).boundingBox(), + ), + ); + const visibleRowBoxes = rowBoxes.filter( + (box): box is NonNullable => box !== null, + ); + if (!timelineBox || visibleRowBoxes.length === 0) { + throw new Error("Join-collapse screenshot target is not visible"); + } + const minRowY = Math.min(...visibleRowBoxes.map((box) => box.y)); + const maxRowY = Math.max(...visibleRowBoxes.map((box) => box.y + box.height)); + const minRowX = Math.min(...visibleRowBoxes.map((box) => box.x)); + const maxRowX = Math.max(...visibleRowBoxes.map((box) => box.x + box.width)); + const desiredTop = minRowY - JOIN_COLLAPSE_CAPTURE_VERTICAL_PADDING; + const desiredBottom = maxRowY + JOIN_COLLAPSE_CAPTURE_VERTICAL_PADDING; + const desiredCenter = (desiredTop + desiredBottom) / 2; + const desiredHorizontalCenter = (minRowX + maxRowX) / 2; + const viewportHeight = page.viewportSize()?.height ?? 720; + const clip = { + x: Math.max( + Math.round(timelineBox.x), + Math.min( + Math.round(desiredHorizontalCenter - JOIN_COLLAPSE_CAPTURE_WIDTH / 2), + Math.round( + timelineBox.x + timelineBox.width - JOIN_COLLAPSE_CAPTURE_WIDTH, + ), + ), + ), + y: Math.max( + 0, + Math.min( + Math.round(desiredCenter - JOIN_COLLAPSE_CAPTURE_HEIGHT / 2), + viewportHeight - JOIN_COLLAPSE_CAPTURE_HEIGHT, + ), + ), + width: JOIN_COLLAPSE_CAPTURE_WIDTH, + height: JOIN_COLLAPSE_CAPTURE_HEIGHT, + }; + await page.screenshot({ path: capturePath, clip }); + console.log(`JOIN_COLLAPSE_CAPTURE_PATH=${capturePath}`); +} + async function expectOwnedAgentProfileActions( profilePopover: import("@playwright/test").Locator, pubkey: string, @@ -3611,14 +3729,14 @@ test("system add rows use plain names while remove rows retain agent mention sty ], }); await page.goto("/"); - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); - await waitForMockLiveSubscription(page, "general", SYSTEM_MESSAGE_KIND); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND); await page.evaluate( ({ actorPubkey, kind, targetPubkey }) => { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ - channelName: "general", + channelName: "random", content: JSON.stringify({ type: "member_joined", actor: actorPubkey, @@ -3627,7 +3745,7 @@ test("system add rows use plain names while remove rows retain agent mention sty kind, }); window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ - channelName: "general", + channelName: "random", content: JSON.stringify({ type: "member_removed", actor: actorPubkey, @@ -3664,32 +3782,22 @@ test("system add rows use plain names while remove rows retain agent mention sty test("groups contiguous arrival activity with hidden names in the standard tooltip", async ({ page, }) => { - const actor = { - pubkey: "10".repeat(32), - displayName: "Alice Chen", - }; - const targets = [ - { pubkey: "11".repeat(32), displayName: "Erica Chapman" }, - { pubkey: "12".repeat(32), displayName: "Peter Griffin" }, - { pubkey: "13".repeat(32), displayName: "Marcia Thomas" }, - { pubkey: "14".repeat(32), displayName: "Jordan Lee" }, - { pubkey: "15".repeat(32), displayName: "Olivia Park" }, - { pubkey: "16".repeat(32), displayName: "Sam Rivera" }, - ]; + const actor = STANDARD_GROUPED_ARRIVAL_ACTOR; + const targets = STANDARD_GROUPED_ARRIVAL_TARGETS; await installMockBridge(page, { searchProfiles: [actor, ...targets], }); await page.goto("/"); - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); - await waitForMockLiveSubscription(page, "general", SYSTEM_MESSAGE_KIND); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND); await page.evaluate( ({ actorPubkey, addedTargets, kind }) => { const createdAt = Math.floor(Date.now() / 1_000); for (const [index, target] of addedTargets.entries()) { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ - channelName: "general", + channelName: "random", content: JSON.stringify({ type: "member_joined", actor: actorPubkey, @@ -3753,16 +3861,119 @@ test("groups contiguous arrival activity with hidden names in the standard toolt await expect(avatarStack.locator("..")).toHaveCSS("align-items", "center"); }); +test("keeps deterministic grouped-arrival fixture pubkeys unique", () => { + expect( + findDuplicateFixturePubkeys([ + STANDARD_GROUPED_ARRIVAL_ACTOR, + ...STANDARD_GROUPED_ARRIVAL_TARGETS, + ...JOIN_COLLAPSE_PROFILES, + ]), + ).toEqual([]); +}); + +test("collapses contiguous mixed join arrivals into one actor-neutral cohort", async ({ + page, +}) => { + await installMockBridge(page, { + searchProfiles: JOIN_COLLAPSE_PROFILES, + }); + await page.goto("/"); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText( + JOIN_COLLAPSE_CHANNEL_NAME, + ); + await waitForMockLiveSubscription( + page, + JOIN_COLLAPSE_CHANNEL_NAME, + SYSTEM_MESSAGE_KIND, + ); + + await page.evaluate( + ({ channelName, currentUser, elrond, legolas, gimli, gandalf, kind }) => { + const createdAt = Math.floor(Date.now() / 1_000); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + content: JSON.stringify({ + type: "member_joined", + actor: currentUser, + target: elrond, + }), + createdAt, + kind, + pubkey: currentUser, + }); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + content: JSON.stringify({ + type: "member_joined", + actor: elrond, + target: legolas, + }), + createdAt: createdAt + 1, + kind, + pubkey: elrond, + }); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + content: JSON.stringify({ + type: "member_joined", + actor: elrond, + target: gimli, + }), + createdAt: createdAt + 2, + kind, + pubkey: elrond, + }); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + content: JSON.stringify({ + type: "member_joined", + actor: currentUser, + target: gandalf, + }), + createdAt: createdAt + 3, + kind, + pubkey: currentUser, + }); + }, + { + channelName: JOIN_COLLAPSE_CHANNEL_NAME, + currentUser: MOCK_VIEWER_PUBKEY, + elrond: ELROND_PUBKEY, + legolas: LEGOLAS_PUBKEY, + gimli: GIMLI_PUBKEY, + gandalf: GANDALF_PUBKEY, + kind: SYSTEM_MESSAGE_KIND, + }, + ); + await waitForTimelineSettled(page); + await page.getByTestId("message-timeline").evaluate((element) => { + element.scrollTop = element.scrollHeight; + element.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await waitForAnimations(page); + + const { rows, texts } = await collectJoinCollapseRows(page); + console.log(`JOIN_COLLAPSE_VISIBLE_TEXTS=${JSON.stringify(texts)}`); + if (process.env.JOIN_COLLAPSE_EXPECT_SPLIT === "1") { + expect(texts).toEqual(JOIN_COLLAPSE_SPLIT_TEXTS); + } + await maybeCaptureJoinCollapseTimeline(page); + + await expect(rows).toHaveCount(1); + await expect(rows.first()).toContainText(JOIN_COLLAPSE_GROUPED_TEXT); +}); + test("system agent profile exposes owned agent actions", async ({ page }) => { await page.goto("/"); - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); - await waitForMockLiveSubscription(page, "general", SYSTEM_MESSAGE_KIND); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND); await page.evaluate( ({ actorPubkey, kind, targetPubkey }) => { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ - channelName: "general", + channelName: "random", content: JSON.stringify({ type: "member_joined", actor: actorPubkey, @@ -3825,8 +4036,7 @@ test("system agent activity avatar stack is decorative", async ({ page }) => { const joinedRow = page .getByTestId("system-message-row") - .filter({ hasText: "mira" }) - .filter({ hasText: "joined the channel" }); + .filter({ has: page.getByText("mira", { exact: true }) }); const avatarStack = joinedRow.getByTestId("system-message-avatar-stack"); await expect(avatarStack.getByTestId("system-message-avatar")).toHaveCount(1); await expect(avatarStack.locator("button")).toHaveCount(0); @@ -3873,6 +4083,57 @@ test("membership activity folds a member joining then leaving", async ({ ); }); +test("membership activity folds duplicate self-joins then leaving into one row", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND); + + // The relay re-emits `member_joined` on each PUT_USER, so a member can arrive + // twice before leaving. Both arrivals group with the departure; describing + // only a 2-event pair dropped the departure and left the member rendered as + // still present. + await page.evaluate( + ({ alicePubkey, kind }) => { + const createdAt = Math.floor(Date.now() / 1_000); + const join = { + type: "member_joined", + actor: alicePubkey, + target: alicePubkey, + }; + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: JSON.stringify(join), + createdAt, + kind, + }); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: JSON.stringify(join), + createdAt: createdAt + 1, + kind, + }); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: JSON.stringify({ type: "member_left", actor: alicePubkey }), + createdAt: createdAt + 2, + kind, + }); + }, + { alicePubkey: TEST_IDENTITIES.alice.pubkey, kind: SYSTEM_MESSAGE_KIND }, + ); + await waitForTimelineSettled(page); + + const aliceRows = page + .getByTestId("system-message-row") + .filter({ hasText: "alice" }); + await expect(aliceRows).toHaveCount(1); + await expect(aliceRows).toHaveText(/joined, then left the channel/); + await expect(aliceRows.getByTestId("system-message-avatar")).toHaveCount(1); +}); + test("profile-only agent author hides actions without agent access", async ({ page, }) => { @@ -3937,8 +4198,7 @@ test("system member-joined rows render the joined person as a plain profile name const joinedRow = page .getByTestId("system-message-row") - .filter({ hasText: "bob" }) - .filter({ hasText: "joined the channel" }); + .filter({ has: page.getByText("bob", { exact: true }) }); const joinedPersonName = joinedRow.getByText("bob", { exact: true }); await expect(joinedPersonName).toBeVisible(); From 4b0744d7f3161d9f045dd67a73e014e4440c2b47 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 3 Sep 2026 23:52:55 -0400 Subject: [PATCH 004/283] test(desktop): await Bestie drag and profile hover endpoints (#7294) --- desktop/tests/e2e/bestie.spec.ts | 6 ++++++ .../e2e/message-feedback-snapshots.spec.ts | 18 ++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/desktop/tests/e2e/bestie.spec.ts b/desktop/tests/e2e/bestie.spec.ts index e5281e2cd82..ba4b93b9b8d 100644 --- a/desktop/tests/e2e/bestie.spec.ts +++ b/desktop/tests/e2e/bestie.spec.ts @@ -223,6 +223,12 @@ test("assigns from an agent profile, reopens, drags, and offers the message acti await expect(page.getByTestId("bestie-activity-dot").last()).toBeVisible(); await page.getByRole("button", { name: "Close Bestie" }).click(); await waitForAnimations(page); + // The Motion close morph can outlive the CSS/Web animations above. Wait for + // the closed, untranslated shell before measuring the drag's starting point. + const closedBloom = floatingAvatar.getByTestId("bestie-bloom-container"); + await expect(closedBloom).toHaveCSS("width", "48px"); + await expect(closedBloom).toHaveCSS("height", "48px"); + await expect(closedBloom).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)"); const beforeDrag = await floatingAvatar.boundingBox(); const collapsedAvatar = floatingAvatar.getByTestId("bestie-trigger-avatar"); const collapsedAvatarBeforeDrag = await collapsedAvatar.boundingBox(); diff --git a/desktop/tests/e2e/message-feedback-snapshots.spec.ts b/desktop/tests/e2e/message-feedback-snapshots.spec.ts index 5bdcade3597..e05951dc002 100644 --- a/desktop/tests/e2e/message-feedback-snapshots.spec.ts +++ b/desktop/tests/e2e/message-feedback-snapshots.spec.ts @@ -100,11 +100,21 @@ test("profile hover uses the channel hover surface", async ({ page }) => { const profile = page.getByTestId("sidebar-profile-card"); const channel = page.getByTestId("channel-random"); + await expect(page.locator("html")).toHaveAttribute("data-buzz-sidebar", ""); + const hoverSurface = await channel.evaluate((element) => { + const probe = document.createElement("span"); + probe.style.backgroundColor = "var(--buzz-hover-surface)"; + element.append(probe); + const color = getComputedStyle(probe).backgroundColor; + probe.remove(); + return color; + }); + // Equal unfinished (transparent) surfaces must not satisfy hover parity. + expect(hoverSurface).not.toMatch(/^(transparent|rgba\(.*,\s*0\))$/); await channel.hover(); - // Wait for the CSS transition to settle before capturing the hover color so - // a mid-transition sample does not produce a value the profile card (which - // uses the same token) can never match. - await waitForAnimations(page); + // Observe the semantic endpoint, not an intermediate transition sample or + // the shared animation helper's timeout-as-success ceiling. + await expect(channel).toHaveCSS("background-color", hoverSurface); const channelHoverColor = await channel.evaluate( (element) => getComputedStyle(element).backgroundColor, ); From b1f6b7ef770dddbb7f33c9f5861c379a47bca1d6 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 4 Sep 2026 16:45:13 +1000 Subject: [PATCH 005/283] feat(desktop): preserve mentions across copy and paste (#7228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying a message out of the timeline lost the mention. The rendered chip drops the `@` for display, so the clipboard carried "John Smith" — two ordinary words no composer could bind back to a pubkey. Pasting into another channel produced dead text, and sending it tagged nobody. ## What changed Every Buzz copy now writes two clipboard flavors in one transaction: - **`text/plain`** — readable anywhere, sigils restored, no pubkeys. This is what TextEdit, Slack, and every other external app receive. - **`text/html`** — the same content with each mention wrapped in a span carrying `data-mention-pubkey` / `-label` / `-kind`. On paste, the composer harvests those records, registers each `name → pubkey` with the existing mention machinery, and inserts the content: the chip re-lights and the send path recovers the identity the author tagged. A marker attribute records what the plain flavor holds, so a Markdown copy pastes through the text pipeline and a rendered copy through the HTML one. **Covered surfaces:** timeline selection copy, thread-panel selection copy, forum post/reply selection copy, "Copy message", and composer copy/cut — plus paste in both the channel and forum composers. ## Trust boundary Clipboard HTML is untrusted, and the branch treats it that way: - Records are capped (50), labels bounded (200 chars), and a pubkey must be 64 hex before it can become a `p` tag. - **Only mentions the paste actually shows are registered.** An empty `` would otherwise rebind that display name for the rest of the composer session, so a later hand-written @Jane Doe would chip-light convincingly against the attacker's key. Each branch registers only the records whose label appears in the text *it* inserts, matched with `getMentionOffsets` — the same matcher the send-time extractor uses. - **The visibility gate reads only what ProseMirror will insert.** `DOMParser` hard-drops `script`, `style`, `title`, `noscript`, `object`, and `head` content, so `visible` beside an empty chip span used to smuggle a binding past the gate. Those elements are stripped before either output is derived. - **A partial chip never gains a sigil.** A selection crossing a chip boundary falls through to the browser's default copy, which serializes the full identity attributes around a slice of the text — pasting that invented "@Smith" out of "John Smith". Paste now leaves a fragment as plain text, tolerating only what a whole chip picks up in transit (restored sigil, author casing, U+00A0 swaps, and the label cap's own ellipsis). Both clipboard sides share one `matchChipTextToLabel` helper, in the module that owns the label attributes, so copy and paste cannot drift on what counts as a whole chip. ## Notes - Mention matching reuses `getMentionOffsets`, so code spans and fences are excluded and the longest display name wins. - The plain flavor inlines chip boxes before reading `innerText`; a chip is a flex container, so the browser's own copy split "@John Smith" onto its own line. - `MarkdownMention` and `MacEmacsTextShortcuts` are extracted verbatim from `markdown.tsx` and `useRichTextEditor.ts` to keep both files under the size gate. ## Testing - ~40 unit tests over the flavor builder/parser, the visibility filter, the ignored-tag sweep, and the chip-match verdicts. - A Playwright spec (`desktop/tests/e2e/mention-clipboard.spec.ts`) driving real copy/cut/paste DOM events: timeline and "Copy message" of a multi-word non-member mention pasted into another channel and sent with the original pubkey in its `p` tag; the forum copy → forum reply round trip; composer copy/cut; plain flavor asserted to contain no 64-hex string; the boundary-crossing drag; and the hidden-record and `" + + `'; + const content = normalizeMentionClipboardContent(html); + + assert.equal(content.text.includes("Jane Doe"), false); + assert.equal(content.html.includes("Jane Doe"), false); + assert.deepEqual(visibleIdentities(html, content.text), []); +}); + +for (const tag of ["script", "style", "title", "noscript", "object"]) { + test(`<${tag}> text reaches neither output`, () => { + const content = normalizeMentionClipboardContent( + `

visible

<${tag}>@Jane Doe `, + ); + assert.equal(content.text.includes("Jane Doe"), false); + assert.equal(content.html.includes("Jane Doe"), false); + // The content the paste does insert survives the sweep. + assert.equal(content.text.includes("visible"), true); + }); +} + +test("a leading visible", + ); + assert.equal(content.text.includes("Jane Doe"), false); + assert.equal(content.html.includes("Jane Doe"), false); + assert.equal(content.text.includes("visible"), true); +}); + +test("a mention span nested in an ignored tag contributes nothing", () => { + // A DOMParser document has scripting disabled, so