From 263c9bf76c18f0cde6cec9fb43d22f8895319380 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:22:42 -0400 Subject: [PATCH 1/7] fix(desktop): restore the agent trading-card mint button (#5900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem PR #5574's profile-panel redesign dropped `ProfileSummaryView`'s `onCreateCard` prop — the only caller of `setCardMintTarget` — so the entire Agent Trading Cards feature (#3278) became unreachable from the GUI while staying fully wired underneath: mint dialog, background job store, viewer, gallery, composer chip, and the Rust `mint_agent_card`/`save_agent_card` commands all survive at main. `git log -S 'setCardMintTarget('` shows exactly two commits: the feature and the accidental removal. ## Outcome The mint trigger returns as a management row in the agent profile's Info tab, directly under **Export agent**, gated `isBot && canManagePersona` exactly like Duplicate/Export. Target resolution is byte-for-byte the original logic: prefer the live instance pubkey, fall back to the persona/definition id, allow locking only when an instance keypair exists. ## Shape - `UserProfileAgentManagementRows`: new optional `onCreateCard` row (Sparkles icon, `user-profile-create-card-row`), placed after Export. - Prop threaded `UserProfilePanel` → `ProfileSummaryView` → `ProfileInfoTabContent` → management rows, mirroring `onExportAgent` at every layer. - The mint-target state + open callback move into a `useCardMint` hook in `UserProfilePersonaDialogs` (beside the `CardMintTarget` type it manages). This keeps `UserProfilePanel.tsx` at 999 lines — the file sits at the size-ratchet cap and may not grow. ## Validation - `pnpm check` green (biome, file-size ratchet, px-text, pubkey-truncation). - `pnpm typecheck` green. - Full desktop unit suite: **4888 passed, 0 failed**. - Profile e2e spec: **32 passed**, including the updated management-row-order assertion and a new click → mint-dialog-visible → Escape → closed exercise of the restored row. Verified at `bff3110a0aeb3d63683eac9ed3e587829f9436da`, one commit atop main `01f76ec97`. Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .../ui/UserProfileAgentManagementRows.tsx | 14 ++++++++++ .../features/profile/ui/UserProfilePanel.tsx | 10 +++---- .../profile/ui/UserProfilePanelSections.tsx | 4 +++ .../profile/ui/UserProfilePanelTabs.tsx | 5 ++++ .../profile/ui/UserProfilePersonaDialogs.tsx | 27 +++++++++++++++++++ desktop/tests/e2e/profile.spec.ts | 6 +++++ 6 files changed, 61 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx b/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx index 8c6b4138c..7b8a58622 100644 --- a/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx +++ b/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx @@ -4,6 +4,7 @@ import { ArchiveRestore, CopyPlus, Download, + Sparkles, Trash2, type LucideIcon, } from "lucide-react"; @@ -30,6 +31,7 @@ export function UserProfileAgentManagementRows({ canDeleteAgent, isDeletePending, managedAgent, + onCreateCard, onDeleteAgent, onDuplicateAgent, onExportAgent, @@ -39,11 +41,14 @@ export function UserProfileAgentManagementRows({ canDeleteAgent: boolean; isDeletePending: boolean; managedAgent?: ManagedAgent; + /** Mint an agent trading card. Present only for owner-managed personas. */ + onCreateCard?: () => void; onDeleteAgent: () => void; onDuplicateAgent?: () => void; onExportAgent?: () => void; }) { if ( + !onCreateCard && !onDuplicateAgent && !onExportAgent && !canArchiveAgent && @@ -72,6 +77,15 @@ export function UserProfileAgentManagementRows({ testId="user-profile-export-agent-row" /> ) : null} + {onCreateCard ? ( + + ) : null} {canArchiveAgent ? ( ) : null} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 3fae4da26..998ea3232 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -66,7 +66,7 @@ import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelD import { useProfileFieldBuckets } from "@/features/profile/ui/UserProfilePanelFields"; import { submitProfilePersonaDialog } from "@/features/profile/ui/UserProfilePanelPersonaSubmit"; import { - type CardMintTarget, + useCardMint, UserProfilePersonaDialogs, } from "@/features/profile/ui/UserProfilePersonaDialogs"; import { @@ -179,8 +179,6 @@ export function UserProfilePanel({ React.useState(null); const [personaToExportSnapshot, setPersonaToExportSnapshot] = React.useState(null); - const [cardMintTarget, setCardMintTarget] = - React.useState(null); const [requestedInstancePubkey, setRequestedInstancePubkey] = React.useState< string | null >(null); @@ -712,6 +710,7 @@ export function UserProfilePanel({ resolvedPersona, ); const canManagePersona = isOwner === true && resolvedPersona !== undefined; + const cardMint = useCardMint(resolvedPersona, managedAgent); const canDeletePersona = canManagePersona && !resolvedPersona?.sourceTeam; const canDeleteProfileAgent = isBot && @@ -822,6 +821,7 @@ export function UserProfilePanel({ agentSettingsFields={agentSettingsFields} diagnosticsFields={diagnosticsFields} onAddToChannel={() => setAddToChannelOpen(true)} + onCreateCard={isBot && canManagePersona ? cardMint.create : undefined} onDeleteAgent={handleDeleteProfileAgent} onDuplicateAgent={ isBot && canManagePersona ? handleDuplicatePersona : undefined @@ -935,7 +935,7 @@ export function UserProfilePanel({ const personaDialogs = ( <> setCardMintTarget(null)} + onCloseCardMint={cardMint.close} onCloseDelete={() => setPersonaToDelete(null)} onCloseDialog={() => setPersonaDialogState(null)} onCloseExportSnapshot={() => setPersonaToExportSnapshot(null)} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 08286a455..dbe17473f 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -94,6 +94,8 @@ export type ProfileSummaryViewProps = { agentSettingsFields: ProfileField[]; diagnosticsFields: ProfileField[]; onAddToChannel: () => void; + /** Mint an agent trading card. Present only for owner-managed personas. */ + onCreateCard?: () => void; onDeleteAgent: () => void; onDuplicateAgent?: () => void; onExportAgent?: () => void; @@ -168,6 +170,7 @@ export function ProfileSummaryView({ agentSettingsFields, diagnosticsFields, onAddToChannel, + onCreateCard, onDeleteAgent, onDuplicateAgent, onExportAgent, @@ -512,6 +515,7 @@ export function ProfileSummaryView({ isDeleteAgentPending={isAgentActionPending} managedAgent={managedAgent} onEditAgent={handleEditAgent} + onCreateCard={onCreateCard} onDeleteAgent={onDeleteAgent} onDuplicateAgent={onDuplicateAgent} onExportAgent={onExportAgent} diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index 65626b7fa..0db96a022 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -206,6 +206,7 @@ export function ProfileInfoTabContent({ isArchived, isDeleteAgentPending, managedAgent, + onCreateCard, onDeleteAgent, onDuplicateAgent, onExportAgent, @@ -225,6 +226,8 @@ export function ProfileInfoTabContent({ isArchived: boolean; isDeleteAgentPending: boolean; managedAgent?: ManagedAgent; + /** Mint an agent trading card. Present only for owner-managed personas. */ + onCreateCard?: () => void; onDeleteAgent: () => void; onDuplicateAgent?: () => void; onExportAgent?: () => void; @@ -257,6 +260,7 @@ export function ProfileInfoTabContent({ !hasInfoFields && !showArchiveAction && !canDeleteAgent && + !onCreateCard && !onDuplicateAgent && !onExportAgent && !showActivityIngress && @@ -306,6 +310,7 @@ export function ProfileInfoTabContent({ canDeleteAgent={canDeleteAgent} isDeletePending={isDeleteAgentPending} managedAgent={managedAgent} + onCreateCard={onCreateCard} onDeleteAgent={onDeleteAgent} onDuplicateAgent={onDuplicateAgent} onExportAgent={onExportAgent} diff --git a/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx b/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx index 9fae1bd88..5038c0600 100644 --- a/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx @@ -1,7 +1,10 @@ +import * as React from "react"; + import type { AcpRuntimeCatalogEntry, AgentPersona, CreatePersonaInput, + ManagedAgent, UpdatePersonaInput, } from "@/shared/api/types"; import { AgentCardMintDialog } from "@/features/agents/ui/AgentCardMintDialog"; @@ -17,6 +20,30 @@ export type CardMintTarget = { canLock: boolean; }; +/** + * Card-mint dialog state plus the callback that opens it. `create` is + * undefined when no persona resolves; owner gating is the caller's job. + */ +export function useCardMint( + persona: AgentPersona | undefined, + managedAgent: ManagedAgent | undefined, +) { + const [target, setTarget] = React.useState(null); + const close = React.useCallback(() => setTarget(null), []); + const create = persona + ? () => + setTarget({ + // Prefer the live instance pubkey; fall back to the + // persona/definition id (same resolution as export). + id: managedAgent?.pubkey ?? persona.id, + name: persona.displayName, + // Locking needs an instance keypair to encrypt to. + canLock: Boolean(managedAgent?.pubkey), + }) + : undefined; + return { close, create, target }; +} + export function UserProfilePersonaDialogs({ cardMintTarget, createError, diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 9f17f9e50..6c88b628c 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1466,6 +1466,7 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a expect(managementRowOrder).toEqual([ "user-profile-duplicate-agent-row", "user-profile-export-agent-row", + "user-profile-create-card-row", "user-profile-archive-agent-row", "user-profile-delete-agent-row", ]); @@ -1486,6 +1487,11 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a const exportDialog = page.getByTestId("agent-snapshot-export-dialog"); await expect(exportDialog).toBeVisible(); await exportDialog.getByRole("button", { name: "Cancel" }).click(); + await page.getByTestId("user-profile-create-card-row").click(); + const cardMintDialog = page.getByTestId("agent-card-mint-dialog"); + await expect(cardMintDialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(cardMintDialog).toHaveCount(0); const archiveAgentRow = page.getByTestId("user-profile-archive-agent-row"); await expect(archiveAgentRow).toHaveText(/Archive agent/); await archiveAgentRow.click(); From 09768100ec3420f0aa7cd278bd00fe0baab5de8d Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 14 Aug 2026 15:28:06 -0600 Subject: [PATCH 2/7] fix(ci): read Playwright version without nested shell quoting (#5910) ## Summary - replace the nested one-line shell quoting used to read the Playwright package version - write the resolved version to `GITHUB_OUTPUT` from a multiline shell step ## Why The `desktop-v0.5.12` release smoke job failed before executing tests because Bash received escaped quotes inside command substitution and parsed the Node expression as shell syntax. ## Validation - `bash scripts/test-release-ref-contract.sh` - isolated execution of the new shell fragment with a fixture `@playwright/test/package.json`, producing `version=1.58.2` - `git diff --check` Signed-off-by: Wes Co-authored-by: Carl --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2114e3d56..5d73b4d00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -795,7 +795,9 @@ jobs: run: just desktop-install-ci - name: Get Playwright version id: pw-version - run: echo "version=$(cd desktop && node -e \"console.log(require('@playwright/test/package.json').version)\")" >> "$GITHUB_OUTPUT" + run: | + PLAYWRIGHT_VERSION=$(cd desktop && node -p "require('@playwright/test/package.json').version") + echo "version=$PLAYWRIGHT_VERSION" >> "$GITHUB_OUTPUT" - name: Restore Playwright browser cache id: playwright-cache uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 From 51beba603886d34e751349d12b33c0c5aeb92c28 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 14 Aug 2026 16:10:09 -0600 Subject: [PATCH 3/7] chore(release): release Buzz Desktop version 0.5.13 (#5912) ## Buzz Desktop release v0.5.13 - **Frozen main:** `09768100ec3420f0aa7cd278bd00fe0baab5de8d` - **Reviewed candidate:** `a239e0f6793ac6e88ccf92cc231054090a9753cc` - **Previous desktop release:** `desktop-v0.5.12` - **Proposed immutable tag:** `desktop-v0.5.13` 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: Wes Co-authored-by: Release Automation --- .release/desktop-candidate.json | 14 +++++++------- CHANGELOG.md | 14 ++++++++++++++ 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, 25 insertions(+), 11 deletions(-) diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 2cb878328..4aa559a08 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.12", - "base_sha": "757779bb1ef22cc4a1c233344baa0946d907e5a6", - "previous_tag": "desktop-v0.5.11", - "previous_base_sha": "4749bc7be3cdb78c2db4ce4864775ba7ab60b4cc", - "previous_merge_sha": "9e0c6b4320190f80b81998e9e5cbf2214d597dd2", - "tag": "desktop-v0.5.12", - "commit_count": 34 + "version": "0.5.13", + "base_sha": "09768100ec3420f0aa7cd278bd00fe0baab5de8d", + "previous_tag": "desktop-v0.5.12", + "previous_base_sha": "757779bb1ef22cc4a1c233344baa0946d907e5a6", + "previous_merge_sha": "1f4c69eccf012dc58737e9265498397215c706c5", + "tag": "desktop-v0.5.13", + "commit_count": 4 } diff --git a/CHANGELOG.md b/CHANGELOG.md index d169c31f2..fee611b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v0.5.13 + +### Desktop and shared changes + +- fix(desktop): restore the agent trading-card mint button ([#5900](https://github.com/block/buzz/pull/5900)) ([`263c9bf76c18f0cde6cec9fb43d22f8895319380`](https://github.com/block/buzz/commit/263c9bf76c18f0cde6cec9fb43d22f8895319380)) +- Projects v3: unify sharing, discussions, and issue ownership ([#5792](https://github.com/block/buzz/pull/5792)) ([`122a8b8988869f0b1a7c056a76f7d16bfb0f6fdd`](https://github.com/block/buzz/commit/122a8b8988869f0b1a7c056a76f7d16bfb0f6fdd)) + +### Other repository changes + +- fix(ci): read Playwright version without nested shell quoting ([#5910](https://github.com/block/buzz/pull/5910)) ([`09768100ec3420f0aa7cd278bd00fe0baab5de8d`](https://github.com/block/buzz/commit/09768100ec3420f0aa7cd278bd00fe0baab5de8d)) +- fix(mobile): unwrap batched observer telemetry ([#5805](https://github.com/block/buzz/pull/5805)) ([`0bb7c60f824a05ac4d8c8569ee1e74d200069b45`](https://github.com/block/buzz/commit/0bb7c60f824a05ac4d8c8569ee1e74d200069b45)) + +[Compare desktop-v0.5.12...desktop-v0.5.13](https://github.com/block/buzz/compare/desktop-v0.5.12...desktop-v0.5.13) + ## v0.5.12 ### Desktop and shared changes diff --git a/desktop/package.json b/desktop/package.json index d13366cae..9935de578 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.12", + "version": "0.5.13", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 0527d5b14..f24c367be 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.12" +version = "0.5.13" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 0710e63bd..21a42f8bc 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.12" +version = "0.5.13" 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 fbf8aa546..a09357590 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.12", + "version": "0.5.13", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From 1b3dbcaaea882eeea90359c1db02e306d2f4f50a Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 14 Aug 2026 18:04:37 -0600 Subject: [PATCH 4/7] ci(release): remove desktop smoke gate (#5914) ## Summary - remove the GitHub-hosted desktop smoke job from the desktop release workflow - remove the smoke result from manifest assembly dependencies and promotion conditions - retain the local smoke tooling for future repair and targeted validation The first release execution of this gate spent its full 10-minute Playwright timeout traversing the 10,000-row fixture, then produced a 987 MB diagnostics upload. All signed platform builds succeeded, but the smoke prevented manifest publication. This restores the previously established release boundary while the harness is made suitable for CI separately. ### Testing - parsed `.github/workflows/release.yml` with Ruby Psych and asserted the smoke job/dependencies are absent - `scripts/test-release-ref-contract.sh` - exact pushed commit passed the repository pre-push hook Signed-off-by: Wes Co-authored-by: Carl --- .github/workflows/release.yml | 62 +---------------------------------- 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d73b4d00..2b0eb25c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -775,65 +775,6 @@ jobs: ${{ steps.artifacts.outputs.exe }} ${{ steps.artifacts.outputs.sig }} - desktop-release-smoke: - name: Desktop release smoke - if: github.repository == 'block/buzz' - runs-on: ubuntu-latest - needs: setup - timeout-minutes: 20 - permissions: - contents: read - env: - PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - ref: ${{ needs.setup.outputs.source_sha }} - persist-credentials: false - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Install desktop dependencies - run: just desktop-install-ci - - name: Get Playwright version - id: pw-version - run: | - PLAYWRIGHT_VERSION=$(cd desktop && node -p "require('@playwright/test/package.json').version") - echo "version=$PLAYWRIGHT_VERSION" >> "$GITHUB_OUTPUT" - - name: Restore Playwright browser cache - id: playwright-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Install Playwright Chromium - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: cd desktop && pnpm exec playwright install chromium - - name: Install Playwright system dependencies - run: cd desktop && pnpm exec playwright install-deps chromium - - name: Save Playwright browser cache - if: steps.playwright-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Build test relay - run: cargo build --profile ci -p buzz-relay - - name: Run deterministic correctness smoke - env: - BUZZ_E2E_RELAY_BIN: ${{ github.workspace }}/target/ci/buzz-relay - BUZZ_RELEASE_SMOKE_ARTIFACT_DIR: ${{ github.workspace }}/release-smoke-artifacts - run: just desktop-release-smoke - - name: Upload release-smoke diagnostics - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: desktop-release-smoke - path: | - release-smoke-artifacts - desktop/test-results - desktop/playwright-release-smoke-report - if-no-files-found: warn - retention-days: 14 - assemble-manifest: name: Assemble multi-platform latest.json # Only the tag-bound setup path can reach this job. @@ -844,10 +785,9 @@ jobs: needs.release-macos-x64.result == 'success' && needs.release-linux.result == 'success' && needs.release-windows.result == 'success' && - needs.desktop-release-smoke.result == 'success' && github.ref == format('refs/tags/desktop-v{0}', needs.setup.outputs.version) runs-on: ubuntu-latest - needs: [setup, release, release-macos-x64, release-linux, release-windows, desktop-release-smoke] + needs: [setup, release, release-macos-x64, release-linux, release-windows] timeout-minutes: 10 permissions: contents: write From 82f7ed1532f50e0d28afca5580ed522f1c2ef1ca Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 14 Aug 2026 18:35:49 -0600 Subject: [PATCH 5/7] chore(release): release Buzz Desktop version 0.5.14 (#5917) ## Buzz Desktop release v0.5.14 - **Frozen main:** `1b3dbcaaea882eeea90359c1db02e306d2f4f50a` - **Reviewed candidate:** `391495e7d347d20b67e39e3c240d17ef63c5c2c0` - **Previous desktop release:** `desktop-v0.5.13` - **Proposed immutable tag:** `desktop-v0.5.14` 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: Wes Co-authored-by: Release Automation --- .release/desktop-candidate.json | 14 +++++++------- CHANGELOG.md | 12 ++++++++++++ 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, 23 insertions(+), 11 deletions(-) diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 4aa559a08..2c06a06c6 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.13", - "base_sha": "09768100ec3420f0aa7cd278bd00fe0baab5de8d", - "previous_tag": "desktop-v0.5.12", - "previous_base_sha": "757779bb1ef22cc4a1c233344baa0946d907e5a6", - "previous_merge_sha": "1f4c69eccf012dc58737e9265498397215c706c5", - "tag": "desktop-v0.5.13", - "commit_count": 4 + "version": "0.5.14", + "base_sha": "1b3dbcaaea882eeea90359c1db02e306d2f4f50a", + "previous_tag": "desktop-v0.5.13", + "previous_base_sha": "09768100ec3420f0aa7cd278bd00fe0baab5de8d", + "previous_merge_sha": "51beba603886d34e751349d12b33c0c5aeb92c28", + "tag": "desktop-v0.5.14", + "commit_count": 1 } diff --git a/CHANGELOG.md b/CHANGELOG.md index fee611b5d..9248c6fbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v0.5.14 + +### Desktop and shared changes + +- None + +### Other repository changes + +- ci(release): remove desktop smoke gate ([#5914](https://github.com/block/buzz/pull/5914)) ([`1b3dbcaaea882eeea90359c1db02e306d2f4f50a`](https://github.com/block/buzz/commit/1b3dbcaaea882eeea90359c1db02e306d2f4f50a)) + +[Compare desktop-v0.5.13...desktop-v0.5.14](https://github.com/block/buzz/compare/desktop-v0.5.13...desktop-v0.5.14) + ## v0.5.13 ### Desktop and shared changes diff --git a/desktop/package.json b/desktop/package.json index 9935de578..39e93d8a9 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.13", + "version": "0.5.14", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index f24c367be..887e1282f 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.13" +version = "0.5.14" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 21a42f8bc..527690df1 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.13" +version = "0.5.14" 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 a09357590..2f85c5d51 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.13", + "version": "0.5.14", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From 69107dc3bfecbb80cc5f5b8bb6a7647ad054ce57 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 15 Aug 2026 09:14:14 +0100 Subject: [PATCH 6/7] Polish mobile message threads and composer (#5645) ## Summary - refine mobile message metadata, search spacing, and Activity filter semantics - add channel-parity Latest navigation and stable tail following to threads - synchronize Android composer/keyboard geometry and keep Latest spacing stable across IME transitions ## Validation - `bin/just mobile-check` - `bin/just mobile-test` (1,276 tests) - Pixel 10 install/launch and channel/thread keyboard, Latest, tail, and back-navigation review - signed iPhone install/launch workflow ## Snapshots See the review snapshots below. --------- Signed-off-by: kenny lopez Signed-off-by: Kenny Lopez Signed-off-by: Princess Donut Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut --- .../lib/features/activity/activity_page.dart | 3 - .../activity_page/header_actions.dart | 64 +- .../activity/activity_page/inbox_row.dart | 2 +- .../features/channels/android_ime_lift.dart | 35 + .../channels/channel_detail_page.dart | 88 +- .../channel_detail_page/message_list.dart | 154 ++- mobile/lib/features/channels/compose_bar.dart | 8 + .../compose_bar/compose_bar_widget.dart | 162 ++- .../features/channels/compose_bar/dock.dart | 22 +- .../channels/compose_bar/helpers.dart | 109 +++ .../features/channels/compose_bar/layout.dart | 363 +++---- .../channels/ime_metrics_settle_observer.dart | 44 + .../channels/latest_message_button.dart | 77 ++ .../channels/thread_detail_helpers.dart | 36 +- .../features/channels/thread_detail_page.dart | 924 +++++++----------- .../nested_thread_summary_row.dart | 113 +++ .../thread_detail_page/tail_alignment.dart | 17 + .../thread_detail_page/thread_message.dart | 284 ++++++ mobile/lib/features/search/search_page.dart | 18 +- .../lib/shared/theme/message_typography.dart | 14 +- .../shared/widgets/message_author_meta.dart | 15 +- .../features/activity/activity_page_test.dart | 81 +- .../channels/android_ime_lift_test.dart | 93 ++ .../channels/channel_detail_page_test.dart | 782 ++++++++++++++- .../features/channels/compose_bar_test.dart | 114 ++- .../ime_metrics_settle_observer_test.dart | 49 + .../features/search/search_page_test.dart | 14 + .../shared/theme/message_typography_test.dart | 9 +- .../widgets/message_author_meta_test.dart | 27 +- 29 files changed, 2648 insertions(+), 1073 deletions(-) create mode 100644 mobile/lib/features/channels/android_ime_lift.dart create mode 100644 mobile/lib/features/channels/ime_metrics_settle_observer.dart create mode 100644 mobile/lib/features/channels/latest_message_button.dart create mode 100644 mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart create mode 100644 mobile/lib/features/channels/thread_detail_page/tail_alignment.dart create mode 100644 mobile/lib/features/channels/thread_detail_page/thread_message.dart create mode 100644 mobile/test/features/channels/android_ime_lift_test.dart create mode 100644 mobile/test/features/channels/ime_metrics_settle_observer_test.dart diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 99d0bbc03..dcae998ed 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -113,7 +113,6 @@ class ActivityPage extends HookConsumerWidget { final readState = ref.watch(readStateProvider); final localState = ref.watch(inboxLocalStateProvider); final drafts = ref.watch(composeDraftsProvider); - final dueReminderCount = ref.watch(dueReminderCountProvider); final allItems = ref.watch(inboxItemsProvider); final myPk = ref.watch(myPubkeyProvider); @@ -380,8 +379,6 @@ class ActivityPage extends HookConsumerWidget { actions: [ _ActivityActionsPill( filter: filter.value, - dueReminderCount: dueReminderCount, - draftCount: drafts.length, unreadOnly: unreadOnly.value, unreadCount: unreadVisibleCount, onFilterChanged: (f) => filter.value = f, diff --git a/mobile/lib/features/activity/activity_page/header_actions.dart b/mobile/lib/features/activity/activity_page/header_actions.dart index 08384b69e..0e40dd6c8 100644 --- a/mobile/lib/features/activity/activity_page/header_actions.dart +++ b/mobile/lib/features/activity/activity_page/header_actions.dart @@ -13,8 +13,6 @@ const _filterLabels = { class _ActivityActionsPill extends StatelessWidget { final InboxFilter filter; - final int dueReminderCount; - final int draftCount; final bool unreadOnly; final int unreadCount; final ValueChanged onFilterChanged; @@ -23,8 +21,6 @@ class _ActivityActionsPill extends StatelessWidget { const _ActivityActionsPill({ required this.filter, - required this.dueReminderCount, - required this.draftCount, required this.unreadOnly, required this.unreadCount, required this.onFilterChanged, @@ -45,12 +41,7 @@ class _ActivityActionsPill extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - _FilterMenuButton( - filter: filter, - dueReminderCount: dueReminderCount, - draftCount: draftCount, - onChanged: onFilterChanged, - ), + _FilterMenuButton(filter: filter, onChanged: onFilterChanged), _InboxOptionsButton( unreadOnly: unreadOnly, unreadCount: unreadCount, @@ -68,16 +59,9 @@ class _ActivityActionsPill extends StatelessWidget { /// inbox filter menu (`FILTER_OPTIONS`). class _FilterMenuButton extends StatelessWidget { final InboxFilter filter; - final int dueReminderCount; - final int draftCount; final ValueChanged onChanged; - const _FilterMenuButton({ - required this.filter, - required this.dueReminderCount, - required this.draftCount, - required this.onChanged, - }); + const _FilterMenuButton({required this.filter, required this.onChanged}); @override Widget build(BuildContext context) { @@ -121,12 +105,6 @@ class _FilterMenuButton extends StatelessWidget { ), ), ), - if (entry.key == InboxFilter.reminders && - dueReminderCount > 0) - _CountBadge(count: dueReminderCount) - else if (entry.key == InboxFilter.drafts && - draftCount > 0) - _CountBadge(count: draftCount), ], ), ), @@ -154,17 +132,6 @@ class _FilterMenuButton extends StatelessWidget { size: 16, color: navigationPrimaryForeground(context), ), - if (dueReminderCount > 0 || draftCount > 0) ...[ - const SizedBox(width: Grid.quarter), - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: context.colors.primary, - ), - ), - ], ], ), ), @@ -174,33 +141,6 @@ class _FilterMenuButton extends StatelessWidget { } } -class _CountBadge extends StatelessWidget { - final int count; - - const _CountBadge({required this.count}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric( - horizontal: Grid.half + Grid.quarter, - vertical: Grid.quarter, - ), - decoration: BoxDecoration( - color: navigationPrimaryForeground(context), - borderRadius: BorderRadius.circular(Grid.xxs), - ), - child: Text( - '$count', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onPrimary, - fontWeight: FontWeight.w600, - ), - ), - ); - } -} - /// Overflow menu with the unread-only toggle and mark-all-read, mirroring /// desktop's inbox options popover. class _InboxOptionsButton extends StatelessWidget { diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index 6d7f8f298..86edeea0e 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -244,7 +244,7 @@ class _InboxRow extends HookConsumerWidget { nameColor: context.colors.onSurface, metadataColor: mutedColor, nameStyle: activityUsernameTextStyle, - metadataStyle: + timestampStyle: activityTimestampTextStyle, displayNameKey: ValueKey( 'activity-author-${item.id}', diff --git a/mobile/lib/features/channels/android_ime_lift.dart b/mobile/lib/features/channels/android_ime_lift.dart new file mode 100644 index 000000000..30904055b --- /dev/null +++ b/mobile/lib/features/channels/android_ime_lift.dart @@ -0,0 +1,35 @@ +import 'dart:math' show max; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// Android keeps the message viewport fixed while the IME animates. Only this +/// small wrapper follows the frame-by-frame inset; timelines apply the final +/// inset once metrics settle. +class AndroidImeLift extends StatelessWidget { + /// The composer subtree that follows the animated Android IME inset. + final Widget child; + + /// Creates a wrapper that lifts [child] without resizing its surrounding + /// message viewport on Android. + const AndroidImeLift({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + if (!usesFixedAndroidImeViewport) return child; + final imeBottom = MediaQuery.viewInsetsOf(context).bottom; + final systemBottom = MediaQuery.viewPaddingOf(context).bottom; + return Padding( + // The composer already reserves [systemBottom]. Android's IME inset + // includes that navigation area, so lifting by the full value leaves a + // second safe-area gap above the keyboard. + padding: EdgeInsets.only(bottom: max(0, imeBottom - systemBottom)), + child: child, + ); + } +} + +/// Whether channel and thread scaffolds should keep a fixed viewport while +/// Android IME insets animate, with their composer lifted independently. +bool get usesFixedAndroidImeViewport => + defaultTargetPlatform == TargetPlatform.android; diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 7cf3532ac..8cef1a78f 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:math' show min; -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ScrollDirection; @@ -27,6 +26,7 @@ import '../profile/profile_provider.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../forum/forum_posts_view.dart'; +import 'android_ime_lift.dart'; import 'channel.dart'; import 'channel_actions_sheet.dart'; import 'channel_link_navigation.dart'; @@ -44,6 +44,8 @@ import 'date_formatters.dart'; import 'day_divider.dart'; import 'dm_channel_labels.dart'; import 'ephemeral_channel_display.dart'; +import 'ime_metrics_settle_observer.dart'; +import 'latest_message_button.dart'; import 'members_sheet.dart'; import 'message_actions.dart'; import 'message_long_press_region.dart'; @@ -298,6 +300,8 @@ class ChannelDetailPage extends HookConsumerWidget { }, [channel.id, readState.isReady, readTimestamp]); return FrostedScaffold( + resizeToAvoidBottomInset: + !usesFixedAndroidImeViewport || resolvedChannel.isForum, appBar: FrostedAppBar( iconColor: context.colors.primary, titleContentHeight: appBarTitleContentHeight, @@ -492,46 +496,48 @@ class ChannelDetailPage extends HookConsumerWidget { ], ), if (showsComposer) - Align( - alignment: Alignment.bottomCenter, - child: ComposerDockSizeReporter( - key: const ValueKey('channel-composer-dock'), - onHeightChanged: (height) { - if ((composerDockHeight.value - height).abs() < 0.5) return; - composerDockHeight.value = height; - }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedSize( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - child: typingEntries.isEmpty - ? const SizedBox.shrink() - : ChannelTypingIndicator(entries: typingEntries), - ), - ComposeBar( - channelId: channel.id, - channelName: resolvedChannel.isDm - ? '' - : resolvedChannel.name, - onSend: - ( - content, - mentionPubkeys, { - mediaTags = const >[], - }) => sendMessage.call( - channelId: channel.id, - content: content, - mentionPubkeys: mentionPubkeys, - channel: resolvedChannel, - mediaTags: mediaTags, - ), - ), - ], + AndroidImeLift( + child: Align( + alignment: Alignment.bottomCenter, + child: ComposerDockSizeReporter( + key: const ValueKey('channel-composer-dock'), + onHeightChanged: (height) { + if ((composerDockHeight.value - height).abs() < 0.5) return; + composerDockHeight.value = height; + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSize( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + alignment: Alignment.bottomCenter, + child: typingEntries.isEmpty + ? const SizedBox.shrink() + : ChannelTypingIndicator(entries: typingEntries), + ), + ComposeBar( + channelId: channel.id, + channelName: resolvedChannel.isDm + ? '' + : resolvedChannel.name, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) => sendMessage.call( + channelId: channel.id, + content: content, + mentionPubkeys: mentionPubkeys, + channel: resolvedChannel, + mediaTags: mediaTags, + ), + ), + ], + ), ), ), ), diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index b05126f31..57a621fba 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -35,16 +35,23 @@ class _MessageList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final appView = View.of(context); final displayEntries = groupMembershipTimelineEntries(entries); final itemScrollController = useMemoized(ItemScrollController.new); final itemPositionsListener = useMemoized(ItemPositionsListener.create); final isLoadingOlder = useState(false); final isAtLatest = useState(true); + final settledImeBottomInset = useState( + usesFixedAndroidImeViewport + ? appView.viewInsets.bottom / appView.devicePixelRatio + : 0.0, + ); final hasUserScrolled = useState(false); - final followsLatest = useRef( + final followsLatest = useState( initialMessageId == null && initialThreadRootId == null, ); final isAutoScrolling = useRef(false); + final latestNavigationRequest = useState(0); final latestRealignmentQueued = useRef(false); final latestEntryId = entries.isEmpty ? null : entries.last.message.id; final previousLatestEntryId = useRef(null); @@ -58,6 +65,15 @@ class _MessageList extends HookConsumerWidget { final hasUnreadDeepLink = initialMessageId != null || initialThreadRootId != null; final notifier = ref.read(channelMessagesProvider(channelId).notifier); + final settledImeLift = usesFixedAndroidImeViewport + ? (settledImeBottomInset.value - + MediaQuery.viewPaddingOf(context).bottom) + .clamp(0.0, double.infinity) + .toDouble() + : settledImeBottomInset.value; + final timelineBottomInset = + composerBottomInset + (followsLatest.value ? settledImeLift : 0); + final navigationBottomInset = composerBottomInset + settledImeLift; useEffect( () { @@ -158,15 +174,15 @@ class _MessageList extends HookConsumerWidget { double latestAlignment() { final viewportHeight = context.size?.height ?? 0; return viewportHeight > 0 - ? (composerBottomInset / viewportHeight).clamp(0.0, 1.0).toDouble() + ? (timelineBottomInset / viewportHeight).clamp(0.0, 1.0).toDouble() : 0.0; } - Future scrollToLatest() async { - if (!itemScrollController.isAttached || isAutoScrolling.value) return; - followsLatest.value = true; - hasUserScrolled.value = false; - isAutoScrolling.value = true; + Future performLatestNavigation() async { + if (!context.mounted || !itemScrollController.isAttached) { + isAutoScrolling.value = false; + return; + } try { await itemScrollController.scrollTo( index: 0, @@ -182,6 +198,24 @@ class _MessageList extends HookConsumerWidget { } } + void scrollToLatest() { + if (!itemScrollController.isAttached || isAutoScrolling.value) return; + isAutoScrolling.value = true; + followsLatest.value = true; + hasUserScrolled.value = false; + latestNavigationRequest.value += 1; + } + + useEffect(() { + if (latestNavigationRequest.value == 0) return null; + var cancelled = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (cancelled) return; + unawaited(performLatestNavigation()); + }); + return () => cancelled = true; + }, [latestNavigationRequest.value]); + Future scrollToOldestUnread() async { final targetIndex = reversedIndexOf(oldestUnreadMessageId.value); if (targetIndex == null || @@ -221,6 +255,7 @@ class _MessageList extends HookConsumerWidget { void realignLatestAfterLayoutChange() { if (latestRealignmentQueued.value || + isAutoScrolling.value || !followsLatest.value || hasUserScrolled.value) { return; @@ -230,6 +265,7 @@ class _MessageList extends HookConsumerWidget { latestRealignmentQueued.value = false; if (!context.mounted || !itemScrollController.isAttached || + isAutoScrolling.value || !followsLatest.value || hasUserScrolled.value || latestIsAtBoundary()) { @@ -237,7 +273,9 @@ class _MessageList extends HookConsumerWidget { } // A dock or keyboard resize is a layout correction, not a navigation // action. Keeping it instant avoids restarting a smooth scroll for - // every position report while the viewport settles. + // every position report while the viewport settles. The rebuilt list + // padding already owns the composer/IME offset; the default alignment + // also keeps short timelines flush with that padding. itemScrollController.jumpTo(index: 0); }); } @@ -284,15 +322,28 @@ class _MessageList extends HookConsumerWidget { useEffect(() { realignLatestAfterLayoutChange(); return null; - }, [composerBottomInset]); + }, [timelineBottomInset]); useEffect(() { - final observer = _ChannelLatestMetricsObserver( - onMetricsChanged: realignLatestAfterLayoutChange, + final observer = ImeMetricsSettleObserver( + onMetricsSettled: () { + if (!usesFixedAndroidImeViewport) { + realignLatestAfterLayoutChange(); + return; + } + final nextInset = + appView.viewInsets.bottom / appView.devicePixelRatio; + if ((settledImeBottomInset.value - nextInset).abs() >= 0.5) { + settledImeBottomInset.value = nextInset; + } + }, ); WidgetsBinding.instance.addObserver(observer); - return () => WidgetsBinding.instance.removeObserver(observer); - }, [itemScrollController]); + return () { + WidgetsBinding.instance.removeObserver(observer); + observer.dispose(); + }; + }, [appView, itemScrollController]); useEffect(() { if (initialThreadRootId == null || didOpenInitialThread.value) { @@ -428,7 +479,7 @@ class _MessageList extends HookConsumerWidget { context, titleContentHeight: appBarTitleContentHeight, ), - bottom: composerBottomInset, + bottom: timelineBottomInset, ), itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0), itemBuilder: (context, index) { @@ -548,10 +599,11 @@ class _MessageList extends HookConsumerWidget { Positioned( left: 0, right: 0, - bottom: composerBottomInset + Grid.xs, + bottom: navigationBottomInset + Grid.xs, child: Center( - child: _JumpToLatestButton( + child: LatestMessageButton( key: const ValueKey('channel-jump-to-latest'), + surfaceKey: const ValueKey('channel-jump-to-latest-surface'), onPressed: scrollToLatest, ), ), @@ -560,73 +612,3 @@ class _MessageList extends HookConsumerWidget { ); } } - -class _JumpToLatestButton extends StatelessWidget { - final VoidCallback onPressed; - - const _JumpToLatestButton({required this.onPressed, super.key}); - - @override - Widget build(BuildContext context) { - final borderRadius = BorderRadius.circular(Radii.full); - return Semantics( - button: true, - child: ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - key: const ValueKey('channel-jump-to-latest-surface'), - decoration: BoxDecoration( - color: context.colors.surface.withValues(alpha: 0.5), - borderRadius: borderRadius, - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, - ), - ), - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: onPressed, - borderRadius: borderRadius, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.xxs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.arrowDown, - size: 16, - color: context.colors.onSurface, - ), - const SizedBox(width: Grid.half), - Text( - 'Latest', - style: context.textTheme.labelLarge?.copyWith( - color: context.colors.onSurface, - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ); - } -} - -class _ChannelLatestMetricsObserver with WidgetsBindingObserver { - final VoidCallback onMetricsChanged; - - _ChannelLatestMetricsObserver({required this.onMetricsChanged}); - - @override - void didChangeMetrics() => onMetricsChanged(); -} diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 7d294c5f1..9c29c67a9 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -58,3 +58,11 @@ part 'compose_bar/send_button.dart'; part 'compose_bar/layout.dart'; part 'compose_bar/dock.dart'; part 'compose_bar/compose_bar_widget.dart'; + +/// Callback used by channels and threads to submit composer content. +typedef ComposeBarOnSend = + Future Function( + String content, + List mentionPubkeys, { + List> mediaTags, + }); diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index f922c7c01..c6a26cc59 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -1,21 +1,15 @@ part of '../compose_bar.dart'; -/// Rich compose bar with @mention autocomplete and a markdown formatting -/// toolbar. Used in both channel and thread views — the caller provides an -/// [onSend] callback that handles actual message submission. -typedef ComposeBarOnSend = - Future Function( - String content, - List mentionPubkeys, { - List> mediaTags, - }); - class ComposeBar extends HookConsumerWidget { final String channelId; final String channelName; final String? hintText; final ComposeBarOnSend onSend; + /// Runs immediately before the editor requests focus, allowing a parent to + /// prepare focus-dependent layout (for example, following a thread tail). + final VoidCallback? onFocusRequested; + /// Optional thread IDs for thread-scoped typing indicators. final String? threadHeadId; final String? rootId; @@ -26,6 +20,7 @@ class ComposeBar extends HookConsumerWidget { this.hintText, this.threadHeadId, this.rootId, + this.onFocusRequested, required this.onSend, }); @override @@ -50,13 +45,22 @@ class ComposeBar extends HookConsumerWidget { final draftIdentity = '${ref.watch(relayConfigProvider).baseUrl}' ':${ref.watch(myPubkeyProvider) ?? 'anon'}'; + final isComposerExpanded = useState(false); + final androidImeTransitionStarted = useState( + defaultTargetPlatform != TargetPlatform.android, + ); + final androidImeFallbackTimer = useRef(null); final focusNode = useFocusNode(); + useEffect( + () => + () => androidImeFallbackTimer.value?.cancel(), + [androidImeFallbackTimer], + ); useEffect( () => () => _dismissComposerKeyboard(focusNode), [focusNode], ); - final isComposerExpanded = useState(false); final isEmojiPickerOpen = useState(false); final attachmentSurface = useState(_AttachmentSurface.closed); final iosAttachmentPopover = useMemoized( @@ -101,10 +105,6 @@ class ComposeBar extends HookConsumerWidget { initialValue: 0, upperBound: 1.05, ); - final composerExpansionValue = useAnimation(composerExpansionController); - final composerExpansionProgress = composerExpansionValue - .clamp(0.0, 1.0) - .toDouble(); void collapseComposer() { if (!isComposerExpanded.value) return; @@ -127,7 +127,15 @@ class ComposeBar extends HookConsumerWidget { useEffect(() { final observer = _ComposerKeyboardMetricsObserver( view: appView, + onKeyboardShown: () { + androidImeFallbackTimer.value?.cancel(); + androidImeTransitionStarted.value = true; + }, onKeyboardHidden: () { + androidImeFallbackTimer.value?.cancel(); + if (defaultTargetPlatform == TargetPlatform.android) { + androidImeTransitionStarted.value = false; + } collapseComposer(); focusNode.unfocus(); }, @@ -138,26 +146,36 @@ class ComposeBar extends HookConsumerWidget { final resolvedHint = hintText ?? (channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026'); - useEffect(() { - final target = isComposerExpanded.value ? 1.0 : 0.0; - if (reducedMotion) { - composerExpansionController.value = target; - } else if ((composerExpansionController.value - target).abs() > 0.001) { - composerExpansionController.animateWith( - SpringSimulation( - SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 220), - bounce: 0.08, + useEffect( + () { + final target = + isComposerExpanded.value && androidImeTransitionStarted.value + ? 1.0 + : 0.0; + if (reducedMotion) { + composerExpansionController.value = target; + } else if ((composerExpansionController.value - target).abs() > 0.001) { + composerExpansionController.animateWith( + SpringSimulation( + SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 220), + bounce: 0.08, + ), + composerExpansionController.value, + target, + 0, + snapToEnd: true, ), - composerExpansionController.value, - target, - 0, - snapToEnd: true, - ), - ); - } - return null; - }, [isComposerExpanded.value, reducedMotion]); + ); + } + return null; + }, + [ + isComposerExpanded.value, + androidImeTransitionStarted.value, + reducedMotion, + ], + ); useEffect(() { if (defaultTargetPlatform != TargetPlatform.iOS) return null; @@ -816,15 +834,10 @@ class ComposeBar extends HookConsumerWidget { attachmentSurface.value = _AttachmentSurface.camera; } - final motionDuration = reducedMotion - ? Duration.zero - : Duration( - milliseconds: - attachmentSurface.value == _AttachmentSurface.camera || - attachmentSurface.value == _AttachmentSurface.photos - ? 320 - : 250, - ); + final motionDuration = _composerMotionDuration( + reducedMotion, + attachmentSurface.value, + ); final resizeDuration = reducedMotion ? Duration.zero : const Duration(milliseconds: 140); @@ -839,42 +852,28 @@ class ComposeBar extends HookConsumerWidget { return null; }, [suggestionOverlayController]); - void expandComposer() { - if (isComposerExpanded.value) return; - attachmentSurface.value = _AttachmentSurface.closed; - isComposerExpanded.value = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) focusNode.requestFocus(); - }); - } + void expandComposer() => _expandComposer( + context: context, + isExpanded: isComposerExpanded, + attachmentSurface: attachmentSurface, + onFocusRequested: onFocusRequested, + focusNode: focusNode, + view: appView, + androidImeTransitionStarted: androidImeTransitionStarted, + androidImeFallbackTimer: androidImeFallbackTimer, + ); - final suggestionPanel = channelSuggestions.isNotEmpty - ? KeyedSubtree( - key: const ValueKey('channel-suggestions'), - child: _ChannelSuggestions( - suggestions: channelSuggestions, - onSelect: insertChannel, - ), - ) - : suggestions.isNotEmpty - ? KeyedSubtree( - key: const ValueKey('mention-suggestions'), - child: _MentionSuggestions( - suggestions: suggestions, - userCache: userCache, - currentPubkey: currentPubkey, - isDmChannel: isDmChannel, - onSelect: insertMention, - ), - ) - : const SizedBox.shrink(key: ValueKey('no-suggestions')); + final suggestionPanel = _composerSuggestionPanel( + channelSuggestions: channelSuggestions, + mentionSuggestions: suggestions, + userCache: userCache, + currentPubkey: currentPubkey, + isDmChannel: isDmChannel, + onChannelSelect: insertChannel, + onMentionSelect: insertMention, + ); Widget buildOverlayPanel(_AttachmentSurface surface) { - return _AttachmentSurfacePanel( - key: ValueKey( - surface == _AttachmentSurface.closed - ? 'composer-suggestions' - : 'attachment-surface', - ), + return _composerAttachmentPanel( surface: surface, suggestionPanel: suggestionPanel, onBack: () => attachmentSurface.value = _AttachmentSurface.menu, @@ -913,12 +912,10 @@ class ComposeBar extends HookConsumerWidget { ); } - // Suggestions and attachments live in the overlay so showing them cannot - // reflow the composer. Both stay anchored just above the capsule. - final composerWidthFactor = 0.85 + 0.15 * composerExpansionProgress; + // Suggestions and attachments live in the overlay. final hasPendingUploads = uploadingCount.value > 0; return _ComposerDockFrame( - widthFactor: composerWidthFactor, + expansionAnimation: composerExpansionController, child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -955,8 +952,7 @@ class ComposeBar extends HookConsumerWidget { attachmentSurface: attachmentSurface.value, onAttachmentTap: handleAttachmentTap, onExpand: expandComposer, - expansionValue: composerExpansionValue, - expansionProgress: composerExpansionProgress, + expansionAnimation: composerExpansionController, formattingOpen: showFormatting.value, onCloseFormatting: () => showFormatting.value = false, motionDuration: motionDuration, diff --git a/mobile/lib/features/channels/compose_bar/dock.dart b/mobile/lib/features/channels/compose_bar/dock.dart index 267aa6cb6..7a25f4cfe 100644 --- a/mobile/lib/features/channels/compose_bar/dock.dart +++ b/mobile/lib/features/channels/compose_bar/dock.dart @@ -1,10 +1,13 @@ part of '../compose_bar.dart'; class _ComposerDockFrame extends StatelessWidget { - final double widthFactor; + final Animation expansionAnimation; final Widget child; - const _ComposerDockFrame({required this.widthFactor, required this.child}); + const _ComposerDockFrame({ + required this.expansionAnimation, + required this.child, + }); @override Widget build(BuildContext context) { @@ -30,10 +33,19 @@ class _ComposerDockFrame extends StatelessWidget { ), child: Align( alignment: Alignment.bottomCenter, - child: FractionallySizedBox( - key: const ValueKey('composer-width-transition'), - widthFactor: widthFactor, + child: AnimatedBuilder( + animation: expansionAnimation, child: child, + builder: (context, child) { + final progress = expansionAnimation.value + .clamp(0.0, 1.0) + .toDouble(); + return FractionallySizedBox( + key: const ValueKey('composer-width-transition'), + widthFactor: 0.85 + 0.15 * progress, + child: child, + ); + }, ), ), ), diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index ff60d7444..09d7a8e84 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -21,17 +21,20 @@ const _typingThrottleMs = 3000; class _ComposerKeyboardMetricsObserver with WidgetsBindingObserver { final FlutterView view; + final VoidCallback onKeyboardShown; final VoidCallback onKeyboardHidden; bool _wasVisible; _ComposerKeyboardMetricsObserver({ required this.view, + required this.onKeyboardShown, required this.onKeyboardHidden, }) : _wasVisible = view.viewInsets.bottom > 0; @override void didChangeMetrics() { final isVisible = view.viewInsets.bottom > 0; + if (!_wasVisible && isVisible) onKeyboardShown(); if (_wasVisible && !isVisible) onKeyboardHidden(); _wasVisible = isVisible; } @@ -59,6 +62,112 @@ void _dismissComposerKeyboard(FocusNode focusNode) { unawaited(SystemChannels.textInput.invokeMethod('TextInput.hide')); } +Duration _composerMotionDuration( + bool reducedMotion, + _AttachmentSurface surface, +) => reducedMotion + ? Duration.zero + : Duration( + milliseconds: + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos + ? 320 + : 250, + ); + +void _expandComposer({ + required BuildContext context, + required ValueNotifier isExpanded, + required ValueNotifier<_AttachmentSurface> attachmentSurface, + required VoidCallback? onFocusRequested, + required FocusNode focusNode, + required FlutterView view, + required ValueNotifier androidImeTransitionStarted, + required ObjectRef androidImeFallbackTimer, +}) { + if (isExpanded.value) return; + attachmentSurface.value = _AttachmentSurface.closed; + onFocusRequested?.call(); + isExpanded.value = true; + // Attach the editor before requesting focus so native restoration cannot + // reopen a composer behind a popped route. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted && isExpanded.value) focusNode.requestFocus(); + }); + if (defaultTargetPlatform != TargetPlatform.android) return; + androidImeFallbackTimer.value?.cancel(); + if (view.viewInsets.bottom > 0) { + androidImeTransitionStarted.value = true; + return; + } + androidImeTransitionStarted.value = false; + androidImeFallbackTimer.value = Timer(const Duration(milliseconds: 250), () { + if (context.mounted && isExpanded.value) { + androidImeTransitionStarted.value = true; + } + }); +} + +Widget _composerSuggestionPanel({ + required List channelSuggestions, + required List mentionSuggestions, + required Map userCache, + required String? currentPubkey, + required bool isDmChannel, + required ValueChanged onChannelSelect, + required ValueChanged onMentionSelect, +}) => channelSuggestions.isNotEmpty + ? KeyedSubtree( + key: const ValueKey('channel-suggestions'), + child: _ChannelSuggestions( + suggestions: channelSuggestions, + onSelect: onChannelSelect, + ), + ) + : mentionSuggestions.isNotEmpty + ? KeyedSubtree( + key: const ValueKey('mention-suggestions'), + child: _MentionSuggestions( + suggestions: mentionSuggestions, + userCache: userCache, + currentPubkey: currentPubkey, + isDmChannel: isDmChannel, + onSelect: onMentionSelect, + ), + ) + : const SizedBox.shrink(key: ValueKey('no-suggestions')); + +Widget _composerAttachmentPanel({ + required _AttachmentSurface surface, + required Widget suggestionPanel, + required VoidCallback onBack, + required VoidCallback onCamera, + required VoidCallback onPhotos, + required VoidCallback onVideo, + required VoidCallback onFiles, + required Future Function(XFile image) onCapture, + required Future> Function() onPickAllPhotos, + required Future Function(List photos) onChoosePhotos, + required Future Function(List photos) onChooseAllPhotos, +}) => _AttachmentSurfacePanel( + key: ValueKey( + surface == _AttachmentSurface.closed + ? 'composer-suggestions' + : 'attachment-surface', + ), + surface: surface, + suggestionPanel: suggestionPanel, + onBack: onBack, + onCamera: onCamera, + onPhotos: onPhotos, + onVideo: onVideo, + onFiles: onFiles, + onCapture: onCapture, + onPickAllPhotos: onPickAllPhotos, + onChoosePhotos: onChoosePhotos, + onChooseAllPhotos: onChooseAllPhotos, +); + const _pastedImageMimeTypes = [ 'image/jpeg', 'image/jpg', diff --git a/mobile/lib/features/channels/compose_bar/layout.dart b/mobile/lib/features/channels/compose_bar/layout.dart index 4d9177663..3400edd54 100644 --- a/mobile/lib/features/channels/compose_bar/layout.dart +++ b/mobile/lib/features/channels/compose_bar/layout.dart @@ -14,8 +14,7 @@ class _ComposeBarLayout extends StatelessWidget { final _AttachmentSurface attachmentSurface; final ValueChanged onAttachmentTap; final VoidCallback onExpand; - final double expansionValue; - final double expansionProgress; + final Animation expansionAnimation; final bool formattingOpen; final VoidCallback onCloseFormatting; final Duration motionDuration; @@ -43,8 +42,7 @@ class _ComposeBarLayout extends StatelessWidget { required this.attachmentSurface, required this.onAttachmentTap, required this.onExpand, - required this.expansionValue, - required this.expansionProgress, + required this.expansionAnimation, required this.formattingOpen, required this.onCloseFormatting, required this.motionDuration, @@ -69,184 +67,173 @@ class _ComposeBarLayout extends StatelessWidget { final collapsedText = trimmedDraft.isEmpty ? resolvedHint : trimmedDraft.replaceAll(RegExp(r'\s+'), ' '); - final composerRadius = - Radii.dialog + Grid.quarter * (1 - expansionProgress); - return Container( - key: const ValueKey('composer-surface'), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(composerRadius), - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, - ), - ), - padding: const EdgeInsets.all(Grid.xxs), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (attachments.isNotEmpty) ...[ - _AttachmentStrip( - attachments: attachments, - onRemove: onRemoveAttachment, - ), - const SizedBox(height: Grid.xxs), - ], - if (uploadError case final error?) ...[ - Align( - alignment: Alignment.centerLeft, - child: Text( - error, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, - ), + final content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (attachments.isNotEmpty) ...[ + _AttachmentStrip( + attachments: attachments, + onRemove: onRemoveAttachment, + ), + const SizedBox(height: Grid.xxs), + ], + if (uploadError case final error?) ...[ + Align( + alignment: Alignment.centerLeft, + child: Text( + error, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, ), ), - const SizedBox(height: Grid.xxs), - ], - // Keep the default state out of the focus system entirely so - // restored native focus cannot expand a newly opened channel. - if (isExpanded) - resizeDuration == Duration.zero - ? KeyedSubtree( - key: const ValueKey('composer-text-height-motion'), - child: _buildTextField(context), - ) - : AnimatedSize( - key: const ValueKey('composer-text-height-motion'), - alignment: Alignment.topCenter, - duration: resizeDuration, - curve: Curves.easeOutCubic, - child: _buildTextField(context), - ) - else - Row( - children: [ - _AttachmentTrigger( - surface: attachmentSurface, - formattingOpen: false, - onTap: onAttachmentTap, - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: Semantics( - button: true, - label: resolvedHint, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _runComposerAction(onExpand), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: Grid.half, - ), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - collapsedText, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyLarge?.copyWith( - color: trimmedDraft.isEmpty - ? context.colors.onSurfaceVariant - : context.colors.onSurface, - ), + ), + const SizedBox(height: Grid.xxs), + ], + // Keep the default state out of the focus system entirely so + // restored native focus cannot expand a newly opened channel. + if (isExpanded) + resizeDuration == Duration.zero + ? KeyedSubtree( + key: const ValueKey('composer-text-height-motion'), + child: _buildTextField(context), + ) + : AnimatedSize( + key: const ValueKey('composer-text-height-motion'), + alignment: Alignment.topCenter, + duration: resizeDuration, + curve: Curves.easeOutCubic, + child: _buildTextField(context), + ) + else + Row( + children: [ + _AttachmentTrigger( + surface: attachmentSurface, + formattingOpen: false, + onTap: onAttachmentTap, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Semantics( + button: true, + label: resolvedHint, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _runComposerAction(onExpand), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.half), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + collapsedText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyLarge?.copyWith( + color: trimmedDraft.isEmpty + ? context.colors.onSurfaceVariant + : context.colors.onSurface, ), ), ), ), ), ), - const SizedBox(width: Grid.xxs), - _SendButton( - isDisabled: !canSend || hasPendingUploads, - isSending: isSending, - onTap: onSend, - ), - ], - ), - ClipRect( - child: Align( - alignment: Alignment.topCenter, - heightFactor: expansionValue, - child: IgnorePointer( - ignoring: !isExpanded, - child: Opacity( - opacity: expansionProgress, - child: Transform.translate( - offset: Offset(0, Grid.xxs * (1 - expansionProgress)), - child: Column( - children: [ - const SizedBox(height: Grid.xxs), - Row( - children: [ - _AttachmentTrigger( - surface: attachmentSurface, - formattingOpen: formattingOpen, - onTap: (triggerContext) { - if (formattingOpen) { - onCloseFormatting(); - } else { - onAttachmentTap(triggerContext); - } - }, + ), + const SizedBox(width: Grid.xxs), + _SendButton( + isDisabled: !canSend || hasPendingUploads, + isSending: isSending, + onTap: onSend, + ), + ], + ), + _ExpandedComposerActionsMotion( + animation: expansionAnimation, + isExpanded: isExpanded, + child: Column( + children: [ + const SizedBox(height: Grid.xxs), + Row( + children: [ + _AttachmentTrigger( + surface: attachmentSurface, + formattingOpen: formattingOpen, + onTap: (triggerContext) { + if (formattingOpen) { + onCloseFormatting(); + } else { + onAttachmentTap(triggerContext); + } + }, + ), + const SizedBox(width: Grid.half), + Expanded( + child: AnimatedSwitcher( + duration: motionDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + layoutBuilder: (currentChild, previousChildren) => Stack( + alignment: Alignment.centerLeft, + children: [...previousChildren, ?currentChild], + ), + child: formattingOpen + ? _FormattingToolbar(onFormat: onFormat) + : Row( + key: const ValueKey('standard-actions'), + children: [ + _ComposeAction( + icon: LucideIcons.atSign, + onTap: onMention, + ), + _ComposeAction( + icon: LucideIcons.hash, + onTap: onChannel, + ), + _ComposeAction( + icon: LucideIcons.smilePlus, + onTap: onEmoji, + ), + _ComposeAction( + icon: LucideIcons.aLargeSmall, + onTap: onOpenFormatting, + ), + const Spacer(), + _SendButton( + isDisabled: !canSend || hasPendingUploads, + isSending: isSending, + onTap: onSend, + ), + ], ), - const SizedBox(width: Grid.half), - Expanded( - child: AnimatedSwitcher( - duration: motionDuration, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - layoutBuilder: - (currentChild, previousChildren) => Stack( - alignment: Alignment.centerLeft, - children: [ - ...previousChildren, - ?currentChild, - ], - ), - child: formattingOpen - ? _FormattingToolbar(onFormat: onFormat) - : Row( - key: const ValueKey('standard-actions'), - children: [ - _ComposeAction( - icon: LucideIcons.atSign, - onTap: onMention, - ), - _ComposeAction( - icon: LucideIcons.hash, - onTap: onChannel, - ), - _ComposeAction( - icon: LucideIcons.smilePlus, - onTap: onEmoji, - ), - _ComposeAction( - icon: LucideIcons.aLargeSmall, - onTap: onOpenFormatting, - ), - const Spacer(), - _SendButton( - isDisabled: - !canSend || hasPendingUploads, - isSending: isSending, - onTap: onSend, - ), - ], - ), - ), - ), - ], - ), - ], ), ), - ), + ], ), + ], + ), + ), + ], + ); + return AnimatedBuilder( + animation: expansionAnimation, + child: content, + builder: (context, child) { + final progress = expansionAnimation.value.clamp(0.0, 1.0).toDouble(); + final composerRadius = Radii.dialog + Grid.quarter * (1 - progress); + return Container( + key: const ValueKey('composer-surface'), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(composerRadius), + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, ), ), - ], - ), + padding: const EdgeInsets.all(Grid.xxs), + child: child, + ); + }, ); } @@ -288,6 +275,46 @@ class _ComposeBarLayout extends StatelessWidget { } } +class _ExpandedComposerActionsMotion extends StatelessWidget { + final Animation animation; + final bool isExpanded; + final Widget child; + + const _ExpandedComposerActionsMotion({ + required this.animation, + required this.isExpanded, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: animation, + child: child, + builder: (context, child) { + final value = animation.value; + final progress = value.clamp(0.0, 1.0).toDouble(); + return ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: value, + child: IgnorePointer( + ignoring: !isExpanded, + child: Opacity( + opacity: progress, + child: Transform.translate( + offset: Offset(0, Grid.xxs * (1 - progress)), + child: child, + ), + ), + ), + ), + ); + }, + ); + } +} + /// Drag the compose bar downward to put the keyboard away. /// /// Continues the gesture the message list starts: once your finger reaches the diff --git a/mobile/lib/features/channels/ime_metrics_settle_observer.dart b/mobile/lib/features/channels/ime_metrics_settle_observer.dart new file mode 100644 index 000000000..5924e6e54 --- /dev/null +++ b/mobile/lib/features/channels/ime_metrics_settle_observer.dart @@ -0,0 +1,44 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +/// How long Android viewport metrics must stay quiet before layout correction. +const androidImeMetricsSettleDelay = Duration(milliseconds: 120); + +/// Coalesces Android's frame-by-frame IME metrics into one settled callback. +/// +/// iOS continues to receive callbacks immediately. Android sends viewport +/// metrics throughout the keyboard animation; doing list realignment for each +/// delivery competes with the IME transition and can drop frames. +class ImeMetricsSettleObserver with WidgetsBindingObserver { + /// Runs after Android metrics remain quiet for [androidSettleDelay], or + /// immediately for each metrics change on other platforms. + final VoidCallback onMetricsSettled; + + /// The quiet period used to coalesce Android IME animation metrics. + final Duration androidSettleDelay; + Timer? _androidTimer; + + /// Creates an observer that coalesces Android IME metric updates. + ImeMetricsSettleObserver({ + required this.onMetricsSettled, + this.androidSettleDelay = androidImeMetricsSettleDelay, + }); + + @override + void didChangeMetrics() { + if (defaultTargetPlatform != TargetPlatform.android) { + onMetricsSettled(); + return; + } + _androidTimer?.cancel(); + _androidTimer = Timer(androidSettleDelay, onMetricsSettled); + } + + /// Cancels any pending Android metrics-settlement callback. + void dispose() { + _androidTimer?.cancel(); + _androidTimer = null; + } +} diff --git a/mobile/lib/features/channels/latest_message_button.dart b/mobile/lib/features/channels/latest_message_button.dart new file mode 100644 index 000000000..81abc8034 --- /dev/null +++ b/mobile/lib/features/channels/latest_message_button.dart @@ -0,0 +1,77 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; + +/// Shared channel/thread control for returning to the newest message. +class LatestMessageButton extends StatelessWidget { + /// Returns the message list to its newest item. + final VoidCallback onPressed; + + /// Optional key for inspecting or measuring the decorated glass surface. + final Key? surfaceKey; + + /// Creates the shared control for returning to the newest message. + const LatestMessageButton({ + required this.onPressed, + this.surfaceKey, + super.key, + }); + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.circular(Radii.full); + return Semantics( + button: true, + child: ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + key: surfaceKey, + decoration: BoxDecoration( + color: context.colors.surface.withValues(alpha: 0.5), + borderRadius: borderRadius, + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onPressed, + borderRadius: borderRadius, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.gutter, + vertical: Grid.xxs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.arrowDown, + size: 16, + color: context.colors.onSurface, + ), + const SizedBox(width: Grid.half), + Text( + 'Latest', + style: context.textTheme.labelLarge?.copyWith( + color: context.colors.onSurface, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_helpers.dart b/mobile/lib/features/channels/thread_detail_helpers.dart index e4d0f76ff..4e13e1f39 100644 --- a/mobile/lib/features/channels/thread_detail_helpers.dart +++ b/mobile/lib/features/channels/thread_detail_helpers.dart @@ -2,21 +2,6 @@ part of 'thread_detail_page.dart'; int _threadTailIndex(int replyCount) => replyCount; -double _threadTailTrailingBoundary({ - required bool hasComposerDock, - required double viewportHeight, - required double dockHeight, -}) { - if (!hasComposerDock) return 1.001; - if (!viewportHeight.isFinite || - viewportHeight <= 0 || - !dockHeight.isFinite || - dockHeight <= 0) { - return double.negativeInfinity; - } - return 1 - (dockHeight / viewportHeight) + 0.001; -} - void _resumeThreadTailFollow({ required bool Function() isVisible, required ObjectRef userOptedOut, @@ -63,15 +48,6 @@ ThreadSummary _buildNestedSummary( ); } -class _ThreadTailMetricsObserver with WidgetsBindingObserver { - final VoidCallback onMetricsChanged; - - _ThreadTailMetricsObserver({required this.onMetricsChanged}); - - @override - void didChangeMetrics() => onMetricsChanged(); -} - /// Serializes deferred tail work behind the latest user scroll intent. class _ThreadTailIntent { var _generation = 0; @@ -86,6 +62,18 @@ class _ThreadTailIntent { void endDrag() => isDragging = false; + void scheduleNextFrame({ + required bool allowed, + required bool Function() revalidate, + required VoidCallback action, + }) { + if (!allowed) return; + final generation = ++_generation; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (generation == _generation && revalidate()) action(); + }); + } + void schedule({ required bool allowed, required bool Function() revalidate, diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index fcd89b767..6ab3ddd9b 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -13,6 +13,7 @@ import '../../shared/widgets/keyboard_dismiss_on_drag.dart'; import '../../shared/widgets/message_author_meta.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; +import 'android_ime_lift.dart'; import 'channel_link_navigation.dart'; import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; @@ -23,9 +24,11 @@ import 'compose_bar.dart'; import 'composer_dock_size_reporter.dart'; import 'date_formatters.dart'; import 'day_divider.dart'; -import '../profile/user_profile_sheet.dart'; +import 'ime_metrics_settle_observer.dart'; import 'initial_thread_tail_settle.dart'; import 'laid_out_viewport.dart'; +import 'latest_message_button.dart'; +import '../profile/user_profile_sheet.dart'; import 'message_actions.dart'; import 'message_long_press_region.dart'; import 'message_content.dart'; @@ -36,7 +39,10 @@ import 'send_message_provider.dart'; import 'small_avatar.dart'; import 'timeline_message.dart'; +part 'thread_detail_page/nested_thread_summary_row.dart'; part 'thread_detail_helpers.dart'; +part 'thread_detail_page/tail_alignment.dart'; +part 'thread_detail_page/thread_message.dart'; /// Full-screen thread detail page. /// @@ -64,14 +70,28 @@ class ThreadDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final appView = View.of(context); final composerDockHeight = useState(0.0); + final settledImeBottomInset = useState( + usesFixedAndroidImeViewport + ? appView.viewInsets.bottom / appView.devicePixelRatio + : 0.0, + ); final sendMessage = ref.read(sendMessageProvider); + // Relay thread queries are keyed by the outermost root, even when this + // page displays a nested branch. Query that root, then select this head's + // direct children from the returned subtree below. final queryRootId = threadHead.rootId ?? threadHead.id; final repliesState = ref.watch( threadRepliesWithLocalProvider( ThreadRepliesArgs(channelId: channelId, rootId: queryRootId), ), ); + // The thread query is one-shot and asks only for content kinds, so a + // reaction, edit, or deletion that lands while the thread is open never + // reaches it — a new pill (and its burst) only showed up after leaving and + // re-entering, which refetched. The channel socket already receives those + // events, so union the two sources and format once. final liveChannelEvents = ref.watch(channelMessagesProvider(channelId)).value ?? const []; @@ -90,12 +110,18 @@ class ThreadDetailPage extends HookConsumerWidget { final allMsgs = fetchedReplies == null ? allMessages : [ + // Only fall back to the pushed-route snapshot when neither source + // carries the head, and no live deletion has suppressed it. That + // keeps a temporarily unavailable head visible without restoring + // a head that was deleted while this page was open. if (!liveDeletionHidesHead && !fetchedReplies.any((message) => message.id == threadHead.id)) threadHead, ...fetchedReplies, ]; + // Index all messages by parentId so we can find direct children of any + // message and compute thread summaries for nested threads. final childrenByParent = >{}; for (final msg in allMsgs) { final pid = msg.parentId; @@ -111,30 +137,101 @@ class ThreadDetailPage extends HookConsumerWidget { final didJumpToInitialMessage = useRef(false); final followsThreadTail = useRef(false); final userOptedOutOfTailFollow = useRef(false); + final userDragDetachedTailFollow = useRef(false); final tailIntent = useMemoized(_ThreadTailIntent.new); - final pendingTailAlignment = useRef(null); + final initialTailSettle = useMemoized(InitialThreadTailSettle.new); + final isAtThreadTail = useState(true); + final tailCorrectionInProgress = useRef(false); + final viewportHeight = useListenable(listViewport.height).value; + final previousViewportHeight = useRef(viewportHeight); + final settledImeLift = usesFixedAndroidImeViewport + ? (settledImeBottomInset.value - + MediaQuery.viewPaddingOf(context).bottom) + .clamp(0.0, double.infinity) + .toDouble() + : 0.0; + final timelineBottomInset = + composerDockHeight.value + + (followsThreadTail.value || !initialTailSettle.isComplete + ? settledImeLift + : 0); + final navigationBottomInset = composerDockHeight.value + settledImeLift; + + // Item 0 is the thread head; reply `i` lives at `i + 1`. const headIndex = 0; int indexForReply(int chronologicalIndex) => chronologicalIndex + 1; + final tailAnchorIndex = replies.length + 1; + + double threadTailAlignment() => _threadTailAlignmentForViewport( + // This reporter already reflects Scaffold resize, typing rows, and every + // other layout consumer above or below the list. + fullHeight: viewportHeight > 0 + ? viewportHeight + : MediaQuery.sizeOf(context).height, + imeBottomInset: 0, + usesFixedImeViewport: true, + bottomInset: + Grid.xs + + composerDockHeight.value + + (followsThreadTail.value ? settledImeLift : 0), + ); bool threadTailIsVisible() { - final lastIndex = _threadTailIndex(replies.length); - final trailingBoundary = _threadTailTrailingBoundary( - hasComposerDock: isMember && !isArchived, - viewportHeight: listViewport.height.value, - dockHeight: composerDockHeight.value, - ); + if (isMember && + !isArchived && + (!viewportHeight.isFinite || + viewportHeight <= 0 || + composerDockHeight.value <= 0)) { + return false; + } + if (!viewportHeight.isFinite || viewportHeight <= 0) return false; + final trailingBoundary = + 1 - ((Grid.xs + timelineBottomInset) / viewportHeight) + 0.001; + final lastMessageIndex = _threadTailIndex(replies.length); return itemPositionsListener.itemPositions.value.any( (position) => - position.index == lastIndex && + position.index == lastMessageIndex && position.itemTrailingEdge <= trailingBoundary, ); } + void correctThreadTailInstantly() { + if (!itemScrollController.isAttached) return; + tailCorrectionInProgress.value = true; + isAtThreadTail.value = true; + itemScrollController.jumpTo( + index: tailAnchorIndex, + alignment: threadTailAlignment(), + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + tailCorrectionInProgress.value = false; + if (context.mounted && followsThreadTail.value) { + isAtThreadTail.value = true; + } + }); + } + + void followThreadTailFromComposer() { + if (userDragDetachedTailFollow.value) return; + initialTailSettle.abandon(); + tailIntent.endDrag(); + tailIntent.detach(); + userOptedOutOfTailFollow.value = false; + followsThreadTail.value = true; + isAtThreadTail.value = true; + if (!threadTailIsVisible()) correctThreadTailInstantly(); + } + useEffect(() { void onPositionsChanged() { - if (!userOptedOutOfTailFollow.value && threadTailIsVisible()) { + final tailIsVisible = threadTailIsVisible(); + if (!userOptedOutOfTailFollow.value && tailIsVisible) { followsThreadTail.value = true; } + if (tailCorrectionInProgress.value) return; + if (isAtThreadTail.value != tailIsVisible) { + isAtThreadTail.value = tailIsVisible; + } } itemPositionsListener.itemPositions.addListener(onPositionsChanged); @@ -143,8 +240,38 @@ class ThreadDetailPage extends HookConsumerWidget { ); }, [itemPositionsListener, replies.length]); + Future scrollToThreadLatest() async { + if (!itemScrollController.isAttached) return; + initialTailSettle.abandon(); + tailIntent.endDrag(); + userOptedOutOfTailFollow.value = false; + userDragDetachedTailFollow.value = false; + followsThreadTail.value = true; + isAtThreadTail.value = true; + tailIntent.scheduleNextFrame( + allowed: true, + revalidate: () => + context.mounted && + itemScrollController.isAttached && + !tailIntent.isDragging, + action: () async { + await itemScrollController.scrollTo( + index: tailAnchorIndex, + alignment: threadTailAlignment(), + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + if (context.mounted && threadTailIsVisible()) { + isAtThreadTail.value = true; + } + }, + ); + } + useEffect(() { final messageId = initialMessageId; + // Wait for the authoritative thread query before consuming the one-shot + // jump; the fallback main-timeline list can contain only the linked reply. if (messageId == null || fetchedReplies == null) return null; final chronologicalIndex = replies.indexWhere( (reply) => reply.id == messageId, @@ -156,6 +283,9 @@ class ThreadDetailPage extends HookConsumerWidget { : indexForReply(chronologicalIndex); if (targetIndex == null || didJumpToInitialMessage.value) return null; didJumpToInitialMessage.value = true; + initialTailSettle.abandon(); + userOptedOutOfTailFollow.value = true; + userDragDetachedTailFollow.value = false; tailIntent.schedule( allowed: true, revalidate: () => @@ -163,127 +293,100 @@ class ThreadDetailPage extends HookConsumerWidget { itemScrollController.isAttached && !tailIntent.isDragging, action: () { + // The provisional route snapshot can make the linked reply look like + // the tail. This authoritative deep-link jump intentionally leaves + // the user at an older item, so it must opt out of follow-tail first. tailIntent.detach(); followsThreadTail.value = false; - pendingTailAlignment.value = null; + isAtThreadTail.value = false; itemScrollController.jumpTo(index: targetIndex, alignment: 0.35); }, ); return null; }, [initialMessageId, fetchedReplies, replies.length]); + // A top-anchored list doesn't stick to the newest item the way the old + // reversed one did, so follow the tail explicitly: when a reply arrives + // while the last item is on screen, scroll it into view. If the user has + // scrolled up to read, leave them where they are. final hasFetchedReplies = fetchedReplies != null; - final initialTailSettle = useMemoized(InitialThreadTailSettle.new); final previousReplyCount = useRef(replies.length); - final viewportHeight = useListenable(listViewport.height).value; - final previousViewportHeight = useRef(viewportHeight); - final topOverlayFraction = frostedAppBarHeight(context) / viewportHeight; - final settleGeometry = (composerDockHeight.value, viewportHeight); - bool currentIntentAllowsTailMutation({bool allowIdleDetached = false}) { - if (tailIntent.isDragging) return false; - if (allowIdleDetached) return true; - return !userOptedOutOfTailFollow.value && - (followsThreadTail.value || threadTailIsVisible()); - } - - void queueTailRealignment({ - bool allowIdleDetached = false, - bool restoreFollow = false, - bool animate = true, - }) { - if (!initialTailSettle.isComplete || - viewportHeight <= 0 || - !currentIntentAllowsTailMutation( - allowIdleDetached: allowIdleDetached, - )) { - return; - } - if (!allowIdleDetached) followsThreadTail.value = true; - tailIntent.schedule( - allowed: true, - revalidate: () => - context.mounted && - itemScrollController.isAttached && - currentIntentAllowsTailMutation( - allowIdleDetached: allowIdleDetached, - ), - action: () { - final lastIndex = _threadTailIndex(replies.length); - if (restoreFollow) { - userOptedOutOfTailFollow.value = false; - followsThreadTail.value = true; - } - if (animate) { - itemScrollController.scrollTo( - index: lastIndex, - alignment: topOverlayFraction, - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - ); - } else { - itemScrollController.jumpTo( - index: lastIndex, - alignment: topOverlayFraction, - ); - } - }, - ); - } - + final topOverlayFraction = viewportHeight > 0 + ? frostedAppBarHeight(context) / viewportHeight + : 0.0; + final settleGeometry = ( + composerDockHeight.value, + settledImeLift, + viewportHeight, + ); useEffect(() { if (!hasFetchedReplies || viewportHeight <= 0) return null; + if (initialMessageId != null) { + initialTailSettle.abandon(); + previousReplyCount.value = replies.length; + return null; + } if (isMember && !isArchived && composerDockHeight.value <= 0) { return null; } if (!initialTailSettle.isComplete) { previousReplyCount.value = replies.length; - previousViewportHeight.value = viewportHeight; + followsThreadTail.value = true; initialTailSettle.schedule( context: context, controller: itemScrollController, positionsListener: itemPositionsListener, - targetIndex: initialMessageId == null && replies.isNotEmpty - ? indexForReply(replies.length - 1) - : null, + targetIndex: replies.isEmpty + ? null + : indexForReply(replies.length - 1), hiddenTopFraction: topOverlayFraction, - hiddenBottomFraction: composerDockHeight.value / viewportHeight, + hiddenBottomFraction: + (composerDockHeight.value + settledImeLift) / viewportHeight, ); return null; } + final previous = previousReplyCount.value; previousReplyCount.value = replies.length; - final viewportChanged = - (viewportHeight - previousViewportHeight.value).abs() >= 0.5; - previousViewportHeight.value = viewportHeight; - if (replies.length <= previous) { - // Preserve a short thread's valid top anchor when resize leaves its - // tail inside the newly measured usable viewport. Long/clipped tails - // still follow through the shared intent-serialized correction path. - if (viewportChanged && !threadTailIsVisible()) { - queueTailRealignment(animate: false); - } - return null; - } + if (replies.length <= previous) return null; final positions = itemPositionsListener.itemPositions.value; - final previousLastIndex = previous == 0 - ? headIndex - : indexForReply(previous - 1); - final wasAtTail = positions.any( - (position) => position.index == previousLastIndex, - ); + // Positions still describe the list as it was *before* these replies, so + // compare against the old tail. Measuring against the new one only reads + // as "at the tail" when exactly one reply arrived. + final previousTailAnchorIndex = previous + 1; + final wasAtTail = + positions.isEmpty || + positions.any( + (position) => position.index >= previousTailAnchorIndex, + ); final localPubkey = currentPubkey?.toLowerCase(); final hasNewLocalReply = localPubkey != null && replies .skip(previous) .any((reply) => reply.pubkey.toLowerCase() == localPubkey); + // A reply the current user just sent must be visible even if they were + // reading at the head of a long thread. Remote arrivals still respect + // the user's scroll position. if (tailIntent.isDragging) return null; if (!hasNewLocalReply && (userOptedOutOfTailFollow.value || !wasAtTail)) { return null; } - queueTailRealignment( - allowIdleDetached: hasNewLocalReply, - restoreFollow: hasNewLocalReply, + if (hasNewLocalReply) { + userOptedOutOfTailFollow.value = false; + userDragDetachedTailFollow.value = false; + } + followsThreadTail.value = true; + tailIntent.scheduleNextFrame( + allowed: true, + revalidate: () => + context.mounted && + itemScrollController.isAttached && + !tailIntent.isDragging && + !userOptedOutOfTailFollow.value, + // A reply arrival changes list geometry. Keep this correction instant; + // only an explicit tap on Latest should animate navigation. + action: correctThreadTailInstantly, ); return null; }, [hasFetchedReplies, replies.length, settleGeometry]); @@ -304,6 +407,7 @@ class ThreadDetailPage extends HookConsumerWidget { return null; }, [threadHead.id, readState.isReady, visibleReplyReadKey]); + // Thread-scoped typing indicators (exclude self). final allTyping = ref.watch(channelTypingProvider(channelId)); final threadTyping = allTyping .where((e) => e.threadHeadId == threadHead.id) @@ -314,11 +418,68 @@ class ThreadDetailPage extends HookConsumerWidget { ) .toList(); + // Resolve thread head from live data (reactions/edits may have changed). final liveHead = allMsgs.where((m) => m.id == threadHead.id).firstOrNull ?? threadHead; + // The root of the entire thread chain. If the current thread head is + // itself a root message its rootId is null, so fall back to its own id. final effectiveRootId = threadHead.rootId ?? threadHead.id; + // Composer size changes and keyboard metrics changes are independent: + // the dock grows first, then the Scaffold's viewport shrinks once the + // keyboard appears. Re-align after that latter layout pass too, but only + // while the user was already following the thread tail. + void realignThreadTailAfterMetricsChange() { + listViewport.reportAfterLayout(); + final shouldFollowTail = + !tailIntent.isDragging && + !userOptedOutOfTailFollow.value && + (followsThreadTail.value || threadTailIsVisible()); + if (!shouldFollowTail || !initialTailSettle.isComplete) return; + followsThreadTail.value = true; + tailIntent.scheduleNextFrame( + allowed: true, + revalidate: () => + context.mounted && + itemScrollController.isAttached && + !tailIntent.isDragging && + !userOptedOutOfTailFollow.value && + followsThreadTail.value, + action: () { + final targetAlignment = threadTailAlignment(); + final positions = itemPositionsListener.itemPositions.value; + final anchorPosition = positions + .where((position) => position.index == tailAnchorIndex) + .firstOrNull; + final headIsVisible = positions.any( + (position) => + position.index == headIndex && position.itemTrailingEdge > 0, + ); + if (anchorPosition != null && + ((anchorPosition.itemLeadingEdge - targetAlignment).abs() < + 0.005 || + (headIsVisible && + anchorPosition.itemLeadingEdge < targetAlignment))) { + return; + } + // This runs once after Android's frame-by-frame IME metrics settle. + // Keep the resulting layout correction instant. + correctThreadTailInstantly(); + }, + ); + } + + useEffect(() { + final previousHeight = previousViewportHeight.value; + previousViewportHeight.value = viewportHeight; + if ((viewportHeight - previousHeight).abs() >= 0.5 && + initialTailSettle.isComplete) { + realignThreadTailAfterMetricsChange(); + } + return null; + }, [viewportHeight]); + void updateComposerDockHeight(double height) { listViewport.reportAfterLayout(); final previousHeight = composerDockHeight.value; @@ -326,54 +487,43 @@ class ThreadDetailPage extends HookConsumerWidget { if (heightDelta.abs() < 0.5) return; final shouldFollowTail = + !tailIntent.isDragging && !userOptedOutOfTailFollow.value && (followsThreadTail.value || threadTailIsVisible()); if (shouldFollowTail) followsThreadTail.value = true; composerDockHeight.value = height; - if (heightDelta <= 0 || - !shouldFollowTail || - !viewportHeight.isFinite || - viewportHeight <= 0 || - !initialTailSettle.isComplete) { - pendingTailAlignment.value = null; - return; - } - final lastIndex = _threadTailIndex(replies.length); - final lastPosition = itemPositionsListener.itemPositions.value - .where((position) => position.index == lastIndex) - .firstOrNull; - if (lastPosition == null) return; - final targetAlignment = - (pendingTailAlignment.value ?? lastPosition.itemLeadingEdge) - - (heightDelta / viewportHeight); - pendingTailAlignment.value = targetAlignment; - - tailIntent.schedule( - allowed: true, - revalidate: () => - context.mounted && - itemScrollController.isAttached && - currentIntentAllowsTailMutation(), - action: () => itemScrollController.jumpTo( - index: lastIndex, - alignment: targetAlignment, - ), - ); - } - - void realignThreadTailAfterMetricsChange() { - listViewport.reportAfterLayout(); - queueTailRealignment(); + if (shouldFollowTail) realignThreadTailAfterMetricsChange(); } useEffect(() { - final observer = _ThreadTailMetricsObserver( - onMetricsChanged: realignThreadTailAfterMetricsChange, + final observer = ImeMetricsSettleObserver( + onMetricsSettled: () { + if (!usesFixedAndroidImeViewport) { + realignThreadTailAfterMetricsChange(); + return; + } + final nextInset = + appView.viewInsets.bottom / appView.devicePixelRatio; + if ((settledImeBottomInset.value - nextInset).abs() >= 0.5) { + settledImeBottomInset.value = nextInset; + } + }, ); WidgetsBinding.instance.addObserver(observer); - return () => WidgetsBinding.instance.removeObserver(observer); - }, [itemScrollController, replies.length]); + return () { + WidgetsBinding.instance.removeObserver(observer); + observer.dispose(); + }; + }, [appView, itemScrollController]); + useEffect(() { + if (usesFixedAndroidImeViewport) { + realignThreadTailAfterMetricsChange(); + } + return null; + }, [settledImeBottomInset.value]); + + // Channel names for message content rendering. final channelsAsync = ref.watch(channelsProvider); final channel = channelsAsync.value ?.where((candidate) => candidate.id == channelId) @@ -386,6 +536,7 @@ class ThreadDetailPage extends HookConsumerWidget { }); return FrostedScaffold( + resizeToAvoidBottomInset: !usesFixedAndroidImeViewport, appBar: const FrostedAppBar( title: Text('Thread'), titleStyle: channelTitleTextStyle, @@ -403,8 +554,8 @@ class ThreadDetailPage extends HookConsumerWidget { initialTailSettle.abandon(); tailIntent.beginDrag(); userOptedOutOfTailFollow.value = true; + userDragDetachedTailFollow.value = true; followsThreadTail.value = false; - pendingTailAlignment.value = null; }, onUserScrollEnd: () { tailIntent.endDrag(); @@ -415,25 +566,44 @@ class ThreadDetailPage extends HookConsumerWidget { itemScrollController.isAttached && !tailIntent.isDragging && userOptedOutOfTailFollow.value, - action: () => _resumeThreadTailFollow( - isVisible: threadTailIsVisible, - userOptedOut: userOptedOutOfTailFollow, - followsTail: followsThreadTail, - ), + action: () { + _resumeThreadTailFollow( + isVisible: threadTailIsVisible, + userOptedOut: userOptedOutOfTailFollow, + followsTail: followsThreadTail, + ); + if (!userOptedOutOfTailFollow.value) { + userDragDetachedTailFollow.value = false; + } + }, ); }, child: ScrollablePositionedList.builder( key: const ValueKey('thread-message-list'), itemScrollController: itemScrollController, itemPositionsListener: itemPositionsListener, + // Top-anchored, head first, replies flowing down — matching + // desktop's thread panel. The old reversed list bottom-anchored + // the content, which jammed the head against the composer + // whenever a thread had only a handful of replies. padding: EdgeInsets.only( left: Grid.gutter, right: Grid.gutter, top: frostedAppBarHeight(context), - bottom: Grid.xs + composerDockHeight.value, + bottom: Grid.xs + timelineBottomInset, ), - itemCount: replies.length + 1, // +1 for thread head + // Head + replies + a stable zero-content tail target. The + // anchor lets Latest align the end directly rather than + // asking the final reply's leading edge to overshoot the + // viewport and rebound against the scroll extent. + itemCount: replies.length + 2, itemBuilder: (context, index) { + if (index == tailAnchorIndex) { + return const SizedBox( + key: ValueKey('thread-tail-anchor'), + height: 1, + ); + } if (index == headIndex) { if (liveDeletionHidesHead) { return const Padding( @@ -496,6 +666,7 @@ class ThreadDetailPage extends HookConsumerWidget { ); } + // Chronological list: index 1 = oldest reply. final chronIdx = index - 1; final reply = replies[chronIdx]; final prevReply = chronIdx > 0 @@ -513,6 +684,7 @@ class ThreadDetailPage extends HookConsumerWidget { reply.pubkey.toLowerCase() || (reply.createdAt - prevReply.createdAt) > 300; + // Check if this reply itself has children (nested thread). final nestedChildren = childrenByParent[reply.id]; final nestedSummary = nestedChildren != null && nestedChildren.isNotEmpty @@ -521,6 +693,9 @@ class ThreadDetailPage extends HookConsumerWidget { return Padding( key: ValueKey('thread-message-group-${reply.id}'), + // Tail spacing comes from the list's own bottom padding now + // that the list runs top-down; the reversed list used to + // need it here because item 0 sat against the composer. padding: EdgeInsets.zero, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -563,36 +738,52 @@ class ThreadDetailPage extends HookConsumerWidget { ], ), if (isMember && !isArchived) - Align( - alignment: Alignment.bottomCenter, - child: ComposerDockSizeReporter( - key: const ValueKey('thread-composer-dock'), - onHeightChanged: updateComposerDockHeight, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _ThreadTypingIndicator(entries: threadTyping), - ComposeBar( - channelId: channelId, - hintText: 'Reply in thread\u2026', - threadHeadId: threadHead.id, - rootId: effectiveRootId, - onSend: - ( - content, - mentionPubkeys, { - mediaTags = const >[], - }) => sendMessage.call( - channelId: channelId, - content: content, - mentionPubkeys: mentionPubkeys, - channel: channel, - parentEventId: threadHead.id, - rootEventId: effectiveRootId, - mediaTags: mediaTags, - ), - ), - ], + AndroidImeLift( + child: Align( + alignment: Alignment.bottomCenter, + child: ComposerDockSizeReporter( + key: const ValueKey('thread-composer-dock'), + onHeightChanged: updateComposerDockHeight, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ThreadTypingIndicator(entries: threadTyping), + ComposeBar( + channelId: channelId, + hintText: 'Reply in thread\u2026', + threadHeadId: threadHead.id, + rootId: effectiveRootId, + onFocusRequested: followThreadTailFromComposer, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) => sendMessage.call( + channelId: channelId, + content: content, + mentionPubkeys: mentionPubkeys, + channel: channel, + parentEventId: threadHead.id, + rootEventId: effectiveRootId, + mediaTags: mediaTags, + ), + ), + ], + ), + ), + ), + ), + if (hasFetchedReplies && !isAtThreadTail.value) + Positioned( + left: 0, + right: 0, + bottom: navigationBottomInset + Grid.xs, + child: Center( + child: LatestMessageButton( + key: const ValueKey('thread-jump-to-latest'), + surfaceKey: const ValueKey('thread-jump-to-latest-surface'), + onPressed: scrollToThreadLatest, ), ), ), @@ -601,398 +792,3 @@ class ThreadDetailPage extends HookConsumerWidget { ); } } - -/// Tappable summary row shown below a reply that itself has replies. -/// Pushes a new [ThreadDetailPage] for the nested thread. -class _NestedThreadSummaryRow extends ConsumerWidget { - final ThreadSummary summary; - final TimelineMessage replyMessage; - final List allMessages; - final String channelId; - final String? currentPubkey; - final bool isMember; - final bool isArchived; - - const _NestedThreadSummaryRow({ - required this.summary, - required this.replyMessage, - required this.allMessages, - required this.channelId, - required this.currentPubkey, - required this.isMember, - required this.isArchived, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final userCache = ref.watch(userCacheProvider); - - return GestureDetector( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: replyMessage, - allMessages: allMessages, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }, - child: Padding( - key: ValueKey('nested-thread-summary-${replyMessage.id}'), - padding: const EdgeInsets.only( - left: messageAvatarSize + messageAvatarContentGap, - top: Grid.half, - bottom: Grid.xs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Stacked participant avatars. - SizedBox( - width: - 32.0 + - (summary.participantPubkeys.length - 1).clamp(0, 2) * 20.0, - height: 32, - child: Stack( - children: [ - for (var i = 0; i < summary.participantPubkeys.length; i++) - Positioned( - left: i * 20.0, - child: SmallAvatar( - pubkey: summary.participantPubkeys[i], - userCache: userCache, - size: 32, - ), - ), - ], - ), - ), - const SizedBox(width: Grid.xxs), - Flexible( - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: - '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', - style: replyPreviewTextStyle.copyWith( - color: context.colors.primary, - ), - ), - if (summary.lastReplyAt case final lastReplyAt?) ...[ - TextSpan( - text: ' · ', - style: replyPreviewTextStyle.copyWith( - color: context.colors.onSurfaceVariant.withValues( - alpha: 0.5, - ), - ), - ), - TextSpan( - text: - 'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}', - style: replyPreviewTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ); - } -} - -class _ThreadMessage extends ConsumerWidget { - final TimelineMessage message; - final Map channelNames; - final String channelId; - final String? currentPubkey; - final bool showAuthor; - final bool isHighlighted; - final List? allMessages; - final bool isMember; - final bool isArchived; - - /// Whether this is the message the thread hangs off, which keeps a standing - /// "+" where replies only get one once they carry a reaction. - final bool isThreadHead; - - const _ThreadMessage({ - required this.message, - required this.channelNames, - required this.channelId, - required this.currentPubkey, - required this.showAuthor, - this.isHighlighted = false, - this.allMessages, - this.isMember = false, - this.isArchived = false, - this.isThreadHead = false, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final pk = message.pubkey.toLowerCase(); - final profile = - ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? - ref.read(userCacheProvider.notifier).get(pk); - final displayName = profile?.label ?? shortPubkey(message.pubkey); - final canManageMessage = - currentPubkey?.toLowerCase() == pk || - (profile?.ownerPubkey != null && - profile?.ownerPubkey == currentPubkey?.toLowerCase()); - - final userCache = ref.watch(userCacheProvider); - final knownAgentPubkeys = agentPubkeysWithProfileOwners( - knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)), - profileOwnedAgentPubkeys: [ - for (final profile in userCache.values) - if (profile.ownerPubkey != null) profile.pubkey, - ], - ); - final mentionNames = {}; - final agentMentionPubkeys = {}; - for (final mpk in message.mentionPubkeys) { - final normalizedPubkey = mpk.toLowerCase(); - final p = userCache[normalizedPubkey]; - if (p?.displayName != null) { - mentionNames[normalizedPubkey] = p!.displayName!; - } - if (knownAgentPubkeys.contains(normalizedPubkey)) { - agentMentionPubkeys.add(normalizedPubkey); - } - } - final resolvedMentionNames = mentionNamesWithDirectoryLabels( - mentionPubkeys: message.mentionPubkeys, - profileMentionNames: mentionNames, - directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), - agentMentionPubkeys: agentMentionPubkeys, - ); - - void openMessageActions(Rect anchorRect) { - showMessageActions( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - anchorRect: anchorRect, - ); - } - - return Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), - child: DecoratedBox( - key: ValueKey('thread-message-${message.id}'), - decoration: BoxDecoration( - color: isHighlighted - ? context.colors.primary.withValues(alpha: 0.12) - : Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - ), - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - // The media carousel intentionally continues through the list's - // trailing gutter. InkWell still clips its ink to [borderRadius], - // while leaving overflowing message content visible. - clipBehavior: Clip.none, - child: MessageLongPressInkWell( - key: ValueKey('thread-message-row-${message.id}'), - onLongPress: openMessageActions, - borderRadius: BorderRadius.circular(Radii.md), - highlightColor: context.colors.primary.withValues(alpha: 0.1), - child: Padding( - padding: EdgeInsets.only( - top: showAuthor ? 0 : Grid.xxs, - bottom: showAuthor ? 0 : Grid.xxs, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - GestureDetector( - onTap: () => - showUserProfileSheet(context, message.pubkey), - child: _Avatar(profile: profile, pubkey: message.pubkey), - ) - else - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.quarter, - ), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - displayName: displayName, - username: messageUsernameLabel(profile), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: context.colors.onSurface, - metadataColor: - context.colors.onSurfaceVariant, - onAuthorTap: () => showUserProfileSheet( - context, - message.pubkey, - ), - displayNameKey: ValueKey( - 'thread-message-author-${message.id}', - ), - usernameKey: ValueKey( - 'thread-message-username-${message.id}', - ), - timestampKey: ValueKey( - 'thread-message-timestamp-${message.id}', - ), - ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall - ?.copyWith( - color: - context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ], - ], - ), - ), - MessageContent( - content: message.content, - mentionNames: resolvedMentionNames, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - scaleEmojiOnly: true, - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, - ref: ref, - message: message, - channelId: channelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of(viewerContext).maybePop(); - } - }, - ), - onChannelTap: (targetChannelId) { - openChannelLink( - context: context, - ref: ref, - channelId: targetChannelId, - currentChannelId: channelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet(context, pubkey), - ), - ReactionRow( - messageId: message.id, - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: - isMember && - !isArchived && - (isThreadHead || message.reactions.isNotEmpty), - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _Avatar extends StatelessWidget { - final UserProfile? profile; - final String pubkey; - - const _Avatar({required this.profile, required this.pubkey}); - - @override - Widget build(BuildContext context) { - final initial = - profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); - final avatarUrl = profile?.avatarUrl; - - return AvatarImage( - imageUrl: avatarUrl, - radius: messageAvatarSize / 2, - backgroundColor: context.colors.primaryContainer, - fallback: Text( - initial, - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ); - } -} diff --git a/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart b/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart new file mode 100644 index 000000000..1dcf40f57 --- /dev/null +++ b/mobile/lib/features/channels/thread_detail_page/nested_thread_summary_row.dart @@ -0,0 +1,113 @@ +part of '../thread_detail_page.dart'; + +/// Tappable summary row shown below a reply that itself has replies. +/// Pushes a new [ThreadDetailPage] for the nested thread. +class _NestedThreadSummaryRow extends ConsumerWidget { + final ThreadSummary summary; + final TimelineMessage replyMessage; + final List allMessages; + final String channelId; + final String? currentPubkey; + final bool isMember; + final bool isArchived; + + const _NestedThreadSummaryRow({ + required this.summary, + required this.replyMessage, + required this.allMessages, + required this.channelId, + required this.currentPubkey, + required this.isMember, + required this.isArchived, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userCache = ref.watch(userCacheProvider); + + return GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: replyMessage, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + child: Padding( + key: ValueKey('nested-thread-summary-${replyMessage.id}'), + padding: const EdgeInsets.only( + left: messageAvatarSize + messageAvatarContentGap, + top: Grid.half, + bottom: Grid.xs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Stacked participant avatars. + SizedBox( + width: + 32.0 + + (summary.participantPubkeys.length - 1).clamp(0, 2) * 20.0, + height: 32, + child: Stack( + children: [ + for (var i = 0; i < summary.participantPubkeys.length; i++) + Positioned( + left: i * 20.0, + child: SmallAvatar( + pubkey: summary.participantPubkeys[i], + userCache: userCache, + size: 32, + ), + ), + ], + ), + ), + const SizedBox(width: Grid.xxs), + Flexible( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: + '${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}', + style: replyPreviewTextStyle.copyWith( + color: context.colors.primary, + ), + ), + if (summary.lastReplyAt case final lastReplyAt?) ...[ + TextSpan( + text: ' · ', + style: replyPreviewTextStyle.copyWith( + color: context.colors.onSurfaceVariant.withValues( + alpha: 0.5, + ), + ), + ), + TextSpan( + text: + 'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}', + style: replyPreviewTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page/tail_alignment.dart b/mobile/lib/features/channels/thread_detail_page/tail_alignment.dart new file mode 100644 index 000000000..cbeed7daa --- /dev/null +++ b/mobile/lib/features/channels/thread_detail_page/tail_alignment.dart @@ -0,0 +1,17 @@ +part of '../thread_detail_page.dart'; + +double _threadTailAlignmentForViewport({ + required double fullHeight, + required double imeBottomInset, + required bool usesFixedImeViewport, + required double bottomInset, +}) { + // iOS resizes the Scaffold body around the keyboard and removes that inset + // from the body's MediaQuery. List alignment is relative to that smaller + // viewport, so using fullHeight leaves the final reply behind the composer. + final viewportHeight = + (fullHeight - (usesFixedImeViewport ? 0 : imeBottomInset)) + .clamp(1.0, double.infinity) + .toDouble(); + return (1 - bottomInset / viewportHeight).clamp(0.0, 1.0).toDouble(); +} diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart new file mode 100644 index 000000000..248ccc968 --- /dev/null +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -0,0 +1,284 @@ +part of '../thread_detail_page.dart'; + +class _ThreadMessage extends ConsumerWidget { + final TimelineMessage message; + final Map channelNames; + final String channelId; + final String? currentPubkey; + final bool showAuthor; + final bool isHighlighted; + final List? allMessages; + final bool isMember; + final bool isArchived; + + /// Whether this is the message the thread hangs off, which keeps a standing + /// "+" where replies only get one once they carry a reaction. + final bool isThreadHead; + + const _ThreadMessage({ + required this.message, + required this.channelNames, + required this.channelId, + required this.currentPubkey, + required this.showAuthor, + this.isHighlighted = false, + this.allMessages, + this.isMember = false, + this.isArchived = false, + this.isThreadHead = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pk = message.pubkey.toLowerCase(); + final profile = + ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? + ref.read(userCacheProvider.notifier).get(pk); + final displayName = profile?.label ?? shortPubkey(message.pubkey); + final canManageMessage = + currentPubkey?.toLowerCase() == pk || + (profile?.ownerPubkey != null && + profile?.ownerPubkey == currentPubkey?.toLowerCase()); + + final userCache = ref.watch(userCacheProvider); + final knownAgentPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)), + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = {}; + final agentMentionPubkeys = {}; + for (final mpk in message.mentionPubkeys) { + final normalizedPubkey = mpk.toLowerCase(); + final p = userCache[normalizedPubkey]; + if (p?.displayName != null) { + mentionNames[normalizedPubkey] = p!.displayName!; + } + if (knownAgentPubkeys.contains(normalizedPubkey)) { + agentMentionPubkeys.add(normalizedPubkey); + } + } + final resolvedMentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: message.mentionPubkeys, + profileMentionNames: mentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); + + void openMessageActions(Rect anchorRect) { + showMessageActions( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + anchorRect: anchorRect, + ); + } + + return Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), + child: DecoratedBox( + key: ValueKey('thread-message-${message.id}'), + decoration: BoxDecoration( + color: isHighlighted + ? context.colors.primary.withValues(alpha: 0.12) + : Colors.transparent, + borderRadius: BorderRadius.circular(Radii.md), + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(Radii.md), + // The media carousel intentionally continues through the list's + // trailing gutter. InkWell still clips its ink to [borderRadius], + // while leaving overflowing message content visible. + clipBehavior: Clip.none, + child: MessageLongPressInkWell( + key: ValueKey('thread-message-row-${message.id}'), + onLongPress: openMessageActions, + borderRadius: BorderRadius.circular(Radii.md), + highlightColor: context.colors.primary.withValues(alpha: 0.1), + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? 0 : Grid.xxs, + bottom: showAuthor ? 0 : Grid.xxs, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + GestureDetector( + onTap: () => + showUserProfileSheet(context, message.pubkey), + child: _Avatar(profile: profile, pubkey: message.pubkey), + ) + else + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, + ), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + displayName: displayName, + username: messageUsernameLabel(profile), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: context.colors.onSurface, + metadataColor: + context.colors.onSurfaceVariant, + onAuthorTap: () => showUserProfileSheet( + context, + message.pubkey, + ), + displayNameKey: ValueKey( + 'thread-message-author-${message.id}', + ), + usernameKey: ValueKey( + 'thread-message-username-${message.id}', + ), + timestampKey: ValueKey( + 'thread-message-timestamp-${message.id}', + ), + ), + ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall + ?.copyWith( + color: + context.colors.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + MessageContent( + content: message.content, + mentionNames: resolvedMentionNames, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + scaleEmojiOnly: true, + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: channelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of(viewerContext).maybePop(); + } + }, + ), + onChannelTap: (targetChannelId) { + openChannelLink( + context: context, + ref: ref, + channelId: targetChannelId, + currentChannelId: channelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet(context, pubkey), + ), + ReactionRow( + messageId: message.id, + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: + isMember && + !isArchived && + (isThreadHead || message.reactions.isNotEmpty), + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _Avatar extends StatelessWidget { + final UserProfile? profile; + final String pubkey; + + const _Avatar({required this.profile, required this.pubkey}); + + @override + Widget build(BuildContext context) { + final initial = + profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); + final avatarUrl = profile?.avatarUrl; + + return AvatarImage( + imageUrl: avatarUrl, + radius: messageAvatarSize / 2, + backgroundColor: context.colors.primaryContainer, + fallback: Text( + initial, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 0615a1963..1001e16cd 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -39,9 +39,7 @@ const _searchTitleReturnDuration = Duration(milliseconds: 80); const _searchCancelEnterDuration = Duration(milliseconds: 80); const _searchCancelExitDuration = Duration(milliseconds: 60); const _searchIdleFieldTopInset = Grid.half; -const _searchActiveFieldTopOffset = 42.0; -const _searchBottomOverlap = - _searchActiveFieldTopOffset + _searchIdleFieldTopInset; +const _searchControlsToFiltersGap = Grid.xxs; const _searchFilterChipVerticalPadding = Grid.xxs; const _searchFilterBarVerticalPadding = Grid.xxs; const _searchHeaderFiltersMinHeight = Grid.xl; @@ -130,15 +128,17 @@ class SearchPage extends HookConsumerWidget { final idleSearchFieldHeight = _idleSearchFieldHeight(context); final searchHeaderFiltersHeight = _searchHeaderFiltersHeight(context); final searchActiveFieldRightInset = _searchActiveFieldRightInset(context); + final searchBottomOverlap = + _searchIdleFieldTopInset + + compactSearchFieldHeight + + _searchControlsToFiltersGap; // Cancel remains an accessible target without giving the text action a // visual button treatment. final searchControlHeight = compactSearchFieldHeight > Grid.xl ? compactSearchFieldHeight : Grid.xl; final searchHeaderBottomHeight = isSearchEditing.value - ? _searchIdleFieldTopInset + - compactSearchFieldHeight + - searchHeaderFiltersHeight + ? searchHeaderFiltersHeight + _searchControlsToFiltersGap : idleSearchFieldHeight + _searchIdleFieldTopInset + Grid.xxs; final topSectionHeight = frostedAppBarHeight( context, @@ -295,7 +295,7 @@ class SearchPage extends HookConsumerWidget { ), ], bottomHeight: searchHeaderBottomHeight, - bottomOverlap: _searchBottomOverlap, + bottomOverlap: searchBottomOverlap, bottom: Stack( clipBehavior: Clip.none, children: [ @@ -308,7 +308,7 @@ class SearchPage extends HookConsumerWidget { : Grid.gutter, top: isSearchEditing.value ? _searchIdleFieldTopInset - : _searchBottomOverlap + _searchIdleFieldTopInset, + : searchBottomOverlap + _searchIdleFieldTopInset, height: isSearchEditing.value ? compactSearchFieldHeight : idleSearchFieldHeight, @@ -360,7 +360,7 @@ class SearchPage extends HookConsumerWidget { ? Align( alignment: Alignment.topCenter, child: Padding( - padding: EdgeInsets.only(top: _searchBottomOverlap), + padding: EdgeInsets.only(top: searchBottomOverlap), child: SizedBox( key: const ValueKey('search-header-filters'), height: searchHeaderFiltersHeight, diff --git a/mobile/lib/shared/theme/message_typography.dart b/mobile/lib/shared/theme/message_typography.dart index 5a5d2c2c6..37c1be0ea 100644 --- a/mobile/lib/shared/theme/message_typography.dart +++ b/mobile/lib/shared/theme/message_typography.dart @@ -43,8 +43,14 @@ const messageMetadataTextStyle = TextStyle( letterSpacing: 0, ); -/// Message timestamps share the secondary author metadata style. -const messageTimestampTextStyle = messageMetadataTextStyle; +/// Message timestamps: 13.1sp regular, one step below 15sp author names. +const messageTimestampTextStyle = TextStyle( + fontFamily: _fontFamily, + fontSize: 13.1, + fontWeight: FontWeight.w400, + height: 17 / 13.1, + letterSpacing: 0, +); /// Compact reply previews: 13.1sp regular on a 17sp line height. const replyPreviewTextStyle = TextStyle( @@ -121,8 +127,8 @@ const systemMessageBodyTextStyle = TextStyle( /// Activity sender names share the primary author style. const activityUsernameTextStyle = messageUsernameTextStyle; -/// Activity timestamps share the secondary author metadata style. -const activityTimestampTextStyle = messageMetadataTextStyle; +/// Activity timestamps share the compact message timestamp treatment. +const activityTimestampTextStyle = messageTimestampTextStyle; /// Activity context labels: 13.1sp medium on a 17sp line height. const activityContextTextStyle = TextStyle( diff --git a/mobile/lib/shared/widgets/message_author_meta.dart b/mobile/lib/shared/widgets/message_author_meta.dart index c7aff7df8..ed1e3a6e2 100644 --- a/mobile/lib/shared/widgets/message_author_meta.dart +++ b/mobile/lib/shared/widgets/message_author_meta.dart @@ -16,7 +16,7 @@ class MessageAuthorMeta extends StatelessWidget { /// Color applied to [displayName]. final Color nameColor; - /// Color applied to the username, separator, and [timestamp]. + /// Color applied to the username and [timestamp]. final Color metadataColor; /// Optional callback invoked when [displayName] is tapped. @@ -37,6 +37,9 @@ class MessageAuthorMeta extends StatelessWidget { /// Base text style for secondary metadata, with [metadataColor] applied. final TextStyle metadataStyle; + /// Base text style for [timestamp], with [metadataColor] applied. + final TextStyle timestampStyle; + /// Creates an inline author row with optional username and tap handling. const MessageAuthorMeta({ super.key, @@ -51,6 +54,7 @@ class MessageAuthorMeta extends StatelessWidget { this.timestampKey, this.nameStyle = messageUsernameTextStyle, this.metadataStyle = messageMetadataTextStyle, + this.timestampStyle = messageTimestampTextStyle, }); @override @@ -62,6 +66,9 @@ class MessageAuthorMeta extends StatelessWidget { normalizedUsername != displayName.trim(); final resolvedNameStyle = nameStyle.copyWith(color: nameColor); final resolvedMetadataStyle = metadataStyle.copyWith(color: metadataColor); + final resolvedTimestampStyle = timestampStyle.copyWith( + color: metadataColor, + ); Widget authorName = Text( displayName, @@ -97,9 +104,7 @@ class MessageAuthorMeta extends StatelessWidget { ), ), ], - const SizedBox(width: Grid.half), - Text('·', style: resolvedMetadataStyle), - const SizedBox(width: Grid.half), + const SizedBox(width: Grid.xxs), ConstrainedBox( constraints: BoxConstraints(maxWidth: metadataMaxWidth), child: Text( @@ -107,7 +112,7 @@ class MessageAuthorMeta extends StatelessWidget { key: timestampKey, maxLines: 1, overflow: TextOverflow.ellipsis, - style: resolvedMetadataStyle, + style: resolvedTimestampStyle, ), ), ], diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 8619a513d..94d040526 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:buzz/features/activity/activity_page.dart'; import 'package:buzz/features/activity/activity_provider.dart'; +import 'package:buzz/features/activity/compose_drafts_provider.dart'; import 'package:buzz/features/activity/feed_item.dart'; import 'package:buzz/features/activity/inbox_item.dart'; import 'package:buzz/features/activity/reminders_provider.dart'; @@ -117,6 +118,8 @@ void main() { TextScaler? textScaler, EdgeInsets mediaPadding = EdgeInsets.zero, ValueListenable? tabReselection, + List drafts = const [], + List reminders = const [], }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -135,7 +138,10 @@ void main() { readStateProvider.overrideWith( () => _FakeReadStateNotifier(readContexts), ), - remindersProvider.overrideWith(() => _FakeRemindersNotifier(const [])), + composeDraftsProvider.overrideWith( + () => _FakeComposeDraftsNotifier(drafts), + ), + remindersProvider.overrideWith(() => _FakeRemindersNotifier(reminders)), ], child: MaterialApp( theme: AppTheme.light(), @@ -386,6 +392,65 @@ void main() { ); }); + testWidgets('filter stays indicator-free when drafts and reminders exist', ( + tester, + ) async { + final dueReminder = Reminder( + id: 'reminder-1', + notBefore: now - 1, + status: 'pending', + target: const ReminderTarget( + eventId: 'm1', + channelId: 'ch1', + preview: 'Follow up', + authorPubkey: 'alice_pk', + ), + note: null, + createdAt: now - 60, + eventId: 'reminder-event-1', + ); + final draft = ComposeDraft( + key: 'ch1', + channelId: 'ch1', + threadHeadId: null, + text: 'Unsent review note', + updatedAt: now, + ); + + await tester.pumpWidget( + await buildTestable(drafts: [draft], reminders: [dueReminder]), + ); + await tester.pumpAndSettle(); + + final filterTrigger = find.byKey(const ValueKey('activity-filter-menu')); + expect( + find.descendant( + of: filterTrigger, + matching: find.byWidgetPredicate((widget) { + if (widget is! Container) return false; + final constraints = widget.constraints; + return constraints?.minWidth == 6 && constraints?.minHeight == 6; + }), + ), + findsNothing, + ); + + await tester.tap(filterTrigger); + await tester.pumpAndSettle(); + final filterPopover = find.byKey(const ValueKey('activity-filter-popover')); + expect( + find.descendant(of: filterPopover, matching: find.text('1')), + findsNothing, + ); + + await tester.tap( + find.descendant(of: filterPopover, matching: find.text('Drafts')), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('draft-row-ch1')), findsOneWidget); + expect(find.text('Unsent review note'), findsOneWidget); + }); + testWidgets('rows lead with sender, contextual label, and preview', ( tester, ) async { @@ -434,8 +499,12 @@ void main() { expect(usernameText.style?.fontSize, messageMetadataTextStyle.fontSize); expect(usernameText.style?.fontWeight, FontWeight.w400); expect(usernameText.style?.height, messageMetadataTextStyle.height); - expect(timestampText.style?.fontSize, messageMetadataTextStyle.fontSize); + expect(timestampText.style?.fontSize, activityTimestampTextStyle.fontSize); expect(timestampText.style?.fontWeight, FontWeight.w400); + expect( + timestampText.style?.fontSize, + lessThan(usernameText.style!.fontSize!), + ); final avatars = tester.widgetList(find.byType(AvatarImage)); expect(avatars, isNotEmpty); @@ -909,3 +978,11 @@ class _FakeRemindersNotifier extends RemindersNotifier { @override Future> build() async => _reminders; } + +class _FakeComposeDraftsNotifier extends ComposeDraftsNotifier { + final List _drafts; + _FakeComposeDraftsNotifier(this._drafts); + + @override + List build() => _drafts; +} diff --git a/mobile/test/features/channels/android_ime_lift_test.dart b/mobile/test/features/channels/android_ime_lift_test.dart new file mode 100644 index 000000000..208ff6f67 --- /dev/null +++ b/mobile/test/features/channels/android_ime_lift_test.dart @@ -0,0 +1,93 @@ +import 'package:buzz/features/channels/android_ime_lift.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('lifts only the composer on Android', (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + await tester.pumpWidget(_testLayout()); + + expect( + tester.getBottomLeft(find.byKey(const ValueKey('composer'))).dy, + 280, + ); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('leaves the iOS composer on the scaffold resize path', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + await tester.pumpWidget(_testLayout()); + + expect( + tester.getBottomLeft(find.byKey(const ValueKey('composer'))).dy, + 400, + ); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('does not double-count Android navigation safe area', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + await tester.pumpWidget( + _testLayout(viewPadding: 24, reservesViewPadding: true), + ); + + final keyboardTop = 400 - 120; + final composerBottom = tester + .getBottomLeft(find.byKey(const ValueKey('composer'))) + .dy; + expect(keyboardTop - composerBottom, Grid.xxs); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); +} + +Widget _testLayout({double viewPadding = 0, bool reservesViewPadding = false}) { + return MediaQuery( + data: MediaQueryData( + viewInsets: const EdgeInsets.only(bottom: 120), + viewPadding: EdgeInsets.only(bottom: viewPadding), + ), + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + height: 400, + child: AndroidImeLift( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.only( + bottom: reservesViewPadding ? viewPadding + Grid.xxs : 0, + ), + child: const SizedBox( + key: ValueKey('composer'), + width: 100, + height: 40, + ), + ), + ), + ), + ), + ), + ), + ); +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 365befcff..79e385452 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -20,6 +20,7 @@ import 'package:buzz/features/channels/composer_dock_size_reporter.dart'; import 'package:buzz/features/channels/date_formatters.dart'; import 'package:buzz/features/channels/day_divider.dart'; import 'package:buzz/features/channels/emoji_picker.dart'; +import 'package:buzz/features/channels/ime_metrics_settle_observer.dart'; import 'package:buzz/features/channels/reaction_row.dart'; import 'package:buzz/features/channels/thread_detail_page.dart'; import 'package:buzz/features/channels/thread_replies_provider.dart'; @@ -36,6 +37,7 @@ import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:buzz/shared/widgets/frosted_scaffold.dart'; import 'package:buzz/shared/widgets/keyboard_dismiss_on_drag.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; @@ -762,6 +764,10 @@ void main() { expect(find.text('Forum threads are not on mobile yet'), findsNothing); // The compose bar for stream messages should not appear. expect(find.text('Message…'), findsNothing); + final scaffold = tester.widget( + find.byType(FrostedScaffold).first, + ); + expect(scaffold.resizeToAvoidBottomInset, isTrue); }); testWidgets('renders video attachments from imeta tags in the timeline', ( @@ -1145,8 +1151,22 @@ void main() { expect(aliceUsername.style?.fontSize, messageMetadataTextStyle.fontSize); expect(aliceUsername.style?.fontWeight, FontWeight.w400); expect(aliceUsername.style?.height, messageMetadataTextStyle.height); - expect(aliceTimestamp.style?.fontSize, messageMetadataTextStyle.fontSize); + expect( + aliceTimestamp.style?.fontSize, + messageTimestampTextStyle.fontSize, + ); expect(aliceTimestamp.style?.fontWeight, FontWeight.w400); + expect( + aliceTimestamp.style?.fontSize, + lessThan(aliceText.style!.fontSize!), + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('message-row-msg1')), + matching: find.text('·'), + ), + findsNothing, + ); final helloContent = findRichText('Hello world!'); final helloText = tester.widget(helloContent); expect( @@ -2285,6 +2305,61 @@ void main() { }, ); + testWidgets( + 'seeds an already-visible Android keyboard into the channel tail layout', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + tester.view.viewPadding = const FakeViewPadding(bottom: 24); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.reset); + + final messages = [ + for (var i = 0; i < 20; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: i == 19 + ? List.filled(8, 'Tall latest message').join('\n') + : 'Message $i', + createdAt: 1000 + i, + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: messages, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + final latestMessage = find.byKey( + const ValueKey('channel-message-group-msg19'), + ); + final composerDock = find.byKey( + const ValueKey('channel-composer-dock'), + ); + expect(latestMessage, findsOneWidget); + expect( + tester.getBottomLeft(latestMessage).dy, + closeTo(tester.getTopLeft(composerDock).dy, 1), + ); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + ); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + testWidgets('keeps a short followed tail flush through composer resize', ( tester, ) async { @@ -2491,6 +2566,182 @@ void main() { ); }); + testWidgets( + 'Latest reveals the channel tail while the Android keyboard stays open', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + tester.view.viewPadding = const FakeViewPadding(bottom: 24); + addTearDown(tester.view.reset); + + final messages = [ + for (var i = 0; i < 40; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: messages, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Message #general')); + await tester.pump(); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pump(); + await tester.pump(androidImeMetricsSettleDelay); + await tester.pumpAndSettle(); + + final textField = tester.widget(find.byType(TextField)); + expect(textField.focusNode?.hasFocus, isTrue); + + final messageList = find.byKey( + const ValueKey('channel-message-list'), + ); + final messageListElement = tester.element(messageList); + UserScrollNotification( + metrics: FixedScrollMetrics( + minScrollExtent: 0, + maxScrollExtent: 100, + pixels: 0, + viewportDimension: 100, + axisDirection: AxisDirection.down, + devicePixelRatio: 1, + ), + context: messageListElement, + direction: ScrollDirection.reverse, + ).dispatch(messageListElement); + tester + .widget(messageList) + .itemScrollController! + .jumpTo(index: 39); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsOneWidget, + ); + + await tester.tap( + find.byKey(const ValueKey('channel-jump-to-latest')), + ); + await tester.pumpAndSettle(); + + final latestMessage = find.byKey( + const ValueKey('channel-message-group-msg39'), + ); + final composerDock = find.byKey( + const ValueKey('channel-composer-dock'), + ); + expect(latestMessage, findsOneWidget); + expect(textField.focusNode?.hasFocus, isTrue); + expect( + tester.getBottomLeft(latestMessage).dy, + closeTo(tester.getTopLeft(composerDock).dy, 1), + ); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + ); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + + testWidgets( + 'keeps the Latest gap stable above the Android composer and keyboard', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + tester.view.viewPadding = const FakeViewPadding(bottom: 24); + addTearDown(tester.view.reset); + + final initialMessages = [ + for (var i = 0; i < 40; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + ), + ]; + await tester.pumpWidget( + _buildTestable( + messages: initialMessages, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + final messageList = find.byKey( + const ValueKey('channel-message-list'), + ); + final messageListElement = tester.element(messageList); + UserScrollNotification( + metrics: FixedScrollMetrics( + minScrollExtent: 0, + maxScrollExtent: 100, + pixels: 0, + viewportDimension: 100, + axisDirection: AxisDirection.down, + devicePixelRatio: 1, + ), + context: messageListElement, + direction: ScrollDirection.reverse, + ).dispatch(messageListElement); + tester + .widget(messageList) + .itemScrollController! + .jumpTo(index: 39); + await tester.pumpAndSettle(); + + final latestSurface = find.byKey( + const ValueKey('channel-jump-to-latest-surface'), + ); + final composerDock = find.byKey( + const ValueKey('channel-composer-dock'), + ); + double latestGap() => + tester.getTopLeft(composerDock).dy - + tester.getBottomLeft(latestSurface).dy; + + final collapsedGap = latestGap(); + expect(collapsedGap, closeTo(Grid.xs, 0.5)); + + await tester.tap(find.text('Message #general')); + await tester.pump(); + await tester.pump(); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pump(); + await tester.pump(androidImeMetricsSettleDelay); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsOneWidget); + expect(latestGap(), closeTo(collapsedGap, 0.5)); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + testWidgets( 'keeps follow mode off while a tall newest message stays visible', (tester) async { @@ -4292,6 +4543,20 @@ void main() { .dy; expect(headY, lessThan(oldestReplyY)); expect(oldestReplyY, lessThan(newestReplyY)); + final threadTimestamp = tester.widget( + find.byKey(const ValueKey('thread-message-timestamp-thread-root')), + ); + expect( + threadTimestamp.style?.fontSize, + messageTimestampTextStyle.fontSize, + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('thread-message-row-thread-root')), + matching: find.text('·'), + ), + findsNothing, + ); }); testWidgets('thread keeps its tail above a growing composer dock', ( @@ -4376,12 +4641,90 @@ void main() { // that metrics change too. tester.view.viewInsets = const FakeViewPadding(bottom: 300); addTearDown(tester.view.reset); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(androidImeMetricsSettleDelay); + await tester.pump(); expect( tester.getBottomLeft(latestReply).dy, lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy), ); + expect( + tester + .state( + find + .descendant( + of: find.byKey(const ValueKey('thread-message-list')), + matching: find.byType(Scrollable), + ) + .first, + ) + .position + .isScrollingNotifier + .value, + isFalse, + reason: 'Keyboard layout correction must not start a scroll animation.', + ); + }); + + testWidgets('short thread keeps its head stable when the keyboard opens', ( + tester, + ) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'A short thread', + createdAt: 1000, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: const {'thread-root': []}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + final head = find.byKey( + const ValueKey('thread-message-group-thread-root'), + ); + final initialHeadY = tester.getTopLeft(head).dy; + expect(initialHeadY, lessThan(300)); + + await tester.tap(find.text('Reply in thread…').hitTestable()); + await tester.pumpAndSettle(); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pump(); + await tester.pump(androidImeMetricsSettleDelay); + await tester.pump(); + + expect( + tester.getTopLeft(head).dy, + closeTo(initialHeadY, 1), + reason: 'A fully visible short thread should remain head-anchored.', + ); }); for (final replyCount in [0, 1]) { @@ -4898,7 +5241,11 @@ void main() { final list = find.byKey(const ValueKey('thread-message-list')); final listHeight = tester.getSize(list).height; final mediaQueryHeight = MediaQuery.sizeOf(tester.element(list)).height; - expect(listHeight, lessThan(mediaQueryHeight)); + expect( + listHeight, + closeTo(mediaQueryHeight, 0.5), + reason: 'Android keeps the thread viewport fixed behind the IME.', + ); completer.complete(replies); await tester.pumpAndSettle(); @@ -5114,6 +5461,10 @@ void main() { find.byKey(const ValueKey('thread-message-group-thread-root')), findsOneWidget, ); + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsNothing, + ); completer.complete(replies); await tester.pump(); @@ -5988,8 +6339,9 @@ void main() { await tester.pumpAndSettle(); } expect( - find.byKey(const ValueKey('thread-message-group-reply-29')), - findsNothing, + find.byKey(const ValueKey('thread-jump-to-latest')), + findsOneWidget, + reason: 'Browsing away from the tail should offer Latest.', ); final visibleBeforeResize = tester .widgetList( @@ -6051,7 +6403,7 @@ void main() { ); testWidgets( - 'deep-linking an older reply does not resume tail following on keyboard resize', + 'deep-link stays put through passive resize until composer focus follows tail', (tester) async { tester.view.physicalSize = const Size(400, 800); tester.view.devicePixelRatio = 1; @@ -6115,13 +6467,427 @@ void main() { const ValueKey('thread-message-group-reply-5'), ); expect(target, findsOneWidget); + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsOneWidget, + ); - await tester.tap(find.text('Reply in thread…').hitTestable()); - await tester.pumpAndSettle(); tester.view.viewInsets = const FakeViewPadding(bottom: 300); await tester.pumpAndSettle(); expect(target, findsOneWidget); + + await tester.tap(find.text('Reply in thread…').hitTestable()); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('thread-message-group-reply-29')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsNothing, + ); + }, + ); + + testWidgets('iOS composer focus and Latest fully reveal the final reply', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + tester.view.viewPadding = const FakeViewPadding(bottom: 20); + addTearDown(tester.view.reset); + + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 30; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: {'thread-root': replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Reply in thread…').hitTestable()); + await tester.pumpAndSettle(); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + + final latestReply = find.byKey( + const ValueKey('thread-message-group-reply-29'), + ); + final composerSurface = find.byKey(const ValueKey('composer-surface')); + final focusedReplyBottom = tester.getBottomLeft(latestReply).dy; + final composerTop = tester.getTopLeft(composerSurface).dy; + + final list = find.byKey(const ValueKey('thread-message-list')); + final listElement = tester.element(list); + ScrollStartNotification( + metrics: FixedScrollMetrics( + minScrollExtent: 0, + maxScrollExtent: 100, + pixels: 0, + viewportDimension: 100, + axisDirection: AxisDirection.down, + devicePixelRatio: 1, + ), + context: listElement, + dragDetails: DragStartDetails(), + ).dispatch(listElement); + tester + .widget(list) + .itemScrollController! + .jumpTo(index: 5); + await tester.pumpAndSettle(); + final latestButton = find.byKey(const ValueKey('thread-jump-to-latest')); + final latestButtonWasVisible = latestButton.evaluate().length == 1; + await tester.tap(latestButton); + await tester.pumpAndSettle(); + final latestReplyBottom = tester.getBottomLeft(latestReply).dy; + debugDefaultTargetPlatformOverride = previousPlatform; + + expect( + focusedReplyBottom, + lessThanOrEqualTo(composerTop), + reason: 'Focusing the composer must retain the final reply above it.', + ); + expect(latestButtonWasVisible, isTrue); + expect( + latestReplyBottom, + lessThanOrEqualTo(composerTop), + reason: 'Latest must reveal the final reply above the iOS composer.', + ); + }); + + for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) { + testWidgets( + 'thread composer focus returns to the tail on ${platform.name}', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = platform; + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + tester.view.viewPadding = const FakeViewPadding(bottom: 20); + addTearDown(tester.view.reset); + + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 30; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: {'thread-root': replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + initialMessageId: 'reply-5', + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsOneWidget, + ); + + await tester.tap(find.text('Reply in thread…').hitTestable()); + await tester.pump(); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pump(); + if (platform == TargetPlatform.android) { + await tester.pump(androidImeMetricsSettleDelay); + } + await tester.pumpAndSettle(); + + final latestReply = find.byKey( + const ValueKey('thread-message-group-reply-29'), + ); + final composerSurface = find.byKey( + const ValueKey('composer-surface'), + ); + final focusNode = tester + .widget(find.byType(TextField)) + .focusNode!; + final latestReplyBottom = tester.getBottomLeft(latestReply).dy; + final composerTop = tester.getTopLeft(composerSurface).dy; + final latestButtonIsVisible = find + .byKey(const ValueKey('thread-jump-to-latest')) + .evaluate() + .isNotEmpty; + debugDefaultTargetPlatformOverride = previousPlatform; + + expect(focusNode.hasFocus, isTrue); + expect(latestReplyBottom, lessThanOrEqualTo(composerTop)); + expect(latestButtonIsVisible, isFalse); + }, + ); + } + + testWidgets( + 'thread shows Latest after browsing history and returns to tail', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 30; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: {'thread-root': replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + final list = find.byKey(const ValueKey('thread-message-list')); + expect( + find.byKey(const ValueKey('thread-message-group-reply-29')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsNothing, + ); + + await tester.drag(list, const Offset(0, 500)); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsOneWidget, + ); + + final threadScrollable = tester.state( + find.descendant(of: list, matching: find.byType(Scrollable)).first, + ); + await tester.tap(find.byKey(const ValueKey('thread-jump-to-latest'))); + await tester.pump(const Duration(milliseconds: 50)); + + expect( + threadScrollable.position.isScrollingNotifier.value, + isTrue, + reason: 'An explicit Latest tap should retain its navigation motion.', + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('thread-message-group-reply-29')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsNothing, + ); + final settledTailY = tester + .getTopLeft(find.byKey(const ValueKey('thread-tail-anchor'))) + .dy; + await tester.pump(const Duration(milliseconds: 250)); + expect( + tester + .getTopLeft(find.byKey(const ValueKey('thread-tail-anchor'))) + .dy, + closeTo(settledTailY, 0.5), + reason: 'Latest must not be followed by a corrective rebound.', + ); + }, + ); + + testWidgets( + 'a newly sent thread reply follows the tail without animation', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 20; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + final messagesNotifier = _FakeMessagesNotifier([rootEvent]); + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + messagesNotifier: messagesNotifier, + threadReplies: {'thread-root': replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + 'self': UserProfile(pubkey: 'self', displayName: 'Me'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + final list = find.byKey(const ValueKey('thread-message-list')); + final threadScrollable = tester.state( + find.descendant(of: list, matching: find.byType(Scrollable)).first, + ); + final localReply = _textMsg( + id: 'reply-local', + pubkey: 'self', + content: 'My new reply', + createdAt: 2000, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ); + + messagesNotifier.setMessages([rootEvent, localReply]); + await tester.pump(); + await tester.pump(); + + expect( + find.byKey(const ValueKey('thread-message-group-reply-local')), + findsOneWidget, + ); + expect( + threadScrollable.position.isScrollingNotifier.value, + isFalse, + reason: 'Reply-driven tail correction must be instant.', + ); + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsNothing, + ); }, ); diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index df84bf073..1270bc386 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -181,6 +181,7 @@ Widget _buildComposeBar({ List customEmoji = const [], RelayConfigNotifier Function()? relayConfig, PhotoLibrary photoLibrary = const _EmptyPhotoLibrary(), + VoidCallback? onFocusRequested, }) { return ProviderScope( overrides: [ @@ -227,7 +228,11 @@ Widget _buildComposeBar({ body: SafeArea( child: Align( alignment: Alignment.bottomCenter, - child: ComposeBar(channelId: 'channel-1', onSend: onSend), + child: ComposeBar( + channelId: 'channel-1', + onFocusRequested: onFocusRequested, + onSend: onSend, + ), ), ), ), @@ -526,6 +531,110 @@ void main() { expect(find.byIcon(LucideIcons.aLargeSmall), findsOneWidget); }); + testWidgets('notifies focus intent before attaching the focused field', ( + tester, + ) async { + var focusRequested = false; + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onFocusRequested: () => focusRequested = true, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await tester.tap(find.text('Message\u2026')); + expect(focusRequested, isTrue); + await tester.pump(); + await tester.pump(); + + expect(find.byType(TextField), findsOneWidget); + expect( + tester.widget(find.byType(TextField)).focusNode!.hasFocus, + isTrue, + ); + }); + + testWidgets('starts Android composer motion with the first IME metrics', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + final widthFinder = find.byKey( + const ValueKey('composer-width-transition'), + ); + final compactWidth = tester.getSize(widthFinder).width; + + await tester.tap(find.text('Message\u2026')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + + expect(tester.getSize(widthFinder).width, closeTo(compactWidth, 0.1)); + + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.reset); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + expect(tester.getSize(widthFinder).width, greaterThan(compactWidth)); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('expands Android composer when the IME is already visible', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(() { + tester.view.reset(); + debugDefaultTargetPlatformOverride = previousPlatform; + }); + try { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + final widthFinder = find.byKey( + const ValueKey('composer-width-transition'), + ); + final compactWidth = tester.getSize(widthFinder).width; + + await tester.tap(find.text('Message\u2026')); + await tester.pumpAndSettle(); + + expect(tester.getSize(widthFinder).width, greaterThan(compactWidth)); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + testWidgets('returns to the compact capsule when the keyboard drops', ( tester, ) async { @@ -705,6 +814,9 @@ void main() { await tester.tap(find.text('Message\u2026')); await tester.pump(); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.reset); + await tester.pump(); await tester.pump(const Duration(milliseconds: 80)); await tester.tap(find.byTooltip('Add attachment').hitTestable()); await tester.pumpAndSettle(); diff --git a/mobile/test/features/channels/ime_metrics_settle_observer_test.dart b/mobile/test/features/channels/ime_metrics_settle_observer_test.dart new file mode 100644 index 000000000..78fa70a90 --- /dev/null +++ b/mobile/test/features/channels/ime_metrics_settle_observer_test.dart @@ -0,0 +1,49 @@ +import 'package:buzz/features/channels/ime_metrics_settle_observer.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('coalesces Android IME metrics until the viewport settles', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + var callbacks = 0; + final observer = ImeMetricsSettleObserver( + onMetricsSettled: () => callbacks += 1, + ); + addTearDown(observer.dispose); + + observer.didChangeMetrics(); + await tester.pump(const Duration(milliseconds: 60)); + observer.didChangeMetrics(); + await tester.pump(const Duration(milliseconds: 119)); + expect(callbacks, 0); + + await tester.pump(const Duration(milliseconds: 1)); + expect(callbacks, 1); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('keeps non-Android metrics callbacks immediate', (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + var callbacks = 0; + final observer = ImeMetricsSettleObserver( + onMetricsSettled: () => callbacks += 1, + ); + addTearDown(observer.dispose); + + observer.didChangeMetrics(); + observer.didChangeMetrics(); + + expect(callbacks, 2); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); +} diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index 55645313c..de8cda398 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -316,6 +316,14 @@ void main() { reason: 'The active field translates upward into the title row.', ); expect(find.byKey(const Key('search-header-filters')), findsOneWidget); + final filtersRect = tester.getRect( + find.byKey(const Key('search-header-filters')), + ); + expect( + filtersRect.top - focusedRect.bottom, + Grid.xxs, + reason: 'Filters keep one compact spacing token below the controls.', + ); final settledSlide = tester.widget( find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first, ); @@ -330,6 +338,12 @@ void main() { expect(iconScale.scale, lessThan(1)); expect(movingField.top, Grid.half); final appBarRect = tester.getRect(find.byType(FrostedAppBar)); + expect( + appBarRect.bottom - filtersRect.bottom, + closeTo(Grid.xxs + 1, 0.01), + reason: + 'The filter row keeps the same spacing below it, plus the divider.', + ); expect( appBarRect.contains(focusedRect.center), isTrue, diff --git a/mobile/test/shared/theme/message_typography_test.dart b/mobile/test/shared/theme/message_typography_test.dart index c5bec5555..6616b7fd2 100644 --- a/mobile/test/shared/theme/message_typography_test.dart +++ b/mobile/test/shared/theme/message_typography_test.dart @@ -34,12 +34,15 @@ void main() { ); expectStyle( messageTimestampTextStyle, - fontSize: 15, + fontSize: 13.1, fontWeight: FontWeight.w400, lineHeight: 17, letterSpacing: 0, ); - expect(messageTimestampTextStyle, messageMetadataTextStyle); + expect( + messageTimestampTextStyle.fontSize, + lessThan(messageUsernameTextStyle.fontSize!), + ); expectStyle( replyPreviewTextStyle, fontSize: 13.1, @@ -87,7 +90,7 @@ void main() { ); expectStyle( activityTimestampTextStyle, - fontSize: 15, + fontSize: 13.1, fontWeight: FontWeight.w400, lineHeight: 17, letterSpacing: 0, diff --git a/mobile/test/shared/widgets/message_author_meta_test.dart b/mobile/test/shared/widgets/message_author_meta_test.dart index 3b55946d4..256685a07 100644 --- a/mobile/test/shared/widgets/message_author_meta_test.dart +++ b/mobile/test/shared/widgets/message_author_meta_test.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('keeps the separator and timestamp next to a short name', ( + testWidgets('keeps the timestamp next to a short name without a separator', ( tester, ) async { const displayNameKey = Key('author-display-name'); @@ -31,11 +31,14 @@ void main() { await tester.pumpAndSettle(); final displayNameRect = tester.getRect(find.byKey(displayNameKey)); - final separatorRect = tester.getRect(find.text('·')); final timestampRect = tester.getRect(find.byKey(timestampKey)); - expect(separatorRect.left - displayNameRect.right, Grid.half); - expect(timestampRect.left - separatorRect.right, Grid.half); + expect(find.text('·'), findsNothing); + expect(timestampRect.left - displayNameRect.right, Grid.xxs); + expect( + tester.widget(find.byKey(timestampKey)).style?.fontSize, + messageTimestampTextStyle.fontSize, + ); expect(tester.takeException(), isNull); }); @@ -84,6 +87,7 @@ void main() { testWidgets('constrains long metadata at large accessible text sizes', ( tester, ) async { + const displayNameKey = Key('author-display-name'); const timestampKey = Key('author-timestamp'); await tester.pumpWidget( @@ -98,6 +102,7 @@ void main() { displayName: 'A very long display name', username: 'a-very-long-username', timestamp: 'Mar 15, 2025', + displayNameKey: displayNameKey, timestampKey: timestampKey, nameColor: Colors.black, metadataColor: Colors.grey, @@ -112,6 +117,20 @@ void main() { final timestamp = tester.widget(find.byKey(timestampKey)); expect(timestamp.maxLines, 1); expect(timestamp.overflow, TextOverflow.ellipsis); + final nameRichText = tester.widget( + find.descendant( + of: find.byKey(displayNameKey), + matching: find.byType(RichText), + ), + ); + final timestampRichText = tester.widget( + find.descendant( + of: find.byKey(timestampKey), + matching: find.byType(RichText), + ), + ); + expect(nameRichText.textScaler.scale(15), 30); + expect(timestampRichText.textScaler.scale(13.1), 26.2); expect(tester.takeException(), isNull); }); } From 78cbffeb64c01220e705adf0aa9690fdbd0d7a37 Mon Sep 17 00:00:00 2001 From: thomaspblock Date: Sat, 15 Aug 2026 10:49:41 -0400 Subject: [PATCH 7/7] fix(desktop): hide the offcanvas-collapsed sidebar so it stops painting over the community rail (#5947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Collapsing the sidebar left a phantom copy of it painted over the community/relay rail — opaquely on flat themes (vesper et al., which made the rail look *removed*), and as ghost fragments (muted search-box fill, truncated channel-name tails) on the Buzz themes whose chrome is intentionally transparent for the gradient. **Cause:** #4281 made the app-sidebar layer `overflow-visible` (the huddle drawer needs to escape it). That removed the ancestor clipping the offcanvas collapse relied on: the sidebar slides to `left: -sidebar-width` but kept painting, exactly over the `z-0` rail (`z-10` sidebar layer). **Fix:** the offcanvas-collapsed sidebar container is now `invisible` + `pointer-events-none`, with `visibility` added to the transition list so the 200 ms slide-out still animates and the flip happens only at the transition's end. Theme-independent; no per-theme CSS touched; the huddle drawer's `overflow-visible` is preserved. ## Before / after Left 420px of the app with the sidebar collapsed. Before = unpatched `origin/main` @ 69107dc3b; after = this branch. Same seeded state, same build pipeline (`build:e2e` between checkouts). | theme | before (ghost sidebar over the rail) | after (rail clean: A / B / + visible) | |---|---|---| | vesper | ![before-vesper](https://raw.githubusercontent.com/block/buzz/3f98c576e062e51d976940725d84b4e0be7fd53c/pr-5947--before-vesper.png) | ![after-vesper](https://raw.githubusercontent.com/block/buzz/3f98c576e062e51d976940725d84b4e0be7fd53c/pr-5947--after-vesper.png) | | buzz | ![before-buzz](https://raw.githubusercontent.com/block/buzz/3f98c576e062e51d976940725d84b4e0be7fd53c/pr-5947--before-buzz.png) | ![after-buzz](https://raw.githubusercontent.com/block/buzz/3f98c576e062e51d976940725d84b4e0be7fd53c/pr-5947--after-buzz.png) | | buzz-dark | ![before-buzz-dark](https://raw.githubusercontent.com/block/buzz/3f98c576e062e51d976940725d84b4e0be7fd53c/pr-5947--before-buzz-dark.png) | ![after-buzz-dark](https://raw.githubusercontent.com/block/buzz/3f98c576e062e51d976940725d84b4e0be7fd53c/pr-5947--after-buzz-dark.png) | Before shots: ghost `⌘K` search chip + blue active-item pill painted over the rail column; on vesper the opaque panel hides the rail buttons entirely. After: the rail's community buttons (A, B) and `+` are visible and clickable in all three themes. Reported by Thomas P in #buzz-bugs: buzz://message?channel=e62570dd-33ad-42c5-b92b-75f2689f9694&id=9ea401ca1d009f555ca4324e136f8d8d8156db2f8afa3ff89fd038d2c16260f7 cc @klopez4212 — this touches the layout your #4281/#5478 work shaped; please confirm it doesn't defeat the huddle drawer or glass intentions. The change deliberately hides only the *offcanvas-collapsed* container, nothing in the expanded path. ## Test plan - [x] New Playwright regression spec `sidebar-offcanvas-rail.spec.ts` (buzz / buzz-dark / vesper): collapsed sidebar must be `visibility: hidden` + `pointer-events: none`, community rail stays visible and interactive. **Fails on unpatched build** (verified), passes with the fix. - [x] Full desktop unit suite: 4,954 pass / 0 fail - [x] `pnpm typecheck`, `pnpm check` (biome + file-size ratchet + px-text + pubkey-truncation) green - [x] Before/after screenshots above captured via the e2e harness on both builds Signed-off-by: Thomas Petersen Co-authored-by: Wintermute <165f0c871dd2586bb18b6aa109eeaf57bb2132ff4d27b10120f4368a0f627022@buzz.block.builderlab.xyz> --- desktop/playwright.config.ts | 1 + desktop/src/shared/ui/sidebar.tsx | 6 +- .../tests/e2e/sidebar-offcanvas-rail.spec.ts | 77 +++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 131657cbb..7d06c4da9 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/sidebar-offcanvas-rail.spec.ts", "**/search-scope-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", diff --git a/desktop/src/shared/ui/sidebar.tsx b/desktop/src/shared/ui/sidebar.tsx index 10745e696..41db47b15 100644 --- a/desktop/src/shared/ui/sidebar.tsx +++ b/desktop/src/shared/ui/sidebar.tsx @@ -351,7 +351,7 @@ const Sidebar = React.forwardRef< data-variant={variant} data-side={side} > - {/* This is what handles the sidebar gap on desktop */} + {/* Sidebar gap on desktop; the offcanvas sibling below also goes invisible, else it paints over the community rail past the overflow-visible shell. */}