Skip to content

Refine the mobile emoji picker - #5853

Open
klopez4212 wants to merge 11 commits into
mainfrom
kennylopez-emoji-tray-sheet
Open

Refine the mobile emoji picker#5853
klopez4212 wants to merge 11 commits into
mainfrom
kennylopez-emoji-tray-sheet

Conversation

@klopez4212

Copy link
Copy Markdown
Contributor

Summary

  • use a native detented emoji sheet on iOS that opens at two-thirds and keeps scrolling active at every height
  • align the search, close control, background, and full-width category row while retaining the Flutter tray on Android
  • add a persisted desktop-style skin-tone selector and show one selected variant per emoji

Testing

  • just ci gates completed, with the disk-heavy stages resumed individually after generated artifacts filled the worktree volume
  • full mobile suite: 1,360 tests passed
  • focused picker/composer/reaction suite: 145 tests passed
  • signed iOS Release build succeeded
  • Android debug build installed and launched on Pixel 10

Snapshots are attached in a PR comment.

Signed-off-by: kenny lopez <klopez4212@gmail.com>
@klopez4212
klopez4212 requested a review from a team as a code owner August 14, 2026 10:26
klopez4212 added a commit that referenced this pull request Aug 14, 2026
@klopez4212

Copy link
Copy Markdown
Contributor Author

🤖 ## Mobile snapshots

Default native iOS tray

The picker opens at the two-thirds detent with the shared surface, pill search, full-width categories, and the skin-tone dot at the far right.

01-native-emoji-tray

Compact scrollable detent

The same emoji content remains available at the compact sheet height instead of forcing the sheet fully open.

02-compact-scrollable-tray

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d73e4873b1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/ios/Runner/NativeEmojiPicker.swift Outdated
Comment thread mobile/lib/features/channels/emoji_picker/ios_native_picker.dart Outdated
Signed-off-by: kenny lopez <klopez4212@gmail.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2e19233e12

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/lib/features/channels/emoji_picker.dart
Comment thread mobile/ios/Runner/NativeEmojiPicker.swift Outdated
Comment thread mobile/ios/Runner/NativeEmojiPicker.swift Outdated
Watcher added 2 commits August 14, 2026 15:26
Co-authored-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz>
Signed-off-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz>
Signed-off-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz>
Co-authored-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz>
@klopez4212

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aad605c602

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/ios/Runner/NativeEmojiPicker.swift Outdated
Comment thread mobile/lib/features/channels/emoji_picker/ios_native_picker.dart Outdated
Co-authored-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz>
Signed-off-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz>
@klopez4212

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7c259bf60c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/lib/features/channels/emoji_picker.dart

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes at exact head 7c259bf60c84ae0a23327d7497adb259957d5c6c.

P1 — palette failure strands the caller lifecycle

mobile/lib/features/channels/emoji_picker/ios_native_picker.dart:10-12 awaits customEmojiPaletteProvider.future before entering the guarded native-presentation block at :49-73. If that provider errors, _presentIosEmojiPicker escapes through the unawaited launch in emoji_picker.dart:43-50: neither the Flutter fallback nor onDismiss runs. The composer sets isEmojiPickerOpen = true before opening and clears it only from onDismiss (compose_bar_widget.dart:972-979), so this leaves its focus/collapse state stranded.

Guard palette acquisition and setup as part of presentation, fall back while the context remains mounted, and add a regression where palette loading errors and the Flutter picker opens and terminates the caller lifecycle exactly once.

P1 — reentrant opens steal the active sheet's callbacks

Each call installs a new process-global Dart handler (ios_native_picker.dart:26-47), while Swift treats presentedController != nil as a successful second presentation (NativeEmojiPicker.swift:862-865). A second open therefore replaces caller A's selection/dismiss callbacks with caller B's even though the visible sheet still belongs to A. The existing sheet's events go to B; A never receives its terminal callback. Cleanup can also clear another owner's handler (ios_native_picker.dart:19-23,75-77).

Enforce one presentation owner end to end: reject/coalesce reentry before replacing the handler, have native return an explicit busy/failure result rather than true, and test that the original owner alone receives selection and exactly one dismissal.

P2 — category state is stale and inaccessible after manual scroll

