Skip to content

Improve message input UX with adaptive attachment buttons#429

Merged
torlando-tech merged 7 commits intomainfrom
claude/expand-chat-input-box-Sjjud
Feb 15, 2026
Merged

Improve message input UX with adaptive attachment buttons#429
torlando-tech merged 7 commits intomainfrom
claude/expand-chat-input-box-Sjjud

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

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

  • Added animated visibility transitions for attachment buttons using AnimatedVisibility with fade and expand/shrink animations
  • Split attachment button layout into two states:
    • Empty state: Shows both image and file attachment buttons side-by-side for quick access
    • Typing state: Collapses to a single "+" button with a dropdown menu containing image and file options
  • Imported necessary animation composables (AnimatedVisibility, expandHorizontally, fadeIn, fadeOut, shrinkHorizontally)
  • Added Icons.Default.Add import for the compact menu button
  • Introduced showAttachmentMenu state variable to manage dropdown visibility

Implementation Details

  • The attachment buttons are wrapped in AnimatedVisibility composables that trigger based on messageText state
  • Animations use fadeIn/fadeOut combined with expandHorizontally/shrinkHorizontally for smooth transitions
  • The dropdown menu maintains the same functionality as the original buttons while saving horizontal space during text input
  • Both image and file attachment buttons remain fully functional with their loading states preserved

https://claude.ai/code/session_01NUUapUmKEtnTNPPT49xo5K

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 9, 2026

Greptile Summary

This 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:

  • Replaced individual image/file buttons with a single "+" toggle button that shows/hides the attachment panel
  • Introduced InputPanelMode state machine (NONE, KEYBOARD, PANEL) to coordinate keyboard and panel visibility
  • Created new AttachmentPanel component with permission handling, photo grid (3 columns), and action buttons
  • Added MediaStoreUtils to query recent photos from MediaStore with proper SecurityException handling
  • Added MediaPermissionManager for API-level-specific media permission checks (Android 13+ vs older)
  • Manual IME spacer replaces .imePadding() to enable smooth transitions between keyboard and panel
  • Panel auto-dismisses when message text blank state changes or back button pressed

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

  • This PR is safe to merge with minor concerns
  • The implementation is well-tested with comprehensive unit tests, includes proper error handling for SecurityException, and follows Android best practices for permissions. Previous review threads identified valid concerns (activity context in ViewModel, state write during composition, concurrent call stacking) that should be addressed but don't block functionality. The state machine logic is sound and the panel dismissal behavior is correct.
  • Pay attention to MessagingScreen.kt:603 (state write during composition) and MessagingViewModel.kt:214-220 (activity context and concurrent calls)

Important Files Changed

Filename Overview
app/src/main/java/com/lxmf/messenger/util/MediaStoreUtils.kt Queries recent photos from MediaStore with proper SecurityException handling
app/src/main/java/com/lxmf/messenger/ui/components/AttachmentPanel.kt New composable showing photo grid with permission prompt and gallery/file action buttons
app/src/main/java/com/lxmf/messenger/viewmodel/MessagingViewModel.kt Added loadRecentPhotos function and recentPhotos state; previous review flagged activity context and concurrent call issues
app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt Implemented attachment panel with state machine (InputPanelMode enum); replaced separate image/file buttons with single toggle button; previous review flagged state write during composition

Flowchart

flowchart 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]
Loading

Last reviewed commit: 3c596d7

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

1 file reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 9, 2026

Additional Comments (1)

app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt
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.

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: 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.

@sentry
Copy link
Copy Markdown
Contributor

sentry bot commented Feb 9, 2026

@torlando-tech torlando-tech added this to the v0.9.0 milestone Feb 9, 2026
claude and others added 4 commits February 14, 2026 20:19
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>
@torlando-tech torlando-tech force-pushed the claude/expand-chat-input-box-Sjjud branch from c24106e to 5a25e75 Compare February 15, 2026 02:32
@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

9 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Permissions can be revoked mid-query on Android; return empty list
instead of crashing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai review

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

9 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +214 to +221
fun loadRecentPhotos(context: Context) {
viewModelScope.launch(Dispatchers.IO) {
val photos =
com.lxmf.messenger.util.MediaStoreUtils
.getRecentPhotos(context)
_recentPhotos.value = photos
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Suggested change
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>
@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai review

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

9 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +602 to +605
// Remember keyboard height whenever it's visible
if (imeIsVisible && imeBottomInset > lastKnownKeyboardHeightPx) {
lastKnownKeyboardHeightPx = imeBottomInset
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

Suggested change
// 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.

@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai review

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

9 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

- 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>
@torlando-tech torlando-tech merged commit 9318c57 into main Feb 15, 2026
10 checks passed
@torlando-tech torlando-tech deleted the claude/expand-chat-input-box-Sjjud branch February 15, 2026 19:51
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.

2 participants