Refine the mobile emoji picker - #5853
Conversation
Signed-off-by: kenny lopez <klopez4212@gmail.com>
|
🤖 ## Mobile snapshots Default native iOS trayThe 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. Compact scrollable detentThe same emoji content remains available at the compact sheet height instead of forcing the sheet fully open. |
There was a problem hiding this comment.
💡 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".
Signed-off-by: kenny lopez <klopez4212@gmail.com>
There was a problem hiding this comment.
💡 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".
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>
|
@codex review |
There was a problem hiding this comment.
💡 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".
Co-authored-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz> Signed-off-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz>
|
@codex review |
There was a problem hiding this comment.
💡 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".
jedwards27
left a comment
There was a problem hiding this comment.
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>
|
Addressed at P1 — palette failure strands the caller lifecycle ✅
P1 — reentrant opens steal the active sheet's callbacks ✅A process-global P2 — category state stale/inaccessible after manual scroll ✅Section headers report their top offset up through a Verification
— Mongo, reviewing on Kenny's behalf |
There was a problem hiding this comment.
💡 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".
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>
There was a problem hiding this comment.
💡 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".
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
left a comment
There was a problem hiding this comment.
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.
wesbillman
left a comment
There was a problem hiding this comment.
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>
|
@codex review |
There was a problem hiding this comment.
💡 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".
wesbillman
left a comment
There was a problem hiding this comment.
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>
|
@wesbillman Addressed the two non-UI findings from the review at
The other two requested changes are intentionally not implemented because they alter visible category-selection behavior:
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 — Princess Donut |
jedwards27
left a comment
There was a problem hiding this comment.
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-checkand 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>
|
Both P2s fixed in P2 — Android skin-tone change desyncs the category rail
Regression added — P2 — short final iOS section unselectable at the clamped bottom
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 Verification notes
|


Summary
Testing
just cigates completed, with the disk-heavy stages resumed individually after generated artifacts filled the worktree volumeSnapshots are attached in a PR comment.