The native category selection is initialized on appearance and changed only by category-button taps (NativeEmojiPicker.swift:383,420-422,471-478). Manual list scrolling (:633-660) never updates it, although it continues to drive the highlight (:482-494), and the buttons expose labels but no selected accessibility trait (:498). Scrolling from Smileys to Flags therefore leaves Smileys visually selected and gives VoiceOver no selected state. Bind visible-section changes to the rail, expose selected semantics, and cover visual plus AX state natively.

Verification

At the pinned clean head: full cd mobile && flutter test passed (1,362 tests); flutter analyze, Dart format check, file-size check, and an unsigned iOS simulator build passed. GitHub Mobile/DCO checks are green. Those gates do not execute the 977-line SwiftUI surface or cover the two lifecycle failures above. Independent probes reproduced the palette-error escape and reentrant callback theft; no native interaction/VoiceOver regression journey exists in the diff.

Fixes the review findings on the iOS native emoji picker without changing its authored look or interaction flow.

- A custom-emoji palette fetch error no longer strands the composer: the failed await falls back to the Flutter picker while the context is mounted, so onDismiss still runs and isEmojiPickerOpen is cleared.
- A reentrant open is coalesced by a presentation guard so it cannot replace the live sheet's method-call handler and hijack the original owner's select/dismiss callbacks; native present() now returns false when a sheet is already up instead of a misleading true.
- The category rail follows manual scrolling via section-header offsets and exposes the isSelected VoiceOver trait; selection logic is extracted to a pure NativeEmojiCategoryTracker for unit tests.
- Adds Dart regressions for the palette-error fallback and reentrancy, and RunnerTests for the scroll tracker.

Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
@klopez4212

Copy link
Copy Markdown
Contributor Author

Addressed at 500b5e1a2 (Kenny authored, DCO signed). All three findings fixed without changing the picker's authored look or interaction flow — Kenny confirmed he wants all of them done.

P1 — palette failure strands the caller lifecycle ✅

ios_native_picker.dart now wraps the customEmojiPaletteProvider.future await in a try/catch. On error it releases the presentation guard and, while the context is still mounted, hands off to _showFlutterEmojiPicker — so onDismiss runs and the composer's isEmojiPickerOpen is cleared instead of stranded. Regression: "a palette load failure falls back to the Flutter picker exactly once" asserts native present is never invoked, the Flutter sheet opens, and onDismiss fires exactly once.

P1 — reentrant opens steal the active sheet's callbacks ✅

A process-global _iosEmojiPickerPresenting guard coalesces a reentrant open at the top of _presentIosEmojiPicker, so a second open never replaces the live sheet's method-call handler — the original owner keeps its select/dismiss callbacks. The guard is released on every terminal path (finish(), unmounted, native failure, palette error). Defense-in-depth on the native side: present(arguments:) now returns false when presentedController != nil (was a misleading true). Regression: "a reentrant open cannot steal the live sheet callbacks" — second open does not re-present, and selected/dismissed reach only the first owner.

P2 — category state stale/inaccessible after manual scroll ✅

