Improve message input UX with adaptive attachment buttons#429
Improve message input UX with adaptive attachment buttons#429torlando-tech merged 7 commits intomainfrom
Conversation
Greptile SummaryThis PR implements a Signal-style attachment panel that appears when users tap the "+" button, replacing the previous separate image and file attachment buttons. The panel displays a grid of recent photos from the device and provides quick access to gallery and file picker actions. Key changes:
Note: The PR description mentions "animated visibility transitions for attachment buttons based on typing" but the actual implementation is an attachment panel feature (not conditional button visibility based on text input). Confidence Score: 4/5
Important Files Changed
Flowchartflowchart TD
Start[User taps + button] --> CheckMode{Input Panel Mode?}
CheckMode -->|NONE or KEYBOARD| ShowPanel[Set mode to PANEL]
CheckMode -->|PANEL| HidePanel[Set mode to NONE]
ShowPanel --> HideKeyboard[Hide keyboard]
HideKeyboard --> CheckPerm{Has media permission?}
CheckPerm -->|Yes| LoadPhotos[Load recent photos via ViewModel]
CheckPerm -->|No| ShowPermPrompt[Show permission prompt in panel]
LoadPhotos --> DisplayGrid[Display photo grid with 3 columns]
ShowPermPrompt --> UserTapsAllow{User taps Allow?}
UserTapsAllow -->|Yes| RequestPerm[Launch permission request]
RequestPerm --> PermGranted{Permission granted?}
PermGranted -->|Yes| LoadPhotos
PermGranted -->|No| ShowPermPrompt
DisplayGrid --> UserAction{User action}
UserAction -->|Taps photo| ProcessImage[Process image with compression]
UserAction -->|Taps Gallery| LaunchGallery[Launch image picker]
UserAction -->|Taps File| LaunchFilePicker[Launch file picker]
UserAction -->|Back button| DismissPanel[Dismiss panel]
ProcessImage --> ClosePanel[Set mode to NONE]
LaunchGallery --> ClosePanel
LaunchFilePicker --> ClosePanel
DismissPanel --> ClosePanel
ClosePanel --> End[Return to normal input]
Last reviewed commit: 3c596d7 |
Additional Comments (1)
Prompt To Fix With AIThis is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt
Line: 1:3
Comment:
**Dropdown state can desync**
`showAttachmentMenu` looks like it’s only toggled by tapping the “+” and by menu item clicks, but the UI that hosts the `DropdownMenu` is conditionally shown via `AnimatedVisibility` based on `messageText.isBlank()`. If the user opens the menu and then the input transitions (e.g., starts typing/clears text or visibility swaps due to recomposition), the menu’s anchor can disappear while `showAttachmentMenu` remains `true`, leaving the state “stuck” and the menu potentially not dismissible/never re-opening correctly until some other path resets it. Consider forcing `showAttachmentMenu = false` whenever the attachment UI state changes (e.g., in a `LaunchedEffect(messageText.isBlank())` / when `AnimatedVisibility` switches) and ensure `onDismissRequest` always resets it.
How can I resolve this? If you propose a fix, please make it concise. |
app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt
Outdated
Show resolved
Hide resolved
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
The image and file attachment buttons now animate out of the way when the user starts typing, giving the text field significantly more horizontal space. A compact "+" button appears in their place, opening a dropdown menu to access image and file attachment options. https://claude.ai/code/session_01NUUapUmKEtnTNPPT49xo5K
- Use a single `hasText` (isNotEmpty) boolean for both AnimatedVisibility blocks, eliminating the gap where whitespace-only input hid all buttons - Add LaunchedEffect(hasText) to dismiss the dropdown menu when the text state transitions, preventing the menu from getting stuck open https://claude.ai/code/session_01NUUapUmKEtnTNPPT49xo5K
The dropdown menu that appeared when tapping "+" stole focus, dismissed the keyboard, and provided a cramped UX. Replace it with a full-width attachment panel that swaps with the keyboard at the same height, showing a grid of recent photos and Gallery/File action buttons. - Add READ_MEDIA_IMAGES / READ_EXTERNAL_STORAGE permissions - Add MediaPermissionManager and MediaStoreUtils utilities - Add AttachmentPanel composable with photo grid and action buttons - Add InputPanelMode state machine (NONE/KEYBOARD/PANEL) - Replace .imePadding() with manual spacer for panel/keyboard control - Simplify MessageInputBar to always show single "+" toggle button - Remove separate image/file buttons and DropdownMenu Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- MediaPermissionManagerTest: permission check logic (4 tests, MockK) - MediaStoreUtilsTest: MediaStore query with limit/URIs (5 tests, Robolectric) - AttachmentPanelTest: permission prompt, empty state, action buttons, photo grid states (8 tests, Robolectric Compose) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
c24106e to
5a25e75
Compare
Permissions can be revoked mid-query on Android; return empty list instead of crashing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@greptileai review |
| fun loadRecentPhotos(context: Context) { | ||
| viewModelScope.launch(Dispatchers.IO) { | ||
| val photos = | ||
| com.lxmf.messenger.util.MediaStoreUtils | ||
| .getRecentPhotos(context) | ||
| _recentPhotos.value = photos | ||
| } | ||
| } |
There was a problem hiding this comment.
Activity context captured in ViewModel coroutine
loadRecentPhotos accepts a Context and uses it inside a viewModelScope.launch coroutine. Since LocalContext.current in the composable returns the Activity context and viewModelScope can outlive the Activity during configuration changes, this could temporarily hold a reference to a destroyed Activity. The MediaStoreUtils.getRecentPhotos call completes quickly so the practical risk is low, but using context.applicationContext at the call site would be safer.
| fun loadRecentPhotos(context: Context) { | |
| viewModelScope.launch(Dispatchers.IO) { | |
| val photos = | |
| com.lxmf.messenger.util.MediaStoreUtils | |
| .getRecentPhotos(context) | |
| _recentPhotos.value = photos | |
| } | |
| } | |
| fun loadRecentPhotos(context: Context) { | |
| viewModelScope.launch(Dispatchers.IO) { | |
| val photos = | |
| com.lxmf.messenger.util.MediaStoreUtils | |
| .getRecentPhotos(context.applicationContext) | |
| _recentPhotos.value = photos | |
| } | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/viewmodel/MessagingViewModel.kt
Line: 214:221
Comment:
**Activity context captured in ViewModel coroutine**
`loadRecentPhotos` accepts a `Context` and uses it inside a `viewModelScope.launch` coroutine. Since `LocalContext.current` in the composable returns the Activity context and `viewModelScope` can outlive the Activity during configuration changes, this could temporarily hold a reference to a destroyed Activity. The `MediaStoreUtils.getRecentPhotos` call completes quickly so the practical risk is low, but using `context.applicationContext` at the call site would be safer.
```suggestion
fun loadRecentPhotos(context: Context) {
viewModelScope.launch(Dispatchers.IO) {
val photos =
com.lxmf.messenger.util.MediaStoreUtils
.getRecentPhotos(context.applicationContext)
_recentPhotos.value = photos
}
}
```
How can I resolve this? If you propose a fix, please make it concise.Add LaunchedEffect keyed on messageText.isBlank() to reset inputPanelMode to NONE when the blank/non-blank state flips. This prevents the attachment panel from staying visible if a message is sent (clearing text) or if text changes externally while the panel is open, addressing the state desync flagged by Greptile. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@greptileai review |
| // Remember keyboard height whenever it's visible | ||
| if (imeIsVisible && imeBottomInset > lastKnownKeyboardHeightPx) { | ||
| lastKnownKeyboardHeightPx = imeBottomInset | ||
| } |
There was a problem hiding this comment.
State write during composition
Writing to lastKnownKeyboardHeightPx (a mutableStateOf variable) directly during composition is a Compose side-effect anti-pattern. Each write triggers a recomposition. The guard condition (imeBottomInset > lastKnownKeyboardHeightPx) prevents an infinite loop, but it still causes one unnecessary extra recomposition every time the keyboard appears or resizes. Wrap this in a SideEffect or LaunchedEffect(imeBottomInset) to make the intent explicit and avoid the extra recomposition:
| // Remember keyboard height whenever it's visible | |
| if (imeIsVisible && imeBottomInset > lastKnownKeyboardHeightPx) { | |
| lastKnownKeyboardHeightPx = imeBottomInset | |
| } | |
| // Remember keyboard height whenever it's visible | |
| LaunchedEffect(imeBottomInset) { | |
| if (imeIsVisible && imeBottomInset > lastKnownKeyboardHeightPx) { | |
| lastKnownKeyboardHeightPx = imeBottomInset | |
| } | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt
Line: 602:605
Comment:
**State write during composition**
Writing to `lastKnownKeyboardHeightPx` (a `mutableStateOf` variable) directly during composition is a [Compose side-effect anti-pattern](https://developer.android.com/develop/ui/compose/side-effects#state-effect-use-cases). Each write triggers a recomposition. The guard condition (`imeBottomInset > lastKnownKeyboardHeightPx`) prevents an infinite loop, but it still causes one unnecessary extra recomposition every time the keyboard appears or resizes. Wrap this in a `SideEffect` or `LaunchedEffect(imeBottomInset)` to make the intent explicit and avoid the extra recomposition:
```suggestion
// Remember keyboard height whenever it's visible
LaunchedEffect(imeBottomInset) {
if (imeIsVisible && imeBottomInset > lastKnownKeyboardHeightPx) {
lastKnownKeyboardHeightPx = imeBottomInset
}
}
```
How can I resolve this? If you propose a fix, please make it concise.
app/src/main/java/com/lxmf/messenger/viewmodel/MessagingViewModel.kt
Outdated
Show resolved
Hide resolved
|
@greptileai review |
- Wrap keyboard height state write in LaunchedEffect to avoid composition side-effect - Only dismiss attachment panel on blank transition (send/clear), not when typing starts - Cancel previous loadRecentPhotos job before launching new one - Use applicationContext to avoid Activity leak in ViewModel coroutine Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Refactored the message input bar to provide a better user experience by adapting the attachment button layout based on whether the user is typing. When the input field is empty, both image and file attachment buttons are displayed prominently. When typing, these buttons collapse into a single compact "+" button that opens a dropdown menu.
Key Changes
AnimatedVisibilitywith fade and expand/shrink animationsAnimatedVisibility,expandHorizontally,fadeIn,fadeOut,shrinkHorizontally)Icons.Default.Addimport for the compact menu buttonshowAttachmentMenustate variable to manage dropdown visibilityImplementation Details
AnimatedVisibilitycomposables that trigger based onmessageTextstatefadeIn/fadeOutcombined withexpandHorizontally/shrinkHorizontallyfor smooth transitionshttps://claude.ai/code/session_01NUUapUmKEtnTNPPT49xo5K