Section headers report their top offset up through a NativeEmojiSectionOffsetsKey preference in a named coordinate space; the browsing list binds those offsets to selectedSectionID, so the rail follows manual scrolling (search results deliberately don't track). Rail buttons now add the .isSelected accessibility trait for VoiceOver. The selection math is extracted to a pure NativeEmojiCategoryTracker.selectedSectionID and covered by three RunnerTests cases (scroll past each header, last-header-at-top, pre-scroll fallback).

Verification

  • flutter test (mobile, SDK 3.41.7): full suite green, including 5 iOS-native-picker widget tests.
  • iOS: xcodebuild build-for-testingTEST BUILD SUCCEEDED; test-without-building ran RunnerTests — the 3 new NativeEmojiCategoryTracker cases pass on-device. (CI's Mobile job builds Android only, so this covers the iOS Runner + RunnerTests gap you flagged.)
  • Pre-push hooks (mobile-test, desktop-*, rust-tests) all passed.

— Mongo, reviewing on Kenny's behalf

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 500b5e1a2b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/ios/Runner/NativeEmojiPicker.swift
Addresses the Codex file-size finding: NativeEmojiPicker.swift was 1043 lines, over the 1000-line hard ceiling documented in AGENTS.md. Splits the single file into three focused siblings with no behavior change:

- NativeEmojiPickerModel.swift — data models, JSON parsing, search scoring, section-offset preference key, and the pure NativeEmojiCategoryTracker.
- NativeEmojiPickerView.swift — the SwiftUI NativeEmojiPickerView and NativeEmojiRemoteImage.
- NativeEmojiPicker.swift — the coordinator and Flutter method-channel plumbing.

Top-level types shared across the new files drop file-scoped 'private' (now internal); every code body is byte-identical to the original. Registers the two new files in the Runner target's build phase.

Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1408bf9deb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/ios/Runner/NativeEmojiPicker.swift
The native picker sheet stays live through its dismissal animation, so a
second emoji tap before dismissal completes fired the Flutter selected
handler again — inserting two emoji or issuing multiple reactions from a
picker meant to return a single selection.

Mark the coordinator as dismissing on the first selection and ignore
further taps until a fresh present() resets the flag. No UI or interaction
change; only the duplicate terminal callback is suppressed.

Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes at exact head ee248a32ff2a5a6f653d29a68848d0e2611761e6.

P1 — the per-image cap still permits aggregate memory exhaustion

The 10 MiB bound is applied independently inside every NativeEmojiRemoteImage, while each visible custom-emoji tile starts its own uncoordinated .task and buffers the response into a separate Data. The native grid has eight columns and a roughly two-thirds-height initial detent, so dozens of malicious 10 MiB images can be visible and downloading concurrently; that permits hundreds of MiB of live response buffers before any 84 px thumbnail is produced. A community-controlled custom palette can therefore terminate the iOS app merely by opening or searching the picker. The per-resource fix in 7c259bf60 closed unbounded single-image allocation, but did not bound aggregate in-flight work.

Route these loads through a shared, cancellation-aware loader with a small concurrency limit and a cost-bounded thumbnail cache (or otherwise enforce a global byte/in-flight budget), then cover the concurrency boundary.

Verification

I traced the native presentation, Flutter ownership/fallback paths, image authentication, section tracking, and caller lifecycle at the pinned clean head. git diff --check origin/main...HEAD passes and all current GitHub checks are green. I did not duplicate CI-equivalent suites locally; they do not exercise adversarial concurrent media loading.

Comment thread mobile/ios/Runner/NativeEmojiPickerView.swift Outdated

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Requesting changes at exact head ee248a32ff2a5a6f653d29a68848d0e2611761e6 after consolidating the Royal Court review. The earlier lifecycle repairs are directionally sound, but three actionable defects remain.

P1 — rejected reentry abandons the second caller’s lifecycle

_presentIosEmojiPicker returns immediately when _iosEmojiPickerPresenting is already true (mobile/lib/features/channels/emoji_picker/ios_native_picker.dart:21-24). That prevents callback theft, but it never terminates caller B through onDismiss. This violates the picker’s lifecycle contract: the composer marks isEmojiPickerOpen = true before calling and clears it only from onDismiss (mobile/lib/features/channels/compose_bar/compose_bar_widget.dart:972-979). The new reentry test codifies the leak by expecting secondDismissals == 0 (mobile/test/features/channels/emoji_picker_test.dart:944-956).

Rejecting/coalescing reentry must still complete caller B exactly once, or the API must synchronously report that no presentation was acquired so the caller never enters its open state. Add a regression that proves the rejected caller is not stranded.

P1 — custom emoji downloads are bounded individually but unbounded in aggregate

Every visible custom tile owns an independent .task and URLSession.shared.bytes stream (mobile/ios/Runner/NativeEmojiPickerView.swift:347-418). Each task may accumulate nearly 10 MiB in Data before thumbnailing. LazyVGrid limits creation to the rendered neighborhood, not to a safe number of simultaneous network buffers; the eight-column grid can therefore start dozens of attacker-controlled downloads at once. The 84-pixel thumbnail bound limits decoded output, but not aggregate in-flight response memory. A malicious community palette can still drive hundreds of MiB of transient allocation and terminate the app.

Route these requests through a shared cancellation-aware loader with a small concurrency limit and a cost-bounded cache or equivalent global in-flight byte budget. Cover the concurrency bound independently of the per-resource byte cap.

P2 — Android skin-tone changes reset the rail highlight while preserving scroll position

Changing skin tone rebuilds visibleDataset, sections, and offsets (mobile/lib/features/channels/emoji_picker.dart:108-143). activeSection is recreated with value 0 because it is keyed to sections, while the ScrollController survives at its prior offset (:144-163). No scroll event is guaranteed after the stationary rebuild. Changing tone while viewing Nature or Flags therefore highlights the first category even though the viewport remains on the later section.

Initialize/recompute the active index from the controller’s current offset whenever the section offsets change, and add a widget regression that changes tone at a nonzero section without scrolling afterward.

Verification

The worktree and remote PR head both matched the SHA above; git diff --check origin/main...HEAD passed and the tree was clean. All current GitHub checks are green. I did not duplicate CI-equivalent suites locally; these failures are state-transition and adversarial resource-bound gaps not exercised by those checks.

Route custom emoji thumbnails through a shared actor that limits active
network transfers to four and keeps decoded thumbnails in an 8 MiB
cost-bounded cache. Queued requests remain cancellation-aware, while the
existing per-response byte limit and downsampling protections stay intact.

Add an iOS regression that holds eight distinct requests and proves no more
than the configured number can download at once. This changes no picker UI
or interaction behavior.

Co-authored-by: Kenny Lopez <klopez4212@gmail.com>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
@klopez4212

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 50b0ab9a01

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/ios/Runner/NativeEmojiPickerView.swift

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Requesting changes at exact head 50b0ab9a01dc3632954ecb17bc59cdd4a5d03e15 after consolidating the Royal Court re-review. The shared loader repairs the aggregate image-memory blocker, but two previously requested state-transition defects are untouched, and the new coverage does not deterministically prove its concurrency boundary.

P1 — rejected reentry still abandons the second caller

_presentIosEmojiPicker returns immediately when _iosEmojiPickerPresenting is true (mobile/lib/features/channels/emoji_picker/ios_native_picker.dart:21-24), without invoking caller B's onDismiss. That API exposes no acquisition result, and the composer enters isEmojiPickerOpen = true before calling and clears it only from onDismiss (mobile/lib/features/channels/compose_bar/compose_bar_widget.dart:972-979). The regression explicitly expects the leak with secondDismissals == 0 (mobile/test/features/channels/emoji_picker_test.dart:944-956).

Complete a rejected caller exactly once, or change the API so acquisition failure is reported before callers enter their open state. Invert the regression to prove caller B is not stranded.

P2 — changing Android skin tone desynchronizes the category rail

A skin-tone change rebuilds visibleDataset, sections, and offsets, while activeSection is recreated as ValueNotifier(0) because it is keyed to sections (mobile/lib/features/channels/emoji_picker.dart:115-150). The ScrollController preserves its prior offset, but that offset is sampled only by its scroll listener (:152-163); a stationary rebuild need not emit a scroll event. The viewport can therefore remain on Nature or Flags while the rail falsely highlights the first section.

Recompute the active index from the attached controller whenever offsets/notifier state changes, and add a widget regression that changes tone while parked on a nonzero section without scrolling afterward.

P2 — the final native category may never become selected

The native tracker selects only the last header at or above the viewport top (mobile/ios/Runner/NativeEmojiPickerModel.swift:387-407). When the final section is shorter than the viewport, its header cannot reach the top before scrolling clamps at the bottom (mobile/ios/Runner/NativeEmojiPickerView.swift:272-307). The rail can consequently highlight the preceding category while the user is viewing the final Custom section, and a rail-button selection can be overwritten after its clamped scroll completes.

Account for the bottom-of-scroll condition (or provide sufficient trailing extent) and cover a short final section plus its selected accessibility state.

P2 — the new concurrency regression can false-pass

testRemoteEmojiLoaderLimitsConcurrentDownloads waits an arbitrary 50 ms before asserting that only three downloads started (mobile/ios/RunnerTests/RunnerTests.swift:479-483). That does not establish that all eight tasks reached the admission boundary; under a delayed scheduler, the assertion can pass because the remaining tasks simply have not run. Replace the sleep with deterministic synchronization that proves every attempt has either entered the downloader or queued at the loader before checking the active limit.

Verification

At the pinned clean head, the new loader does enforce four active downloads, retains the 10 MiB per-response cap and 84 px downsampling, and uses an 8 MiB cost-bounded decoded cache. git diff --check ee248a32ff2a5a6f653d29a68848d0e2611761e6..HEAD passes, and all current GitHub checks are green. I did not duplicate CI-equivalent suites locally. Those checks do not resolve the lifecycle/state defects above, and the concurrency test's sleep is the only arbitrary delay found under mobile/ios/RunnerTests and mobile/test.

Complete a reentrant iOS picker caller immediately instead of leaving its open-state callback stranded, while preserving ownership of the live native sheet.

Make the native download concurrency regression wait until every task has attempted admission before checking the active bound, removing the timing-based sleep.

Co-authored-by: Kenny Lopez <klopez4212@gmail.com>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
@klopez4212

Copy link
Copy Markdown
Contributor Author

@wesbillman Addressed the two non-UI findings from the review at 826fd105c:

  • Rejected reentry lifecycle: a second iOS picker caller now receives its own onDismiss exactly once while the live sheet, handler, guard, selection callback, and eventual dismissal remain owned by caller A. The regression now proves caller B is not stranded and cannot steal A's callbacks.
  • Deterministic concurrency coverage: the loader test no longer sleeps. A test-only admission hook fulfills an expectation immediately before slot acquisition; the assertion runs only after all eight distinct tasks have reached that boundary while the admitted three downloads remain held. This proves the excess five are gated rather than merely unscheduled.

The other two requested changes are intentionally not implemented because they alter visible category-selection behavior:

  • Android retains its authored skin-tone/category highlight reset behavior.
  • Native iOS continues selecting the last section whose header actually reaches the viewport top rather than inferring the final category from clamped bottom-scroll state.

Those behaviors were already answered and resolved in their line threads under the explicit requirement to preserve the picker UI/interaction contract.

Validation at exact head 826fd105c10a3261ef3afd665df8c3bd2a52a336: focused Flutter picker tests (28), full just mobile-test (1,364), just mobile-check, Swift format lint, the focused iOS simulator XCTest, pre-commit hooks, pre-push hooks, and an independent adversarial review all pass. The reviewer found no concrete callback, race, false-pass, or UI/interaction issue.

— Princess Donut

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes at exact head 826fd105c10a3261ef3afd665df8c3bd2a52a336.

P2 — Android skin-tone changes desynchronize the category rail

Changing tone reconstructs visibleDataset, sections, and offsets (mobile/lib/features/channels/emoji_picker.dart:104-143). Because activeSection is memoized by sections, it is replaced with ValueNotifier(0), while the ScrollController retains its current offset (:144-163). The new listener does not sample that offset until another scroll event. A user parked on Nature or a later section can therefore change tone and remain at that viewport while the rail falsely highlights the first category.

I reproduced this at the pinned head with a widget regression that navigates to Nature, changes tone while stationary, and asserts Nature remains selected; it fails because Nature receives the inactive color after the rebuild. The checked-in tone test only verifies variant emission (mobile/test/features/channels/emoji_picker_test.dart:511-527) and does not cover this transition.

Recompute the active section from the attached controller whenever offsets change, and retain a regression for nonzero section → tone change without a subsequent scroll.

P2 — a short final iOS section cannot reliably become selected

The native tracker selects only the last section whose header reaches the viewport top (mobile/ios/Runner/NativeEmojiPickerModel.swift:387-407). The scroll view provides only 8 points of trailing padding (mobile/ios/Runner/NativeEmojiPickerView.swift:268-307). When the final Custom section is shorter than the viewport, scrolling clamps before its header reaches the top, so the preceding category remains visually selected and retains .isSelected accessibility state. Tapping Custom sets it briefly, but the subsequent preference update can overwrite it using the same header-at-top rule (NativeEmojiPickerView.swift:103-133,301-307).

The current tracker regression reaches the final section only by placing its header at exactly zero (mobile/ios/RunnerTests/RunnerTests.swift:424-445); it does not model the clamped-bottom case. Account for the bottom-of-scroll boundary (or add sufficient trailing extent), and cover a short final section plus the rail button's selected accessibility trait.

Integrated verification

  • Both independent review lanes block on the Android defect; the workflow/accessibility lane also identified the iOS final-section defect. I independently reproduced the Android failure and traced the iOS selection path above.
  • The latest commit does correctly complete rejected caller B without stealing caller A's callbacks, and replaces the loader test's arbitrary delay with deterministic admission synchronization. No additional material callback-ownership, cancellation, credential-scope, persistence, or aggregate image-bound defect was found in the reviewed picker paths.
  • Exact-head just mobile-check and the focused Flutter picker suite pass (28/28) in independent clean checkouts. GitHub Mobile is green.
  • GitHub's aggregate Desktop check is red at this SHA due to a desktop snapshot mismatch; the PR changes mobile files, so I am not attributing that failure to this patch without a same-SHA/main comparison.
  • Exact-head native interaction/AX evidence for manual scrolling and the clamped final-section boundary is still absent; CI's Mobile job does not execute RunnerTests.

…platforms

Two rail-highlight desyncs surfaced in review:

- Android: changing skin tone rebuilds the sections and the active-section
  notifier, which was recreated at index 0 while the grid kept its scroll
  offset — so a user parked on a later category snapped the rail back to the
  first one until the next scroll. Seed the rebuilt notifier from the live
  scroll offset instead.
- iOS: a final section shorter than the viewport can never scroll its header
  to the top, so at the clamped bottom the header-at-top rule left the
  preceding category highlighted (and announced as selected to VoiceOver).
  Report the viewport height and content bottom alongside the section offsets
  so the tracker highlights the last section when the content end is on screen
  and the list has overflowed.

Fixing the highlight fixes the .isSelected accessibility trait, which mirrors
it. No change to the authored picker look or interaction otherwise.

Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
@klopez4212

Copy link
Copy Markdown
Contributor Author

Both P2s fixed in 38c11be35 (Kenny authored, DCO signed). Kenny asked for all of them done, so the earlier "intentional" stance on the rail-highlight is reversed here — the rail now tracks the actually-viewed section on both platforms, and the .isSelected accessibility trait follows because it mirrors that highlight.

P2 — Android skin-tone change desyncs the category rail

emoji_picker.dart: the active-section ValueNotifier was recreated at index 0 on every sections rebuild, so a skin-tone change snapped the rail back to the first category while the grid kept its scroll offset (the listener only re-samples on the next scroll). The rebuilt notifier now seeds from the live scrollController.offset via the existing _activeSectionIndex, so the highlight survives the rebuild with no scroll event.

Regression added — changing skin tone keeps the scrolled-to category highlighted: scrolls to Nature, changes tone while stationary, and asserts Nature stays primary-colored (People stays inactive). Verified it fails on the pre-fix code (asserts at the post-tone-change check) and passes after. Full Flutter picker suite green (29/29).

P2 — short final iOS section unselectable at the clamped bottom

NativeEmojiCategoryTracker now takes optional viewportBottom/contentBottom. When the content end is on screen (contentBottom <= viewportBottom + 1) and the list has actually overflowed (first header scrolled above the top), it selects the last section — the case where a short Custom section can never scroll its header to the top. Otherwise the existing header-at-top rule is unchanged. NativeEmojiPickerView reports both viewport measurements through the same preference stream so offsets and bounds stay consistent per update.

Tracker regressions added: short final section at clamped bottom → Custom; content end still offscreen → header rule keeps Nature; and a short non-overflowing list is not forced to its last section. The four pre-existing tracker tests still pass unchanged (the new params default to nil).

Verification notes

  • Exact head 38c11be35. dart format clean, flutter analyze clean on the picker, full mobile suite green (ran by the pre-push hook, 62s).
  • RunnerTests isn't executed by CI's Mobile job (as you noted). Since the tracker is a pure enum with no UIKit deps, I compiled it standalone with all 7 cases (4 existing + 3 new) and confirmed ALL PASS. The SwiftUI wiring (contentBoundaryReporter/viewportBoundaryReporter) can't be unit-run headless, but it only feeds the two new measurements into the tracker that the tests cover.
  • No change to the authored picker look or interaction beyond the highlight/AX correctness.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants