From 5ef8d7a5fe27bbde1b33cbc2dcf2d1e761e47e94 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 13:52:15 -0400 Subject: [PATCH 1/7] feat(chat): restyle the photo viewer chrome and add zoom, reply, save and forward The viewer opens with no chrome; a tap shows a close button and two bottom pills (Zoom/Reply, Save/Forward). Forward replaces the raw-photo share with the message-link share; Save writes to the gallery through MediaStore. Reply and Forward follow the message's capabilities. --- .../app/src/main/res/xml/file_paths.xml | 2 - .../core/src/main/res/values/strings.xml | 6 +- .../app/messenger/ChatPhotoViewerScreen.kt | 6 +- .../internal/ChatPhotoViewerViewModel.kt | 11 ++ .../app/messenger/internal/ChatViewModel.kt | 13 ++ .../internal/screens/MessengerScreen.kt | 21 ++- .../internal/screens/components/ChatTopBar.kt | 20 +-- .../shared/chat/ui/media/ChatMediaViewer.kt | 146 ++++++++++++------ .../shared/chat/ui/media/ChatPhotoOverlay.kt | 13 +- .../shared/chat/ui/media/ChatPhotoSave.kt | 80 ++++++++++ .../shared/chat/ui/media/ChatPhotoShare.kt | 49 ------ .../shared/chat/ui/media/ForwardArrow.kt | 22 +++ .../chat/ui/media/ChatPhotoMediaUiTest.kt | 130 ++++++++++------ 13 files changed, 348 insertions(+), 171 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSave.kt delete mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoShare.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ForwardArrow.kt diff --git a/apps/flipcash/app/src/main/res/xml/file_paths.xml b/apps/flipcash/app/src/main/res/xml/file_paths.xml index 59628fd959..df595ae07d 100644 --- a/apps/flipcash/app/src/main/res/xml/file_paths.xml +++ b/apps/flipcash/app/src/main/res/xml/file_paths.xml @@ -5,6 +5,4 @@ - - diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index dd1c7fdfc4..a01b3f47d7 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1586,8 +1586,10 @@ Price curve for %1$s with %2$s purchased Close - Share - Share photo + Zoom + Save + Saved to Photos + Couldn\'t save the photo Remove photo Remove link preview Loading preview for %1$s diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatPhotoViewerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatPhotoViewerScreen.kt index 2d76f833ad..f5ceefed87 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatPhotoViewerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatPhotoViewerScreen.kt @@ -16,7 +16,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.messenger.internal.ChatPhotoViewerViewModel import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ui.media.ChatMediaViewer -import com.flipcash.shared.chat.ui.media.rememberSharePhoto +import com.flipcash.shared.chat.ui.media.rememberSavePhoto import com.getcode.navigation.core.LocalCodeNavigator /** The full-screen photo, behind `AppRoute.Messaging.PhotoViewer`. Black, dismissed by back or a pull. */ @@ -27,14 +27,14 @@ fun ChatPhotoViewerScreen(chatId: ChatId, messageId: Long) { val photo by viewModel.photo.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current var fullImageLoaded by remember { mutableStateOf(false) } - val share = rememberSharePhoto(photo) + val save = rememberSavePhoto(photo) { saved -> viewModel.onSaveResult(saved) } Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { ChatMediaViewer( model = photo, fullImageLoaded = fullImageLoaded, onFullImageLoaded = { fullImageLoaded = true }, - onShare = share, + onSave = save, onDismiss = { navigator.pop() }, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatPhotoViewerViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatPhotoViewerViewModel.kt index 4837752a0f..b1946e655e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatPhotoViewerViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatPhotoViewerViewModel.kt @@ -1,6 +1,8 @@ package com.flipcash.app.messenger.internal import androidx.lifecycle.ViewModel +import com.flipcash.app.core.toast.SystemToastController +import com.flipcash.core.R import androidx.lifecycle.viewModelScope import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ChatCoordinator @@ -20,11 +22,20 @@ import javax.inject.Inject @HiltViewModel internal class ChatPhotoViewerViewModel @Inject constructor( private val chatCoordinator: ChatCoordinator, + private val toastController: SystemToastController, ) : ViewModel() { private val _photo = MutableStateFlow(null) val photo: StateFlow = _photo + /** Tells the user how a save went, as a system toast, which is what the chat does for a copy. */ + fun onSaveResult(saved: Boolean) { + toastController.showToast( + if (saved) R.string.title_chatPhotoSaved else R.string.error_chatPhotoSaveFailed, + replacePrevious = true, + ) + } + private var loaded: Pair? = null fun load(chatId: ChatId, messageId: Long) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index b0b7c8a48b..5fd6bdd76c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -1041,6 +1041,9 @@ internal class ChatViewModel @AssistedInject constructor( // which has the navigator and has already captured the message. data object ShareMessageRequested : Event + /** A photo the viewer was asked to save has, or has not, reached the gallery. */ + data class PhotoSaveResult(val saved: Boolean) : Event + data object SubmitEdit : Event data object CancelEdit : Event data object EditingEnded : Event @@ -2755,6 +2758,15 @@ internal class ChatViewModel @AssistedInject constructor( } .launchIn(viewModelScope) + eventFlow.filterIsInstance() + .onEach { event -> + toastController.showToast( + if (event.saved) R.string.title_chatPhotoSaved else R.string.error_chatPhotoSaveFailed, + replacePrevious = true, + ) + } + .launchIn(viewModelScope) + eventFlow.filterIsInstance() .onEach { event -> clipboardManager.setText( @@ -4057,6 +4069,7 @@ internal class ChatViewModel @AssistedInject constructor( Event.ShareMessageRequested -> { state -> state.copy(selection = null, confirmingDelete = false) } + is Event.PhotoSaveResult -> { state -> state } is Event.EditMessage -> { state -> state.copy( selection = null, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index c4ce1979ee..73a54ddc51 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -73,7 +73,9 @@ import com.flipcash.app.messenger.internal.screens.components.MessageList import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar import com.getcode.theme.CodeTheme import com.getcode.ui.components.BlurredContent +import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.app.core.LocalUserManager import com.flipcash.app.shareable.LocalShareController import com.flipcash.app.shareable.Shareable @@ -129,7 +131,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { // The photo bubbles on screen, and the photo opened over them. The overlay sits above the whole // screen rather than being a route, so it can grow out of and shrink back into its bubble. val photoSources = remember { ChatPhotoSources() } - var openPhotoId by remember { mutableStateOf(null) } + var openPhoto by remember { mutableStateOf(null) } // A private group the viewer is not in has no transcript to show: no key envelope, so nothing // to decrypt. A share link or deep link only knows the chat id and lands here first, so once the @@ -179,7 +181,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { is ChatAction.OpenPhoto -> { // After the keyboard is down, so the bubble the photo grows from has settled. - keyboard.hideIfVisible { openPhotoId = action.bubble.messageId } + keyboard.hideIfVisible { openPhoto = action.bubble } } is ChatAction.RetryMessage -> { @@ -568,11 +570,20 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } - openPhotoId?.let { id -> + openPhoto?.let { bubble -> ChatPhotoOverlay( - messageId = id, + messageId = bubble.messageId, sources = photoSources, - onClosed = { openPhotoId = null }, + onClosed = { openPhoto = null }, + onSaveResult = { saved -> viewModel.dispatchEvent(ChatViewModel.Event.PhotoSaveResult(saved)) }, + // The same actions the message's context menu offers, gated by the same capabilities. + // Reply sets the strip the way the menu's Reply does; the overlay closes itself. + onReply = if (MessageCapability.Reply in bubble.capabilities) { + { viewModel.dispatchEvent(ChatViewModel.Event.ReplyRequested(bubble)) } + } else null, + onForward = if (MessageCapability.Link in bubble.capabilities) { + { navigator.openMessageShare(bubble.messageId) } + } else null, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index ffe5fdffbc..4ab2e49c1f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -24,8 +24,6 @@ import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.filled.Feedback -import androidx.compose.material.icons.materialIcon -import androidx.compose.material.icons.materialPath import com.flipcash.app.messenger.internal.screens.openMessageShare import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -60,6 +58,7 @@ import com.flipcash.shared.chat.models.ChatAction import com.flipcash.shared.chat.models.ChatActionHandler import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.ui.MutedIndicator +import com.flipcash.shared.chat.ui.media.ForwardArrow import com.flipcash.shared.chat.ui.messageFullTime import com.getcode.navigation.core.CodeNavigator import com.getcode.theme.CodeTheme @@ -504,20 +503,3 @@ private fun OverflowLabel(text: String) { color = CodeTheme.colors.textMain, ) } - -// Material has no forward counterpart to Reply, and its Forward is a straight block arrow. iOS draws -// Forward as Reply's mirror (arrowshape.turn.up.right.fill), so this is Reply's path flipped across x = 12. -private val ForwardArrow: ImageVector by lazy { - materialIcon(name = "Flipcash.ForwardArrow", autoMirror = true) { - materialPath { - moveTo(14.0f, 9.0f) - verticalLineTo(5.0f) - lineToRelative(7.0f, 7.0f) - lineToRelative(-7.0f, 7.0f) - verticalLineToRelative(-4.1f) - curveToRelative(-5.0f, 0.0f, -8.5f, 1.6f, -11.0f, 5.1f) - curveToRelative(1.0f, -5.0f, 4.0f, -10.0f, 11.0f, -11.0f) - close() - } - } -} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt index b7382d7a8b..b140d2e308 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt @@ -1,8 +1,6 @@ package com.flipcash.shared.chat.ui.media import com.getcode.theme.CodeTheme -import android.content.Context -import android.content.Intent import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap @@ -15,15 +13,21 @@ import androidx.compose.foundation.gestures.calculateCentroid import androidx.compose.foundation.gestures.calculatePan import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.outlined.IosShare +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.ZoomIn import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -61,17 +65,18 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.core.content.FileProvider import coil3.compose.AsyncImagePainter import coil3.compose.rememberAsyncImagePainter import com.flipcash.core.R import kotlinx.coroutines.launch -import java.io.File import kotlin.math.abs internal const val VIEWER_IMAGE_TAG = "chat_media_viewer_image" internal const val VIEWER_CLOSE_TAG = "chat_media_viewer_close" -internal const val VIEWER_SHARE_TAG = "chat_media_viewer_share" +internal const val VIEWER_ZOOM_TAG = "chat_media_viewer_zoom" +internal const val VIEWER_REPLY_TAG = "chat_media_viewer_reply" +internal const val VIEWER_SAVE_TAG = "chat_media_viewer_save" +internal const val VIEWER_FORWARD_TAG = "chat_media_viewer_forward" private const val DISMISS_DISTANCE_FRACTION = 0.2f private const val MIN_BACKGROUND_ALPHA = 0.2f @@ -80,8 +85,9 @@ private const val MIN_BACKGROUND_ALPHA = 0.2f * A full-screen photo. * * Pinch zooms from 1× to 4× with the pan held inside the image's edges; a double tap goes to 2.5× - * about the tapped point, or back to fit when already zoomed. A single tap hides or shows the - * close and share buttons. A one-finger drag that starts while + * about the tapped point, or back to fit when already zoomed. The viewer opens with no chrome; a + * single tap shows or hides the close button and the two pills at the bottom (Zoom and Reply on the + * left, Save and Forward on the right). A one-finger drag that starts while * the image is at fit pulls it away; when zoomed the same drag pans instead. * * On its own the pull moves the image down, fading the black behind it, and past a fifth of the @@ -91,12 +97,14 @@ private const val MIN_BACKGROUND_ALPHA = 0.2f * close button's. * * [placeholder] (usually the bubble's BlurHash, or the thumbnail already on screen) shows until - * the full image has loaded, which [onFullImageLoaded] reports. Share stays disabled until - * [fullImageLoaded] is true, since there is nothing worth sharing before then. + * the full image has loaded, which [onFullImageLoaded] reports. Zoom and Save stay disabled until + * [fullImageLoaded] is true, since there is nothing worth zooming or saving before then. * - * Hostable as a Nav3 entry: it takes plain parameters and calls back for close and share. The host - * owns what Share does; [shareImage] is the usual answer. + * Hostable as a Nav3 entry: it takes plain parameters and calls back for close, save, reply and + * forward. The host owns what each does; [rememberSavePhoto] is the usual answer to Save. * + * @param onReply Reply; null hides the button, for a message that cannot be replied to. + * @param onForward Forward; null hides the button, for a message with no link to hand out. * @param model what Coil loads for the full image. * @param frame the photo's rect, corners and the opacities, read at layout and draw time so a * host can animate them without recomposing. Null fills the container. @@ -108,9 +116,11 @@ fun ChatMediaViewer( model: Any?, fullImageLoaded: Boolean, onFullImageLoaded: () -> Unit, - onShare: () -> Unit, + onSave: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, + onReply: (() -> Unit)? = null, + onForward: (() -> Unit)? = null, placeholder: Painter? = null, zoomState: ChatMediaZoomState = remember { ChatMediaZoomState() }, reduceMotion: Boolean = rememberReducedMotion(), @@ -128,7 +138,7 @@ fun ChatMediaViewer( val currentOnPull by rememberUpdatedState(onPull) val currentOnPullEnd by rememberUpdatedState(onPullEnd) val touchSlop = LocalViewConfiguration.current.touchSlop - var chromeVisible by remember { mutableStateOf(true) } + var chromeVisible by remember { mutableStateOf(false) } val chromeShown by animateFloatAsState( targetValue = if (chromeVisible) 1f else 0f, animationSpec = if (reduceMotion) snap() else tween(200), @@ -313,15 +323,52 @@ fun ChatMediaViewer( onClick = onDismiss, modifier = chrome.align(Alignment.TopStart), ) - ViewerButton( - icon = Icons.Outlined.IosShare, - description = stringResource(R.string.description_chatMediaShare), - enabled = fullImageLoaded && interactive, - visible = chromeVisible, - tag = VIEWER_SHARE_TAG, - onClick = onShare, - modifier = chrome.align(Alignment.TopEnd), - ) + val center = { Offset(zoomState.container.width / 2f, zoomState.container.height / 2f) } + Row( + modifier = chrome + .align(Alignment.BottomStart) + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.staticGrid.x4, vertical = CodeTheme.dimens.staticGrid.x3), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + ViewerPill { + ViewerPillButton( + icon = Icons.Outlined.ZoomIn, + description = stringResource(R.string.description_chatMediaZoom), + enabled = fullImageLoaded && interactive && chromeVisible, + tag = VIEWER_ZOOM_TAG, + onClick = { scope.launch { zoomState.toggle(center(), animated = !reduceMotion) } }, + ) + if (onReply != null) { + ViewerPillButton( + icon = Icons.AutoMirrored.Filled.Reply, + description = stringResource(R.string.action_reply), + enabled = interactive && chromeVisible, + tag = VIEWER_REPLY_TAG, + onClick = onReply, + ) + } + } + ViewerPill { + ViewerPillButton( + icon = Icons.Outlined.Download, + description = stringResource(R.string.description_chatMediaSave), + enabled = fullImageLoaded && interactive && chromeVisible, + tag = VIEWER_SAVE_TAG, + onClick = onSave, + ) + if (onForward != null) { + ViewerPillButton( + icon = ForwardArrow, + description = stringResource(R.string.action_forwardMessage), + enabled = interactive && chromeVisible, + tag = VIEWER_FORWARD_TAG, + onClick = onForward, + ) + } + } + } } LaunchedEffect(model) { zoomState.reset() } @@ -362,27 +409,36 @@ private fun ViewerButton( } } -/** - * The directory the share sheet may read chat photos from. `res/xml/file_paths.xml` in the app - * exposes `cacheDir/chat_media/` through the existing `${applicationId}.fileprovider` authority; - * a file anywhere else makes [FileProvider] throw. - */ -fun chatMediaShareDir(context: Context): File = - File(context.cacheDir, "chat_media").apply { if (!exists()) mkdirs() } +/** The capsule behind a group of bottom buttons: dark and translucent, like the close button. */ +@Composable +private fun ViewerPill(content: @Composable () -> Unit) { + Row( + modifier = Modifier + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.4f)), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), + verticalAlignment = Alignment.CenterVertically, + ) { content() } +} -/** - * Opens the system share sheet for [file], a JPEG under [chatMediaShareDir]. Returns false, rather - * than throwing, when the file is outside the provider's paths or nothing can handle the intent. - */ -fun shareImage(context: Context, file: File, mimeType: String = "image/jpeg"): Boolean = runCatching { - val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) - val send = Intent(Intent.ACTION_SEND).apply { - putExtra(Intent.EXTRA_STREAM, uri) - type = mimeType - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) +@Composable +private fun ViewerPillButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + enabled: Boolean, + tag: String, + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + // Hidden buttons fade out at full tint but stop taking taps straight away. + enabled = enabled, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x10).testTag(tag), + ) { + Icon( + imageVector = icon, + contentDescription = description, + tint = Color.White.copy(alpha = if (enabled) 1f else 0.4f), + ) } - context.startActivity( - Intent.createChooser(send, context.getString(R.string.title_chatMediaShare)) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) -}.isSuccess +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt index c1a4fdd108..434e6a1c6a 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt @@ -151,6 +151,9 @@ internal class ChatPhotoPresentation( * drags back into it, and goes when it lands. Compose it above the whole screen with the chat * still live underneath, only while a photo is open. * + * [onReply] and [onForward] are null when the message cannot be replied to or linked. + * [onSaveResult] reports whether a save reached the gallery, for the host to tell the user. + * * The caller hides the keyboard first, so the bubble the photo grows from is where it will rest. */ @Composable @@ -158,7 +161,10 @@ fun ChatPhotoOverlay( messageId: Long, sources: ChatPhotoSources, onClosed: () -> Unit, + onSaveResult: (saved: Boolean) -> Unit, modifier: Modifier = Modifier, + onReply: (() -> Unit)? = null, + onForward: (() -> Unit)? = null, reduceMotion: Boolean = rememberReducedMotion(), ) { val source = remember(messageId) { sources.sourceOf(messageId) } @@ -214,7 +220,7 @@ fun ChatPhotoOverlay( .build() } var fullImageLoaded by remember(messageId) { mutableStateOf(false) } - val share = rememberSharePhoto(source.fullModel) + val save = rememberSavePhoto(source.fullModel, onSaveResult) Box( modifier = modifier @@ -226,7 +232,10 @@ fun ChatPhotoOverlay( model = request, fullImageLoaded = fullImageLoaded, onFullImageLoaded = { fullImageLoaded = true }, - onShare = share, + onSave = save, + // Reply leaves the photo, back into its bubble, as the composer's strip opens. + onReply = onReply?.let { reply -> { presentation.dismiss(); reply() } }, + onForward = onForward, onDismiss = presentation::dismiss, zoomState = zoomState, reduceMotion = reduceMotion, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSave.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSave.kt new file mode 100644 index 0000000000..20ef52f613 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSave.kt @@ -0,0 +1,80 @@ +package com.flipcash.shared.chat.ui.media + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.os.Environment +import android.provider.MediaStore +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import coil3.SingletonImageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.size.Size +import coil3.toBitmap +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val ALBUM = "Flipcash" + +/** + * Returns a function that saves the photo [model] draws to the device's gallery and then reports + * whether it worked through [onResult]. The image is read back through Coil, which has just loaded + * it for the viewer and so answers from its cache. + * + * Needs no permission: the app's minSdk is 29, where MediaStore lets an app add its own pictures. + */ +@Composable +fun rememberSavePhoto(model: Any?, onResult: (Boolean) -> Unit): () -> Unit { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val currentModel by rememberUpdatedState(model) + val currentOnResult by rememberUpdatedState(onResult) + return save@{ + val data = currentModel ?: return@save + scope.launch { + val saved = try { + withContext(Dispatchers.IO) { + val result = SingletonImageLoader.get(context) + .execute(ImageRequest.Builder(context).data(data).size(Size.ORIGINAL).build()) + val bitmap = (result as? SuccessResult)?.image?.toBitmap() ?: return@withContext false + saveToGallery(context, bitmap) + } + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + false + } + currentOnResult(saved) + } + } +} + +/** + * Inserts [bitmap] into the shared pictures collection as a JPEG. `IS_PENDING` keeps the file out of + * other apps' queries until it is fully written; a failed write removes the half-made row. + */ +internal fun saveToGallery(context: Context, bitmap: Bitmap): Boolean { + val resolver = context.contentResolver + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "flipcash_${System.currentTimeMillis()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/$ALBUM") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return false + return try { + val written = resolver.openOutputStream(uri)?.use { bitmap.compress(Bitmap.CompressFormat.JPEG, 95, it) } == true + check(written) + resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null) + true + } catch (_: Throwable) { + runCatching { resolver.delete(uri, null, null) } + false + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoShare.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoShare.kt deleted file mode 100644 index 40ae7ba928..0000000000 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoShare.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.flipcash.shared.chat.ui.media - -import android.graphics.Bitmap -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.LocalContext -import coil3.SingletonImageLoader -import coil3.request.ImageRequest -import coil3.request.SuccessResult -import coil3.toBitmap -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File - -/** - * Returns a function that shares the photo [model] draws. The image is read back through Coil, which - * has just loaded it for the viewer and so answers from its cache, then written under - * [chatMediaShareDir] where the share sheet is allowed to read it. - */ -@Composable -fun rememberSharePhoto(model: Any?): () -> Unit { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val currentModel by rememberUpdatedState(model) - return share@{ - val data = currentModel ?: return@share - scope.launch { - val file = try { - withContext(Dispatchers.IO) { - val result = SingletonImageLoader.get(context) - .execute(ImageRequest.Builder(context).data(data).build()) - val bitmap = (result as? SuccessResult)?.image?.toBitmap() ?: return@withContext null - File(chatMediaShareDir(context), "photo_${System.currentTimeMillis()}.jpg").also { target -> - target.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 95, it) } - } - } - } catch (e: CancellationException) { - throw e - } catch (_: Throwable) { - null - } - if (file != null) shareImage(context, file) - } - } -} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ForwardArrow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ForwardArrow.kt new file mode 100644 index 0000000000..92646b79e1 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ForwardArrow.kt @@ -0,0 +1,22 @@ +package com.flipcash.shared.chat.ui.media + +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.ui.graphics.vector.ImageVector + +// Material has no forward counterpart to Reply, and its Forward is a straight block arrow. iOS draws +// Forward as Reply's mirror (arrowshape.turn.up.right.fill), so this is Reply's path flipped across x = 12. +val ForwardArrow: ImageVector by lazy { + materialIcon(name = "Flipcash.ForwardArrow", autoMirror = true) { + materialPath { + moveTo(14.0f, 9.0f) + verticalLineTo(5.0f) + lineToRelative(7.0f, 7.0f) + lineToRelative(-7.0f, 7.0f) + verticalLineToRelative(-4.1f) + curveToRelative(-5.0f, 0.0f, -8.5f, 1.6f, -11.0f, 5.1f) + curveToRelative(1.0f, -5.0f, 4.0f, -10.0f, 11.0f, -11.0f) + close() + } + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt index ef340b7d85..05796e2499 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt @@ -280,44 +280,107 @@ class ChatPhotoMediaUiTest { // region viewer - @Test - fun shareIsDisabledUntilTheFullImageLoads() { + private fun viewer( + loaded: Boolean, + zoom: ChatMediaZoomState = ChatMediaZoomState(), + onSave: () -> Unit = {}, + onReply: (() -> Unit)? = {}, + onForward: (() -> Unit)? = {}, + ) { rule.setContent { DesignSystem { ChatMediaViewer( - model = null, fullImageLoaded = false, onFullImageLoaded = {}, - onShare = {}, onDismiss = {}, + model = null, fullImageLoaded = loaded, onFullImageLoaded = {}, + onSave = onSave, onDismiss = {}, onReply = onReply, onForward = onForward, + zoomState = zoom, reduceMotion = true, ) } } - rule.onNodeWithTag(VIEWER_SHARE_TAG).assertIsNotEnabled() + } + + private fun tapPhoto() { + rule.onNodeWithTag(VIEWER_IMAGE_TAG).performTouchInput { click(center) } + rule.mainClock.advanceTimeBy(1_000) + } + + private val allButtons = listOf( + VIEWER_CLOSE_TAG, VIEWER_ZOOM_TAG, VIEWER_REPLY_TAG, VIEWER_SAVE_TAG, VIEWER_FORWARD_TAG, + ) + + @Test + fun opensWithNoChromeEnabled() { + viewer(loaded = true) + allButtons.forEach { rule.onNodeWithTag(it).assertIsNotEnabled() } + } + + @Test + fun singleTapTogglesTheChrome() { + viewer(loaded = true) + tapPhoto() + allButtons.forEach { rule.onNodeWithTag(it).assertIsEnabled() } + tapPhoto() + allButtons.forEach { rule.onNodeWithTag(it).assertIsNotEnabled() } + } + + @Test + fun saveAndZoomAreDisabledUntilTheFullImageLoads() { + viewer(loaded = false) + tapPhoto() + rule.onNodeWithTag(VIEWER_SAVE_TAG).assertIsNotEnabled() + rule.onNodeWithTag(VIEWER_ZOOM_TAG).assertIsNotEnabled() rule.onNodeWithTag(VIEWER_CLOSE_TAG).assertIsEnabled() + rule.onNodeWithTag(VIEWER_REPLY_TAG).assertIsEnabled() + rule.onNodeWithTag(VIEWER_FORWARD_TAG).assertIsEnabled() } @Test - fun shareIsEnabledOnceLoaded() { - rule.setContent { - DesignSystem { - ChatMediaViewer( - model = null, fullImageLoaded = true, onFullImageLoaded = {}, - onShare = {}, onDismiss = {}, - ) - } - } - rule.onNodeWithTag(VIEWER_SHARE_TAG).assertIsEnabled() + fun saveAndZoomAreEnabledOnceLoaded() { + viewer(loaded = true) + tapPhoto() + rule.onNodeWithTag(VIEWER_SAVE_TAG).assertIsEnabled() + rule.onNodeWithTag(VIEWER_ZOOM_TAG).assertIsEnabled() + } + + @Test + fun buttonsCallTheirHandlers() { + var saved = 0 + var replied = 0 + var forwarded = 0 + viewer(loaded = true, onSave = { saved++ }, onReply = { replied++ }, onForward = { forwarded++ }) + tapPhoto() + rule.onNodeWithTag(VIEWER_SAVE_TAG).performClick() + rule.onNodeWithTag(VIEWER_REPLY_TAG).performClick() + rule.onNodeWithTag(VIEWER_FORWARD_TAG).performClick() + assertEquals(listOf(1, 1, 1), listOf(saved, replied, forwarded)) + } + + @Test + fun replyAndForwardAreHiddenWithoutTheirHandlers() { + viewer(loaded = true, onReply = null, onForward = null) + tapPhoto() + rule.onNodeWithTag(VIEWER_REPLY_TAG).assertDoesNotExist() + rule.onNodeWithTag(VIEWER_FORWARD_TAG).assertDoesNotExist() + rule.onNodeWithTag(VIEWER_ZOOM_TAG).assertExists() + } + + @Test + fun zoomButtonTogglesZoomAboutTheCenter() { + val zoom = ChatMediaZoomState() + viewer(loaded = true, zoom = zoom) + tapPhoto() + assertFalse(zoom.isZoomed) + rule.onNodeWithTag(VIEWER_ZOOM_TAG).performClick() + rule.waitForIdle() + assertTrue(zoom.isZoomed) + rule.onNodeWithTag(VIEWER_ZOOM_TAG).performClick() + rule.waitForIdle() + assertFalse(zoom.isZoomed) } @Test fun doubleTapTogglesZoom() { val zoom = ChatMediaZoomState() - rule.setContent { - DesignSystem { - ChatMediaViewer( - model = null, fullImageLoaded = true, onFullImageLoaded = {}, - onShare = {}, onDismiss = {}, zoomState = zoom, reduceMotion = true, - ) - } - } + viewer(loaded = true, zoom = zoom) assertFalse(zoom.isZoomed) rule.onNodeWithTag(VIEWER_IMAGE_TAG).performTouchInput { doubleClick(center) } rule.waitForIdle() @@ -328,26 +391,5 @@ class ChatPhotoMediaUiTest { assertFalse(zoom.isZoomed) } - @Test - fun singleTapTogglesTheButtons() { - rule.setContent { - DesignSystem { - ChatMediaViewer( - model = null, fullImageLoaded = true, onFullImageLoaded = {}, - onShare = {}, onDismiss = {}, reduceMotion = true, - ) - } - } - rule.onNodeWithTag(VIEWER_IMAGE_TAG).performTouchInput { click(center) } - rule.mainClock.advanceTimeBy(1_000) - rule.onNodeWithTag(VIEWER_CLOSE_TAG).assertIsNotEnabled() - rule.onNodeWithTag(VIEWER_SHARE_TAG).assertIsNotEnabled() - - rule.onNodeWithTag(VIEWER_IMAGE_TAG).performTouchInput { click(center) } - rule.mainClock.advanceTimeBy(1_000) - rule.onNodeWithTag(VIEWER_CLOSE_TAG).assertIsEnabled() - rule.onNodeWithTag(VIEWER_SHARE_TAG).assertIsEnabled() - } - // endregion } From 5ea2cc16f619856b3e7053d509686716f04e5fc1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 17:38:40 -0400 Subject: [PATCH 2/7] feat(chat): swap the photo viewer's zoom button for react, close the photo before forward React opens the quick reaction strip above the button and sends the same ToggleReaction event as the context menu's strip. Pinch and double-tap still zoom. --- .../core/src/main/res/values/strings.xml | 1 - .../app/messenger/internal/ChatViewModel.kt | 18 +++-- .../internal/screens/MessengerScreen.kt | 11 +++ .../shared/chat/ui/media/ChatMediaViewer.kt | 69 +++++++++++++--- .../shared/chat/ui/media/ChatPhotoOverlay.kt | 10 ++- .../chat/ui/media/ChatPhotoMediaUiTest.kt | 81 +++++++++++++++---- 6 files changed, 154 insertions(+), 36 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index a01b3f47d7..016b580be1 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1586,7 +1586,6 @@ Price curve for %1$s with %2$s purchased Close - Zoom Save Saved to Photos Couldn\'t save the photo diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 5fd6bdd76c..58c8e01927 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -4193,20 +4193,28 @@ private fun ChatListItem.ContentBubble.narrowedFor(state: ChatViewModel.State): * [ChatViewModel.State.quickReactionStrip] rebuilt for the current selection: empty while nothing * is selected, while the selected message can't be reacted to, or before the inputs have loaded. */ -private fun ChatViewModel.State.withQuickReactionStrip(): ChatViewModel.State { - val bubble = selection?.takeIf { it.canReact } +private fun ChatViewModel.State.withQuickReactionStrip(): ChatViewModel.State = + copy(quickReactionStrip = reactionStripFor(selection)) + +/** + * The quick strip for [bubble]: empty when it can't be reacted to or the inputs haven't loaded. + * Shared by the context menu's strip and the photo viewer's. + */ +internal fun ChatViewModel.State.reactionStripFor( + bubble: ChatListItem.ContentBubble?, +): List { + val reactable = bubble?.takeIf { it.canReact } val inputs = quickReactionInputs - val strip = if (bubble == null || inputs == null) { + return if (reactable == null || inputs == null) { emptyList() } else { ReactionStripComposer.compose( recentStats = inputs.recentStats, - selfReactions = bubble.selfReactions, + selfReactions = reactable.selfReactions, catalogFillSource = inputs.catalogFill, undrawable = inputs.undrawable, ) } - return copy(quickReactionStrip = strip) } /** diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 73a54ddc51..8be76905d2 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -61,6 +61,7 @@ import com.flipcash.app.core.links.LocalTrustedWebsites import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.reactionStripFor import com.flipcash.app.messenger.internal.MentionDestination import com.flipcash.app.messenger.internal.link.CashCardTap import com.flipcash.app.messenger.internal.screens.components.ChatTopBar @@ -577,6 +578,16 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { onClosed = { openPhoto = null }, onSaveResult = { saved -> viewModel.dispatchEvent(ChatViewModel.Event.PhotoSaveResult(saved)) }, // The same actions the message's context menu offers, gated by the same capabilities. + // React sends the event the menu's strip does, and the strip is built the same way. + onReact = if (bubble.canReact) { + { emoji -> + viewModel.dispatchEvent( + ChatViewModel.Event.ToggleReaction(bubble.messageId, emoji, clearsSelection = true) + ) + } + } else null, + reactionStrip = state.reactionStripFor(bubble), + onOpenPicker = { navigator.push(ChatStep.ReactionPicker(bubble.messageId)) }, // Reply sets the strip the way the menu's Reply does; the overlay closes itself. onReply = if (MessageCapability.Reply in bubble.capabilities) { { viewModel.dispatchEvent(ChatViewModel.Event.ReplyRequested(bubble)) } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt index b140d2e308..2455e546fc 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt @@ -24,10 +24,12 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import com.flipcash.shared.chat.reactions.ReactionStrip +import com.flipcash.shared.chat.ui.QuickReactionStrip import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.outlined.Download -import androidx.compose.material.icons.outlined.ZoomIn +import androidx.compose.material.icons.outlined.AddReaction import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -73,7 +75,9 @@ import kotlin.math.abs internal const val VIEWER_IMAGE_TAG = "chat_media_viewer_image" internal const val VIEWER_CLOSE_TAG = "chat_media_viewer_close" -internal const val VIEWER_ZOOM_TAG = "chat_media_viewer_zoom" +internal const val VIEWER_REACT_TAG = "chat_media_viewer_react" +internal const val VIEWER_REACT_STRIP_TAG = "chat_media_viewer_react_strip" +internal const val VIEWER_REACT_SCRIM_TAG = "chat_media_viewer_react_scrim" internal const val VIEWER_REPLY_TAG = "chat_media_viewer_reply" internal const val VIEWER_SAVE_TAG = "chat_media_viewer_save" internal const val VIEWER_FORWARD_TAG = "chat_media_viewer_forward" @@ -86,7 +90,7 @@ private const val MIN_BACKGROUND_ALPHA = 0.2f * * Pinch zooms from 1× to 4× with the pan held inside the image's edges; a double tap goes to 2.5× * about the tapped point, or back to fit when already zoomed. The viewer opens with no chrome; a - * single tap shows or hides the close button and the two pills at the bottom (Zoom and Reply on the + * single tap shows or hides the close button and the two pills at the bottom (React and Reply on the * left, Save and Forward on the right). A one-finger drag that starts while * the image is at fit pulls it away; when zoomed the same drag pans instead. * @@ -97,12 +101,16 @@ private const val MIN_BACKGROUND_ALPHA = 0.2f * close button's. * * [placeholder] (usually the bubble's BlurHash, or the thumbnail already on screen) shows until - * the full image has loaded, which [onFullImageLoaded] reports. Zoom and Save stay disabled until - * [fullImageLoaded] is true, since there is nothing worth zooming or saving before then. + * the full image has loaded, which [onFullImageLoaded] reports. Save stays disabled until + * [fullImageLoaded] is true, since there is nothing worth saving before then. * * Hostable as a Nav3 entry: it takes plain parameters and calls back for close, save, reply and * forward. The host owns what each does; [rememberSavePhoto] is the usual answer to Save. * + * @param onReact a pick from [reactionStrip]; null hides the React button, for a message that + * cannot be reacted to. The button opens the strip above it, and a pick, a tap outside it or + * hiding the chrome closes it again. + * @param onOpenPicker the strip's "+": the host's full emoji picker. * @param onReply Reply; null hides the button, for a message that cannot be replied to. * @param onForward Forward; null hides the button, for a message with no link to hand out. * @param model what Coil loads for the full image. @@ -119,6 +127,9 @@ fun ChatMediaViewer( onSave: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, + onReact: ((emoji: String) -> Unit)? = null, + reactionStrip: List = emptyList(), + onOpenPicker: () -> Unit = {}, onReply: (() -> Unit)? = null, onForward: (() -> Unit)? = null, placeholder: Painter? = null, @@ -139,6 +150,8 @@ fun ChatMediaViewer( val currentOnPullEnd by rememberUpdatedState(onPullEnd) val touchSlop = LocalViewConfiguration.current.touchSlop var chromeVisible by remember { mutableStateOf(false) } + var stripOpen by remember { mutableStateOf(false) } + LaunchedEffect(chromeVisible) { if (!chromeVisible) stripOpen = false } val chromeShown by animateFloatAsState( targetValue = if (chromeVisible) 1f else 0f, animationSpec = if (reduceMotion) snap() else tween(200), @@ -323,7 +336,35 @@ fun ChatMediaViewer( onClick = onDismiss, modifier = chrome.align(Alignment.TopStart), ) - val center = { Offset(zoomState.container.width / 2f, zoomState.container.height / 2f) } + if (stripOpen && onReact != null && reactionStrip.isNotEmpty()) { + // A tap anywhere else closes the strip without reacting. + Box( + modifier = Modifier + .fillMaxSize() + .testTag(VIEWER_REACT_SCRIM_TAG) + .pointerInput(Unit) { detectTapGestures { stripOpen = false } }, + ) + QuickReactionStrip( + entries = reactionStrip, + onToggle = { emoji -> + stripOpen = false + onReact(emoji) + }, + onOpenPicker = { + stripOpen = false + onOpenPicker() + }, + modifier = chrome + .align(Alignment.BottomStart) + .navigationBarsPadding() + .padding( + start = CodeTheme.dimens.staticGrid.x4, + bottom = CodeTheme.dimens.staticGrid.x3 + CodeTheme.dimens.staticGrid.x10 + + CodeTheme.dimens.staticGrid.x2, + ) + .testTag(VIEWER_REACT_STRIP_TAG), + ) + } Row( modifier = chrome .align(Alignment.BottomStart) @@ -333,13 +374,15 @@ fun ChatMediaViewer( horizontalArrangement = Arrangement.SpaceBetween, ) { ViewerPill { - ViewerPillButton( - icon = Icons.Outlined.ZoomIn, - description = stringResource(R.string.description_chatMediaZoom), - enabled = fullImageLoaded && interactive && chromeVisible, - tag = VIEWER_ZOOM_TAG, - onClick = { scope.launch { zoomState.toggle(center(), animated = !reduceMotion) } }, - ) + if (onReact != null) { + ViewerPillButton( + icon = Icons.Outlined.AddReaction, + description = stringResource(R.string.action_addReaction), + enabled = interactive && chromeVisible, + tag = VIEWER_REACT_TAG, + onClick = { stripOpen = !stripOpen }, + ) + } if (onReply != null) { ViewerPillButton( icon = Icons.AutoMirrored.Filled.Reply, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt index 434e6a1c6a..b24f733b59 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.chat.ui.media +import com.flipcash.shared.chat.reactions.ReactionStrip import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.VectorConverter @@ -163,6 +164,9 @@ fun ChatPhotoOverlay( onClosed: () -> Unit, onSaveResult: (saved: Boolean) -> Unit, modifier: Modifier = Modifier, + onReact: ((emoji: String) -> Unit)? = null, + reactionStrip: List = emptyList(), + onOpenPicker: () -> Unit = {}, onReply: (() -> Unit)? = null, onForward: (() -> Unit)? = null, reduceMotion: Boolean = rememberReducedMotion(), @@ -235,7 +239,11 @@ fun ChatPhotoOverlay( onSave = save, // Reply leaves the photo, back into its bubble, as the composer's strip opens. onReply = onReply?.let { reply -> { presentation.dismiss(); reply() } }, - onForward = onForward, + // Forward and the picker, like Reply, leave the photo before their screen opens. + onForward = onForward?.let { forward -> { presentation.dismiss(); forward() } }, + onReact = onReact, + reactionStrip = reactionStrip, + onOpenPicker = { presentation.dismiss(); onOpenPicker() }, onDismiss = presentation::dismiss, zoomState = zoomState, reduceMotion = reduceMotion, diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt index 05796e2499..aae87d07ff 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt @@ -1,5 +1,7 @@ package com.flipcash.shared.chat.ui.media +import com.flipcash.shared.chat.reactions.ReactionStrip +import androidx.compose.ui.geometry.Offset import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.runtime.getValue @@ -286,25 +288,32 @@ class ChatPhotoMediaUiTest { onSave: () -> Unit = {}, onReply: (() -> Unit)? = {}, onForward: (() -> Unit)? = {}, + onReact: ((String) -> Unit)? = {}, ) { rule.setContent { DesignSystem { ChatMediaViewer( model = null, fullImageLoaded = loaded, onFullImageLoaded = {}, onSave = onSave, onDismiss = {}, onReply = onReply, onForward = onForward, + onReact = onReact, reactionStrip = strip, zoomState = zoom, reduceMotion = true, ) } } } + private val strip = listOf( + ReactionStrip.Entry(emoji = "\uD83D\uDC4D", highlighted = false), + ReactionStrip.Entry(emoji = "\u2764\uFE0F", highlighted = false), + ) + private fun tapPhoto() { rule.onNodeWithTag(VIEWER_IMAGE_TAG).performTouchInput { click(center) } rule.mainClock.advanceTimeBy(1_000) } private val allButtons = listOf( - VIEWER_CLOSE_TAG, VIEWER_ZOOM_TAG, VIEWER_REPLY_TAG, VIEWER_SAVE_TAG, VIEWER_FORWARD_TAG, + VIEWER_CLOSE_TAG, VIEWER_REACT_TAG, VIEWER_REPLY_TAG, VIEWER_SAVE_TAG, VIEWER_FORWARD_TAG, ) @Test @@ -323,22 +332,21 @@ class ChatPhotoMediaUiTest { } @Test - fun saveAndZoomAreDisabledUntilTheFullImageLoads() { + fun saveIsDisabledUntilTheFullImageLoads() { viewer(loaded = false) tapPhoto() rule.onNodeWithTag(VIEWER_SAVE_TAG).assertIsNotEnabled() - rule.onNodeWithTag(VIEWER_ZOOM_TAG).assertIsNotEnabled() + rule.onNodeWithTag(VIEWER_REACT_TAG).assertIsEnabled() rule.onNodeWithTag(VIEWER_CLOSE_TAG).assertIsEnabled() rule.onNodeWithTag(VIEWER_REPLY_TAG).assertIsEnabled() rule.onNodeWithTag(VIEWER_FORWARD_TAG).assertIsEnabled() } @Test - fun saveAndZoomAreEnabledOnceLoaded() { + fun saveIsEnabledOnceLoaded() { viewer(loaded = true) tapPhoto() rule.onNodeWithTag(VIEWER_SAVE_TAG).assertIsEnabled() - rule.onNodeWithTag(VIEWER_ZOOM_TAG).assertIsEnabled() } @Test @@ -360,21 +368,62 @@ class ChatPhotoMediaUiTest { tapPhoto() rule.onNodeWithTag(VIEWER_REPLY_TAG).assertDoesNotExist() rule.onNodeWithTag(VIEWER_FORWARD_TAG).assertDoesNotExist() - rule.onNodeWithTag(VIEWER_ZOOM_TAG).assertExists() + rule.onNodeWithTag(VIEWER_SAVE_TAG).assertExists() } @Test - fun zoomButtonTogglesZoomAboutTheCenter() { - val zoom = ChatMediaZoomState() - viewer(loaded = true, zoom = zoom) + fun reactIsHiddenWithoutItsHandler() { + viewer(loaded = true, onReact = null) tapPhoto() - assertFalse(zoom.isZoomed) - rule.onNodeWithTag(VIEWER_ZOOM_TAG).performClick() - rule.waitForIdle() - assertTrue(zoom.isZoomed) - rule.onNodeWithTag(VIEWER_ZOOM_TAG).performClick() - rule.waitForIdle() - assertFalse(zoom.isZoomed) + rule.onNodeWithTag(VIEWER_REACT_TAG).assertDoesNotExist() + } + + @Test + fun reactIsEnabledBeforeTheImageLoads() { + viewer(loaded = false) + tapPhoto() + rule.onNodeWithTag(VIEWER_REACT_TAG).assertIsEnabled() + } + + @Test + fun reactOpensTheStripAndAPickCallsTheHandlerAndClosesIt() { + val picked = mutableListOf() + viewer(loaded = true, onReact = { picked += it }) + tapPhoto() + rule.onNodeWithTag(VIEWER_REACT_STRIP_TAG).assertDoesNotExist() + rule.onNodeWithTag(VIEWER_REACT_TAG).performClick() + rule.mainClock.advanceTimeBy(1_000) + rule.onNodeWithTag(VIEWER_REACT_STRIP_TAG).assertExists() + rule.onNodeWithTag("quick_reaction_\u2764\uFE0F").performClick() + rule.mainClock.advanceTimeBy(1_000) + assertEquals(listOf("\u2764\uFE0F"), picked) + rule.onNodeWithTag(VIEWER_REACT_STRIP_TAG).assertDoesNotExist() + // The viewer stays open with its chrome. + rule.onNodeWithTag(VIEWER_SAVE_TAG).assertIsEnabled() + } + + @Test + fun tappingOutsideTheStripClosesItWithoutReacting() { + val picked = mutableListOf() + viewer(loaded = true, onReact = { picked += it }) + tapPhoto() + rule.onNodeWithTag(VIEWER_REACT_TAG).performClick() + rule.mainClock.advanceTimeBy(1_000) + rule.onNodeWithTag(VIEWER_REACT_SCRIM_TAG).performTouchInput { click(topLeft + Offset(10f, 10f)) } + rule.mainClock.advanceTimeBy(1_000) + rule.onNodeWithTag(VIEWER_REACT_STRIP_TAG).assertDoesNotExist() + assertTrue(picked.isEmpty()) + } + + @Test + fun tappingReactAgainClosesTheStrip() { + viewer(loaded = true) + tapPhoto() + rule.onNodeWithTag(VIEWER_REACT_TAG).performClick() + rule.mainClock.advanceTimeBy(1_000) + rule.onNodeWithTag(VIEWER_REACT_TAG).performClick() + rule.mainClock.advanceTimeBy(1_000) + rule.onNodeWithTag(VIEWER_REACT_STRIP_TAG).assertDoesNotExist() } @Test From 5c37ca8ff46dd461d39a4deda3a2dfae10607fd4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 17:49:55 -0400 Subject: [PATCH 3/7] fix(chat): tidy the photo viewer's react, save and dead route React highlights the live picks, not the ones from when the photo opened, and stays disabled until the strip has entries. Save writes the cached original (JPEG, PNG or WebP) instead of re-encoding. Removes the unused AppRoute.Messaging.PhotoViewer route, screen and view model. --- .../ui/navigation/AppScreenContent.kt | 2 - .../kotlin/com/flipcash/app/core/AppRoute.kt | 9 --- .../app/messenger/ChatPhotoViewerScreen.kt | 41 ------------- .../internal/ChatPhotoViewerViewModel.kt | 59 ------------------ .../app/messenger/internal/ChatViewModel.kt | 13 ++++ .../internal/screens/MessengerScreen.kt | 7 ++- .../internal/ChatReactionsReducerTest.kt | 28 +++++++++ .../shared/chat/ui/media/ChatMediaViewer.kt | 3 +- .../shared/chat/ui/media/ChatPhotoSave.kt | 60 +++++++++++++++++-- .../chat/ui/media/ChatPhotoMediaUiTest.kt | 12 +++- .../shared/chat/ui/media/ChatPhotoSaveTest.kt | 33 ++++++++++ 11 files changed, 148 insertions(+), 119 deletions(-) delete mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatPhotoViewerScreen.kt delete mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatPhotoViewerViewModel.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSaveTest.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 76d35f1a3f..7aeb01df63 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -44,7 +44,6 @@ import com.flipcash.app.currency.RegionSelectionScreen import com.flipcash.app.deposit.DepositFlowScreen import com.flipcash.app.invite.InviteContactScreen import com.flipcash.app.messenger.ChatFlowScreen -import com.flipcash.app.messenger.ChatPhotoViewerScreen import com.flipcash.app.messenger.E2eeLearnMoreScreen import com.flipcash.app.messenger.MuteChatScreen import com.flipcash.app.messenger.ProfileScreen @@ -171,7 +170,6 @@ internal fun appEntryProvider( annotatedEntry { SharePickerScreen() } annotatedEntry { key -> ShareProfileToChatsScreen(key) } annotatedEntry { key -> MuteChatScreen(key.chatId, key.chatType) } - annotatedEntry { key -> ChatPhotoViewerScreen(key.chatId, key.messageId) } annotatedEntry { E2eeLearnMoreScreen(forGroup = false) } annotatedEntry { E2eeLearnMoreScreen(forGroup = true) } annotatedEntry { diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 03254b422e..1eecce3441 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -563,15 +563,6 @@ sealed interface AppRoute : NavKey, Parcelable { com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet - /** - * The full-screen viewer for the photo in message [messageId] of [chatId]. Carries the - * message's address rather than the photo, which is not something a back stack can hold; the - * viewer reads the message back to learn how to fetch and open it. - */ - @Serializable - @Parcelize - data class PhotoViewer(val chatId: ChatId, val messageId: Long) : Messaging - /** The group counterpart of [E2eeDmInfo], opened from Group Info's footer. */ @Serializable @Parcelize diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatPhotoViewerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatPhotoViewerScreen.kt deleted file mode 100644 index f5ceefed87..0000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatPhotoViewerScreen.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.flipcash.app.messenger - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.messenger.internal.ChatPhotoViewerViewModel -import com.flipcash.services.models.chat.ChatId -import com.flipcash.shared.chat.ui.media.ChatMediaViewer -import com.flipcash.shared.chat.ui.media.rememberSavePhoto -import com.getcode.navigation.core.LocalCodeNavigator - -/** The full-screen photo, behind `AppRoute.Messaging.PhotoViewer`. Black, dismissed by back or a pull. */ -@Composable -fun ChatPhotoViewerScreen(chatId: ChatId, messageId: Long) { - val viewModel = hiltViewModel() - LaunchedEffect(chatId, messageId) { viewModel.load(chatId, messageId) } - val photo by viewModel.photo.collectAsStateWithLifecycle() - val navigator = LocalCodeNavigator.current - var fullImageLoaded by remember { mutableStateOf(false) } - val save = rememberSavePhoto(photo) { saved -> viewModel.onSaveResult(saved) } - - Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { - ChatMediaViewer( - model = photo, - fullImageLoaded = fullImageLoaded, - onFullImageLoaded = { fullImageLoaded = true }, - onSave = save, - onDismiss = { navigator.pop() }, - ) - } -} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatPhotoViewerViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatPhotoViewerViewModel.kt deleted file mode 100644 index b1946e655e..0000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatPhotoViewerViewModel.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.flipcash.app.messenger.internal - -import androidx.lifecycle.ViewModel -import com.flipcash.app.core.toast.SystemToastController -import com.flipcash.core.R -import androidx.lifecycle.viewModelScope -import com.flipcash.services.models.chat.ChatId -import com.flipcash.shared.chat.ChatCoordinator -import com.flipcash.shared.chat.media.ChatPhoto -import com.flipcash.shared.chat.ui.media.photoBody -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import javax.inject.Inject - -/** - * Reads the photo behind a viewer route. The route carries only the message's address, so this looks - * the message up and builds the Coil model the transcript's bubble loads from, which is what lets the - * viewer open on what the bubble already cached. - */ -@HiltViewModel -internal class ChatPhotoViewerViewModel @Inject constructor( - private val chatCoordinator: ChatCoordinator, - private val toastController: SystemToastController, -) : ViewModel() { - - private val _photo = MutableStateFlow(null) - val photo: StateFlow = _photo - - /** Tells the user how a save went, as a system toast, which is what the chat does for a copy. */ - fun onSaveResult(saved: Boolean) { - toastController.showToast( - if (saved) R.string.title_chatPhotoSaved else R.string.error_chatPhotoSaveFailed, - replacePrevious = true, - ) - } - - private var loaded: Pair? = null - - fun load(chatId: ChatId, messageId: Long) { - if (loaded == chatId to messageId) return - loaded = chatId to messageId - viewModelScope.launch { - val message = chatCoordinator.getMessage(chatId, messageId) ?: return@launch - val rendition = message.content.firstNotNullOfOrNull { it.photoBody() } - ?.items?.firstOrNull() - ?.let { item -> item.rendition(com.flipcash.services.models.chat.MediaItemRendition.Role.ORIGINAL) ?: item.renditions.firstOrNull() } - ?: return@launch - _photo.value = ChatPhoto( - chatId = chatId, - rendition = rendition, - senderId = message.senderId, - sealed = message.encryption != null, - redacted = message.redacted, - ) - } - } -} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 58c8e01927..93c8801b8f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -2663,6 +2663,14 @@ internal class ChatViewModel @AssistedInject constructor( .launchIn(viewModelScope) } + /** + * The viewer's own reactions on [messageId] as they stand now, `null` while nothing is known + * beyond the bubble a caller already holds. For a surface that outlives the bubble it was + * opened with, like the photo viewer, whose strip would otherwise highlight stale picks. + */ + fun selfReactionsOf(messageId: Long): Flow?> = + reactionOverlay.map { it[messageId]?.selfReactions }.distinctUntilChanged() + private suspend fun loadQuickReactionInputs(): QuickReactionInputs { val catalog = emojiCatalogLoader.load() val fill = catalog.firstCategoryEntries.map { it.emoji } @@ -4196,6 +4204,11 @@ private fun ChatListItem.ContentBubble.narrowedFor(state: ChatViewModel.State): private fun ChatViewModel.State.withQuickReactionStrip(): ChatViewModel.State = copy(quickReactionStrip = reactionStripFor(selection)) +/** This bubble with the viewer's reactions replaced by [live], when the overlay knows better. */ +internal fun ChatListItem.ContentBubble.withLiveSelfReactions( + live: List?, +): ChatListItem.ContentBubble = if (live == null) this else copy(selfReactions = live) + /** * The quick strip for [bubble]: empty when it can't be reacted to or the inputs haven't loaded. * Shared by the context menu's strip and the photo viewer's. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 8be76905d2..ef6d225784 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -62,6 +62,7 @@ import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.reactionStripFor +import com.flipcash.app.messenger.internal.withLiveSelfReactions import com.flipcash.app.messenger.internal.MentionDestination import com.flipcash.app.messenger.internal.link.CashCardTap import com.flipcash.app.messenger.internal.screens.components.ChatTopBar @@ -571,7 +572,11 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } - openPhoto?.let { bubble -> + openPhoto?.let { opened -> + // The bubble the photo opened from is a snapshot; the strip highlights the live picks. + val live by remember(opened.messageId) { viewModel.selfReactionsOf(opened.messageId) } + .collectAsStateWithLifecycle(initialValue = null) + val bubble = opened.withLiveSelfReactions(live) ChatPhotoOverlay( messageId = bubble.messageId, sources = photoSources, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt index e1ab6feaf3..c7d27ece2b 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt @@ -150,4 +150,32 @@ class ChatReactionsReducerTest { assertEquals(1L, selected.selection?.messageId) assertFalse(selected.reactionStripOnly) } + + @Test + fun `the photo viewer's strip highlights the live picks, not the ones from when it opened`() { + val squid = SelfReaction(emoji = "🦑", reactedAt = Instant.fromEpochSeconds(2_000)) + val state = ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs) + val opened = bubble(1) + + val stale = state.reactionStripFor(opened) + val live = state.reactionStripFor(opened.withLiveSelfReactions(listOf(squid))) + val cleared = state.reactionStripFor(opened.withLiveSelfReactions(listOf(squid)).withLiveSelfReactions(emptyList())) + + assertFalse(ReactionStrip.Entry(emoji = "🦑", highlighted = true) in stale) + assertTrue(ReactionStrip.Entry(emoji = "🦑", highlighted = true) in live) + assertFalse(cleared.any { it.highlighted }) + } + + @Test + fun `an overlay with nothing for the message leaves the opened bubble as it is`() { + val squid = SelfReaction(emoji = "🦑", reactedAt = Instant.fromEpochSeconds(2_000)) + val opened = bubble(1, selfReactions = listOf(squid)) + + assertEquals(opened, opened.withLiveSelfReactions(null)) + } + + @Test + fun `the photo viewer's strip is empty until the inputs load`() { + assertEquals(emptyList(), ChatViewModel.State(messagePolicy = unbounded).reactionStripFor(bubble(1))) + } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt index 2455e546fc..5a6f39e9ed 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt @@ -378,7 +378,8 @@ fun ChatMediaViewer( ViewerPillButton( icon = Icons.Outlined.AddReaction, description = stringResource(R.string.action_addReaction), - enabled = interactive && chromeVisible, + // Nothing to show until the strip's inputs have loaded. + enabled = interactive && chromeVisible && reactionStrip.isNotEmpty(), tag = VIEWER_REACT_TAG, onClick = { stripOpen = !stripOpen }, ) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSave.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSave.kt index 20ef52f613..bdc7d6849a 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSave.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSave.kt @@ -15,6 +15,7 @@ import coil3.request.ImageRequest import coil3.request.SuccessResult import coil3.size.Size import coil3.toBitmap +import com.flipcash.shared.chat.media.ChatPhoto import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -25,7 +26,8 @@ private const val ALBUM = "Flipcash" /** * Returns a function that saves the photo [model] draws to the device's gallery and then reports * whether it worked through [onResult]. The image is read back through Coil, which has just loaded - * it for the viewer and so answers from its cache. + * it for the viewer and so answers from its cache. A [ChatPhoto] saves the cached original as it + * is when it's a JPEG, PNG or WebP; anything else is re-encoded as a JPEG. * * Needs no permission: the app's minSdk is 29, where MediaStore lets an app add its own pictures. */ @@ -40,10 +42,23 @@ fun rememberSavePhoto(model: Any?, onResult: (Boolean) -> Unit): () -> Unit { scope.launch { val saved = try { withContext(Dispatchers.IO) { - val result = SingletonImageLoader.get(context) - .execute(ImageRequest.Builder(context).data(data).size(Size.ORIGINAL).build()) - val bitmap = (result as? SuccessResult)?.image?.toBitmap() ?: return@withContext false - saveToGallery(context, bitmap) + val imageLoader = SingletonImageLoader.get(context) + val result = imageLoader.execute(ImageRequest.Builder(context).data(data).size(Size.ORIGINAL).build()) + if (result !is SuccessResult) return@withContext false + // The fetcher caches the opened original under the photo's key, so the file the + // sender uploaded is on disk by now; saving it avoids a lossy re-encode. + val original = (data as? ChatPhoto)?.let { photo -> + imageLoader.diskCache?.openSnapshot(photo.cacheKey)?.use { snapshot -> + imageLoader.diskCache?.fileSystem?.read(snapshot.data) { readByteArray() } + } + } + val type = original?.let(::sniffImageType) + if (original != null && type != null) { + saveBytesToGallery(context, original, type) + } else { + val bitmap = result.image.toBitmap() + saveToGallery(context, bitmap) + } } } catch (e: CancellationException) { throw e @@ -55,6 +70,41 @@ fun rememberSavePhoto(model: Any?, onResult: (Boolean) -> Unit): () -> Unit { } } +/** An image type [saveBytesToGallery] can write as it is: its MIME type and file extension. */ +internal data class ImageType(val mime: String, val extension: String) + +/** Reads the type from [bytes]' signature; null when it isn't one the gallery takes as it is. */ +internal fun sniffImageType(bytes: ByteArray): ImageType? { + fun at(offset: Int, vararg values: Int) = + bytes.size >= offset + values.size && values.indices.all { (bytes[offset + it].toInt() and 0xFF) == values[it] } + return when { + at(0, 0xFF, 0xD8, 0xFF) -> ImageType("image/jpeg", "jpg") + at(0, 0x89, 0x50, 0x4E, 0x47) -> ImageType("image/png", "png") + at(0, 0x52, 0x49, 0x46, 0x46) && at(8, 0x57, 0x45, 0x42, 0x50) -> ImageType("image/webp", "webp") + else -> null + } +} + +/** [saveToGallery] for bytes that are already an encoded image: written untouched. */ +internal fun saveBytesToGallery(context: Context, bytes: ByteArray, type: ImageType): Boolean { + val resolver = context.contentResolver + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "flipcash_${System.currentTimeMillis()}.${type.extension}") + put(MediaStore.Images.Media.MIME_TYPE, type.mime) + put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/$ALBUM") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return false + return try { + resolver.openOutputStream(uri)!!.use { it.write(bytes) } + resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null) + true + } catch (_: Throwable) { + runCatching { resolver.delete(uri, null, null) } + false + } +} + /** * Inserts [bitmap] into the shared pictures collection as a JPEG. `IS_PENDING` keeps the file out of * other apps' queries until it is fully written; a failed write removes the half-made row. diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt index aae87d07ff..b9417e4f5b 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt @@ -289,13 +289,14 @@ class ChatPhotoMediaUiTest { onReply: (() -> Unit)? = {}, onForward: (() -> Unit)? = {}, onReact: ((String) -> Unit)? = {}, + reactionStrip: List = strip, ) { rule.setContent { DesignSystem { ChatMediaViewer( model = null, fullImageLoaded = loaded, onFullImageLoaded = {}, onSave = onSave, onDismiss = {}, onReply = onReply, onForward = onForward, - onReact = onReact, reactionStrip = strip, + onReact = onReact, reactionStrip = reactionStrip, zoomState = zoom, reduceMotion = true, ) } @@ -378,6 +379,15 @@ class ChatPhotoMediaUiTest { rule.onNodeWithTag(VIEWER_REACT_TAG).assertDoesNotExist() } + @Test + fun reactIsDisabledWhileTheStripHasNoEntries() { + viewer(loaded = true, reactionStrip = emptyList()) + tapPhoto() + rule.onNodeWithTag(VIEWER_REACT_TAG).assertIsNotEnabled() + rule.onNodeWithTag(VIEWER_REACT_TAG).performClick() + rule.onNodeWithTag(VIEWER_REACT_STRIP_TAG).assertDoesNotExist() + } + @Test fun reactIsEnabledBeforeTheImageLoads() { viewer(loaded = false) diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSaveTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSaveTest.kt new file mode 100644 index 0000000000..ab2d7fa5dc --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSaveTest.kt @@ -0,0 +1,33 @@ +package com.flipcash.shared.chat.ui.media + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ChatPhotoSaveTest { + + private fun bytes(vararg v: Int) = ByteArray(v.size) { v[it].toByte() } + + @Test + fun jpegIsSavedAsItIs() { + assertEquals("image/jpeg", sniffImageType(bytes(0xFF, 0xD8, 0xFF, 0xE0, 0))?.mime) + } + + @Test + fun pngIsSavedAsItIs() { + assertEquals("png", sniffImageType(bytes(0x89, 0x50, 0x4E, 0x47, 0x0D))?.extension) + } + + @Test + fun webpNeedsBothRiffAndWebpMarkers() { + val webp = bytes(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50) + assertEquals("image/webp", sniffImageType(webp)?.mime) + assertNull(sniffImageType(bytes(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x41, 0x56, 0x49, 0x20))) + } + + @Test + fun anythingElseFallsBackToReencoding() { + assertNull(sniffImageType(bytes(1, 2, 3))) + assertNull(sniffImageType(ByteArray(0))) + } +} From 7b5c382596c114786dd7749459870cb55824f658 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 20:23:39 -0400 Subject: [PATCH 4/7] feat(chat): show the viewer's own reactions on the photo viewer's React button The button draws up to three of the viewer's own emoji, or the first two and a +N count, in place of the add-reaction icon. It reads the same live self-reactions as the strip highlight, ordered like the pill row. --- .../core/src/main/res/values/strings.xml | 1 + .../app/messenger/internal/ChatViewModel.kt | 16 +++++ .../internal/screens/MessengerScreen.kt | 2 + .../internal/ChatReactionsReducerTest.kt | 16 +++++ .../shared/chat/ui/media/ChatMediaViewer.kt | 58 +++++++++++++++++- .../shared/chat/ui/media/ChatPhotoOverlay.kt | 2 + .../chat/ui/media/ChatPhotoMediaUiTest.kt | 61 ++++++++++++++++++- 7 files changed, 153 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 016b580be1..d971da5770 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1112,6 +1112,7 @@ Clear search Choose skin tone Add reaction + %1$s, your reactions: %2$s Unknown Contact Allow Full Contact Access diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 93c8801b8f..c014c4727f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -4209,6 +4209,22 @@ internal fun ChatListItem.ContentBubble.withLiveSelfReactions( live: List?, ): ChatListItem.ContentBubble = if (live == null) this else copy(selfReactions = live) +/** + * The emoji the viewer reacted to this bubble with, in the pill row's order where the row knows + * them, then newest first. Read from the same live [ChatListItem.ContentBubble.selfReactions] the + * strip's highlight uses, so the photo viewer's React button never has a second source. + */ +internal fun ChatListItem.ContentBubble.ownReactionEmoji(): List { + val rank = reactionPills.withIndex().associate { it.value.emoji to it.index } + return selfReactions.withIndex() + .sortedWith( + compareBy> { rank[it.value.emoji] ?: Int.MAX_VALUE } + .thenByDescending { it.value.reactedAt } + .thenBy { it.index }, + ) + .map { it.value.emoji } +} + /** * The quick strip for [bubble]: empty when it can't be reacted to or the inputs haven't loaded. * Shared by the context menu's strip and the photo viewer's. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index ef6d225784..e6c1971857 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -61,6 +61,7 @@ import com.flipcash.app.core.links.LocalTrustedWebsites import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.ownReactionEmoji import com.flipcash.app.messenger.internal.reactionStripFor import com.flipcash.app.messenger.internal.withLiveSelfReactions import com.flipcash.app.messenger.internal.MentionDestination @@ -592,6 +593,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } else null, reactionStrip = state.reactionStripFor(bubble), + ownReactions = bubble.ownReactionEmoji(), onOpenPicker = { navigator.push(ChatStep.ReactionPicker(bubble.messageId)) }, // Reply sets the strip the way the menu's Reply does; the overlay closes itself. onReply = if (MessageCapability.Reply in bubble.capabilities) { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt index c7d27ece2b..855d9cba4c 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt @@ -178,4 +178,20 @@ class ChatReactionsReducerTest { fun `the photo viewer's strip is empty until the inputs load`() { assertEquals(emptyList(), ChatViewModel.State(messagePolicy = unbounded).reactionStripFor(bubble(1))) } + + @Test + fun `own reactions follow the pill row's order, then newest first, and track the live picks`() { + val a = SelfReaction("🦑", Instant.fromEpochSeconds(1)) + val b = SelfReaction("🔥", Instant.fromEpochSeconds(3)) + val c = SelfReaction("👍", Instant.fromEpochSeconds(2)) + val pill = { e: String -> com.flipcash.shared.chat.reactions.ReactionPill(e, 1, true, false) } + val opened = bubble(1).copy(reactionPills = listOf(pill("👍"), pill("🦑"))) + + assertEquals(emptyList(), opened.ownReactionEmoji()) + assertEquals( + listOf("👍", "🦑", "🔥"), + opened.withLiveSelfReactions(listOf(a, b, c)).ownReactionEmoji(), + ) + assertEquals(listOf("🔥", "👍"), bubble(1).withLiveSelfReactions(listOf(c, b)).ownReactionEmoji()) + } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt index 5a6f39e9ed..168873a89e 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt @@ -20,6 +20,14 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.material3.Text +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.CircleShape @@ -76,6 +84,8 @@ import kotlin.math.abs internal const val VIEWER_IMAGE_TAG = "chat_media_viewer_image" internal const val VIEWER_CLOSE_TAG = "chat_media_viewer_close" internal const val VIEWER_REACT_TAG = "chat_media_viewer_react" +internal const val VIEWER_REACT_GLYPH_TAG = "chat_media_viewer_react_glyph" +internal const val VIEWER_REACT_MORE_TAG = "chat_media_viewer_react_more" internal const val VIEWER_REACT_STRIP_TAG = "chat_media_viewer_react_strip" internal const val VIEWER_REACT_SCRIM_TAG = "chat_media_viewer_react_scrim" internal const val VIEWER_REPLY_TAG = "chat_media_viewer_reply" @@ -107,6 +117,8 @@ private const val MIN_BACKGROUND_ALPHA = 0.2f * Hostable as a Nav3 entry: it takes plain parameters and calls back for close, save, reply and * forward. The host owns what each does; [rememberSavePhoto] is the usual answer to Save. * + * @param ownReactions the emoji the viewer has reacted with, in the order the pill row shows them; + * the React button draws them in place of its icon. * @param onReact a pick from [reactionStrip]; null hides the React button, for a message that * cannot be reacted to. The button opens the strip above it, and a pick, a tap outside it or * hiding the chrome closes it again. @@ -129,6 +141,7 @@ fun ChatMediaViewer( modifier: Modifier = Modifier, onReact: ((emoji: String) -> Unit)? = null, reactionStrip: List = emptyList(), + ownReactions: List = emptyList(), onOpenPicker: () -> Unit = {}, onReply: (() -> Unit)? = null, onForward: (() -> Unit)? = null, @@ -377,7 +390,8 @@ fun ChatMediaViewer( if (onReact != null) { ViewerPillButton( icon = Icons.Outlined.AddReaction, - description = stringResource(R.string.action_addReaction), + emoji = ownReactions, + description = reactDescription(ownReactions), // Nothing to show until the strip's inputs have loaded. enabled = interactive && chromeVisible && reactionStrip.isNotEmpty(), tag = VIEWER_REACT_TAG, @@ -472,13 +486,21 @@ private fun ViewerPillButton( enabled: Boolean, tag: String, onClick: () -> Unit, + emoji: List = emptyList(), ) { IconButton( onClick = onClick, // Hidden buttons fade out at full tint but stop taking taps straight away. enabled = enabled, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x10).testTag(tag), + modifier = (if (emoji.isEmpty()) Modifier else Modifier.widthIn(min = CodeTheme.dimens.staticGrid.x10)) + .then(if (emoji.isEmpty()) Modifier.size(CodeTheme.dimens.staticGrid.x10) else Modifier.height(CodeTheme.dimens.staticGrid.x10)) + .semantics { contentDescription = description } + .testTag(tag), ) { + if (emoji.isNotEmpty()) { + ReactionGlyphs(emoji, dimmed = !enabled) + return@IconButton + } Icon( imageVector = icon, contentDescription = description, @@ -486,3 +508,35 @@ private fun ViewerPillButton( ) } } + +/** The user's own reactions drawn as text: up to three overlapping, else the first two and "+N". */ +@Composable +private fun ReactionGlyphs(emoji: List, dimmed: Boolean) { + val shown = if (emoji.size > 3) emoji.take(2) else emoji + Row( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.staticGrid.x1).graphicsLayer { alpha = if (dimmed) 0.4f else 1f }, + verticalAlignment = Alignment.CenterVertically, + ) { + shown.forEachIndexed { index, glyph -> + Text( + text = glyph, + fontSize = 18.sp, + modifier = Modifier.then(if (index == 0) Modifier else Modifier.offset(x = (-6 * index).dp)).testTag(VIEWER_REACT_GLYPH_TAG), + ) + } + if (emoji.size > 3) { + Text( + text = "+${emoji.size - 2}", + color = Color.White, + fontSize = 13.sp, + modifier = Modifier.offset(x = (-12).dp).testTag(VIEWER_REACT_MORE_TAG), + ) + } + } +} + +@Composable +private fun reactDescription(own: List): String { + val base = stringResource(R.string.action_addReaction) + return if (own.isEmpty()) base else stringResource(R.string.description_chatMediaReactWith, base, own.joinToString(" ")) +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt index b24f733b59..eb9e79750d 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoOverlay.kt @@ -166,6 +166,7 @@ fun ChatPhotoOverlay( modifier: Modifier = Modifier, onReact: ((emoji: String) -> Unit)? = null, reactionStrip: List = emptyList(), + ownReactions: List = emptyList(), onOpenPicker: () -> Unit = {}, onReply: (() -> Unit)? = null, onForward: (() -> Unit)? = null, @@ -243,6 +244,7 @@ fun ChatPhotoOverlay( onForward = onForward?.let { forward -> { presentation.dismiss(); forward() } }, onReact = onReact, reactionStrip = reactionStrip, + ownReactions = ownReactions, onOpenPicker = { presentation.dismiss(); onOpenPicker() }, onDismiss = presentation::dismiss, zoomState = zoomState, diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt index b9417e4f5b..f8bbbce04c 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt @@ -12,6 +12,10 @@ import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.click import androidx.compose.ui.test.doubleClick @@ -290,13 +294,14 @@ class ChatPhotoMediaUiTest { onForward: (() -> Unit)? = {}, onReact: ((String) -> Unit)? = {}, reactionStrip: List = strip, + ownReactions: List = emptyList(), ) { rule.setContent { DesignSystem { ChatMediaViewer( model = null, fullImageLoaded = loaded, onFullImageLoaded = {}, onSave = onSave, onDismiss = {}, onReply = onReply, onForward = onForward, - onReact = onReact, reactionStrip = reactionStrip, + onReact = onReact, reactionStrip = reactionStrip, ownReactions = ownReactions, zoomState = zoom, reduceMotion = true, ) } @@ -388,6 +393,60 @@ class ChatPhotoMediaUiTest { rule.onNodeWithTag(VIEWER_REACT_STRIP_TAG).assertDoesNotExist() } + @Test + fun reactShowsTheAddIconWhenThereAreNoOwnReactions() { + viewer(loaded = true) + tapPhoto() + rule.onAllNodesWithTag(VIEWER_REACT_GLYPH_TAG, useUnmergedTree = true).assertCountEquals(0) + rule.onNodeWithTag(VIEWER_REACT_TAG).assert(hasContentDescription("Add reaction")) + } + + @Test + fun reactShowsOneOwnEmojiAndDescribesIt() { + viewer(loaded = true, ownReactions = listOf("\uD83D\uDD25")) + tapPhoto() + rule.onAllNodesWithTag(VIEWER_REACT_GLYPH_TAG, useUnmergedTree = true).assertCountEquals(1) + rule.onNodeWithTag(VIEWER_REACT_TAG, useUnmergedTree = true) + .assert(hasContentDescription("Add reaction, your reactions: \uD83D\uDD25", substring = true)) + } + + @Test + fun reactShowsThreeOwnEmojiWithoutAMoreLabel() { + viewer(loaded = true, ownReactions = listOf("a", "b", "c")) + tapPhoto() + rule.onAllNodesWithTag(VIEWER_REACT_GLYPH_TAG, useUnmergedTree = true).assertCountEquals(3) + rule.onAllNodesWithTag(VIEWER_REACT_MORE_TAG, useUnmergedTree = true).assertCountEquals(0) + } + + @Test + fun reactShowsTwoOwnEmojiAndAMoreLabelBeyondThree() { + viewer(loaded = true, ownReactions = listOf("a", "b", "c", "d", "e")) + tapPhoto() + rule.onAllNodesWithTag(VIEWER_REACT_GLYPH_TAG, useUnmergedTree = true).assertCountEquals(2) + rule.onNodeWithTag(VIEWER_REACT_MORE_TAG, useUnmergedTree = true).assertTextEquals("+3") + rule.onNodeWithTag(VIEWER_REACT_TAG, useUnmergedTree = true) + .assert(hasContentDescription("a b c d e", substring = true)) + } + + @Test + fun reactUpdatesWhenTheOwnReactionsChangeAfterAPick() { + val own = androidx.compose.runtime.mutableStateOf(emptyList()) + rule.setContent { + DesignSystem { + ChatMediaViewer( + model = null, fullImageLoaded = true, onFullImageLoaded = {}, + onSave = {}, onDismiss = {}, onReact = {}, reactionStrip = strip, + ownReactions = own.value, zoomState = ChatMediaZoomState(), reduceMotion = true, + ) + } + } + tapPhoto() + rule.onAllNodesWithTag(VIEWER_REACT_GLYPH_TAG, useUnmergedTree = true).assertCountEquals(0) + own.value = listOf("\uD83D\uDC4D") + rule.waitForIdle() + rule.onAllNodesWithTag(VIEWER_REACT_GLYPH_TAG, useUnmergedTree = true).assertCountEquals(1) + } + @Test fun reactIsEnabledBeforeTheImageLoads() { viewer(loaded = false) From e1672ebe624f81085cc7486ccb3fd08f65fb52d1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 20:42:02 -0400 Subject: [PATCH 5/7] fix(chat): fill the photo viewer's buttons with the quick-reaction strip's surface --- .../com/flipcash/shared/chat/ui/QuickReactionStrip.kt | 2 +- .../com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt index d322191f84..da2c9ea349 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt @@ -100,7 +100,7 @@ private val GrowSpring = spring(dampingRatio = 0.9f, stiffness = 900f) private val PopSpring = spring(dampingRatio = 0.65f, stiffness = 1200f) /** iOS's pre-glass strip surface. */ -private val StripSurface = Color(0xFF303030) +internal val StripSurface = Color(0xFF303030) /** The "+"'s frost over the emoji passing under it: a blur tinted back toward the strip. */ private val PlusGlass = HazeBlurStyle { diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt index 168873a89e..aeff8eb441 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import com.flipcash.shared.chat.reactions.ReactionStrip import com.flipcash.shared.chat.ui.QuickReactionStrip +import com.flipcash.shared.chat.ui.StripSurface import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.outlined.Download @@ -456,7 +457,7 @@ private fun ViewerButton( .padding(CodeTheme.dimens.staticGrid.x2) .size(CodeTheme.dimens.staticGrid.x8) .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.4f)) + .background(StripSurface) .testTag(tag), ) { Icon( @@ -467,13 +468,13 @@ private fun ViewerButton( } } -/** The capsule behind a group of bottom buttons: dark and translucent, like the close button. */ +/** The capsule behind a group of bottom buttons: the same solid surface as the quick-reaction strip, so it reads over black and over a bright photo. */ @Composable private fun ViewerPill(content: @Composable () -> Unit) { Row( modifier = Modifier .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.4f)), + .background(StripSurface), horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), verticalAlignment = Alignment.CenterVertically, ) { content() } From 7cf2c04a4a03caed94e89b4c6bb59497e5877e25 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 21:16:50 -0400 Subject: [PATCH 6/7] feat(chat): frost the photo viewer's chrome with haze glass over the photo --- .../shared/chat/ui/QuickReactionStrip.kt | 10 ++++++-- .../shared/chat/ui/media/ChatMediaViewer.kt | 24 ++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt index da2c9ea349..f3b3c3bea9 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt @@ -63,8 +63,10 @@ import kotlinx.coroutines.launch import com.flipcash.shared.chat.reactions.ReactionStrip import androidx.compose.ui.res.stringResource import com.flipcash.core.R +import com.getcode.ui.components.glass.floatingGlass import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.hazeBlur import dev.chrisbanes.haze.hazeSource @@ -100,7 +102,7 @@ private val GrowSpring = spring(dampingRatio = 0.9f, stiffness = 900f) private val PopSpring = spring(dampingRatio = 0.65f, stiffness = 1200f) /** iOS's pre-glass strip surface. */ -internal val StripSurface = Color(0xFF303030) +private val StripSurface = Color(0xFF303030) /** The "+"'s frost over the emoji passing under it: a blur tinted back toward the strip. */ private val PlusGlass = HazeBlurStyle { @@ -121,6 +123,9 @@ private val PlusGlass = HazeBlurStyle { * more to scroll that way, growing in over the first [EdgeFade] of travel so the fade doesn't snap on * at the first pixel. * + * With [glass] the capsule frosts what lies under it, from that [HazeState]'s source, instead of + * filling with the flat surface. + * * It enters as iOS's does: the capsule widens out of the side it hugs ([growsFromEnd] for the * trailing side), the "+" riding its growing edge, and the emoji pop in one after another from * that side. All of it animates in the draw phase, so the strip's measured size never changes — @@ -133,6 +138,7 @@ fun QuickReactionStrip( onOpenPicker: () -> Unit, modifier: Modifier = Modifier, growsFromEnd: Boolean = false, + glass: HazeState? = null, ) { if (entries.isEmpty()) return @@ -167,7 +173,7 @@ fun QuickReactionStrip( // Fully opaque a third of the way through the growth, as the iOS capsule is. alpha = (grow.value * 3f).coerceIn(0f, 1f) } - .background(StripSurface), + .then(if (glass != null) Modifier.floatingGlass(glass) else Modifier.background(StripSurface)), contentAlignment = Alignment.CenterEnd, ) { val density = LocalDensity.current diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt index aeff8eb441..cf31d92cf1 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt @@ -34,7 +34,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import com.flipcash.shared.chat.reactions.ReactionStrip import com.flipcash.shared.chat.ui.QuickReactionStrip -import com.flipcash.shared.chat.ui.StripSurface +import com.getcode.ui.components.glass.floatingGlass +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.outlined.Download @@ -183,6 +186,7 @@ fun ChatMediaViewer( ) if (onPainter != null) SideEffect { onPainter(painter) } + val hazeState = rememberHazeState() Box( modifier = modifier .fillMaxSize() @@ -203,6 +207,7 @@ fun ChatMediaViewer( Box( modifier = Modifier .fillMaxSize() + .hazeSource(hazeState) .testTag(VIEWER_IMAGE_TAG) .pointerInput(zoomState, reduceMotion) { detectTapGestures( @@ -347,6 +352,7 @@ fun ChatMediaViewer( enabled = interactive, visible = chromeVisible, tag = VIEWER_CLOSE_TAG, + hazeState = hazeState, onClick = onDismiss, modifier = chrome.align(Alignment.TopStart), ) @@ -368,6 +374,7 @@ fun ChatMediaViewer( stripOpen = false onOpenPicker() }, + glass = hazeState, modifier = chrome .align(Alignment.BottomStart) .navigationBarsPadding() @@ -387,7 +394,7 @@ fun ChatMediaViewer( .padding(horizontal = CodeTheme.dimens.staticGrid.x4, vertical = CodeTheme.dimens.staticGrid.x3), horizontalArrangement = Arrangement.SpaceBetween, ) { - ViewerPill { + ViewerPill(hazeState) { if (onReact != null) { ViewerPillButton( icon = Icons.Outlined.AddReaction, @@ -409,7 +416,7 @@ fun ChatMediaViewer( ) } } - ViewerPill { + ViewerPill(hazeState) { ViewerPillButton( icon = Icons.Outlined.Download, description = stringResource(R.string.description_chatMediaSave), @@ -446,6 +453,7 @@ private fun ViewerButton( visible: Boolean, tag: String, onClick: () -> Unit, + hazeState: HazeState, modifier: Modifier = Modifier, ) { IconButton( @@ -456,8 +464,7 @@ private fun ViewerButton( .statusBarsPadding() .padding(CodeTheme.dimens.staticGrid.x2) .size(CodeTheme.dimens.staticGrid.x8) - .clip(CircleShape) - .background(StripSurface) + .floatingGlass(hazeState) .testTag(tag), ) { Icon( @@ -468,13 +475,12 @@ private fun ViewerButton( } } -/** The capsule behind a group of bottom buttons: the same solid surface as the quick-reaction strip, so it reads over black and over a bright photo. */ +/** The capsule behind a group of bottom buttons: frosted glass over the photo, as the viewer's other chrome. */ @Composable -private fun ViewerPill(content: @Composable () -> Unit) { +private fun ViewerPill(hazeState: HazeState, content: @Composable () -> Unit) { Row( modifier = Modifier - .clip(CircleShape) - .background(StripSurface), + .floatingGlass(hazeState), horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), verticalAlignment = Alignment.CenterVertically, ) { content() } From facad4f2025d8dda1755538e065ae57ddd608276 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 25 Oct 2026 21:40:50 -0400 Subject: [PATCH 7/7] fix(chat): lay the viewer's own reactions out as separate stacked chips --- .../shared/chat/ui/media/ChatMediaViewer.kt | 90 ++++++++++++++----- 1 file changed, 68 insertions(+), 22 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt index cf31d92cf1..c46d300761 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt @@ -7,6 +7,10 @@ import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.ui.graphics.lerp +import androidx.compose.foundation.border +import androidx.compose.ui.semantics.Role +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.calculateCentroid @@ -26,6 +30,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.material3.Text import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size @@ -495,19 +500,28 @@ private fun ViewerPillButton( onClick: () -> Unit, emoji: List = emptyList(), ) { + if (emoji.isNotEmpty()) { + // Not an IconButton: that pins its content to a 40dp square, which would clip the stack. + Box( + modifier = Modifier + .height(CodeTheme.dimens.staticGrid.x10) + .widthIn(min = CodeTheme.dimens.staticGrid.x10) + .clip(CircleShape) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .semantics { contentDescription = description } + .testTag(tag), + contentAlignment = Alignment.Center, + ) { ReactionGlyphs(emoji, dimmed = !enabled) } + return + } IconButton( onClick = onClick, - // Hidden buttons fade out at full tint but stop taking taps straight away. enabled = enabled, - modifier = (if (emoji.isEmpty()) Modifier else Modifier.widthIn(min = CodeTheme.dimens.staticGrid.x10)) - .then(if (emoji.isEmpty()) Modifier.size(CodeTheme.dimens.staticGrid.x10) else Modifier.height(CodeTheme.dimens.staticGrid.x10)) + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x10) .semantics { contentDescription = description } .testTag(tag), ) { - if (emoji.isNotEmpty()) { - ReactionGlyphs(emoji, dimmed = !enabled) - return@IconButton - } Icon( imageVector = icon, contentDescription = description, @@ -516,32 +530,64 @@ private fun ViewerPillButton( } } -/** The user's own reactions drawn as text: up to three overlapping, else the first two and "+N". */ +/** + * The user's own reactions as a stack of chips: each emoji sits in its own circle, ringed in the + * glass tint's opaque equivalent so each chip is cut out of the one beneath it. Up to three show; + * more show the first two and a "+N" chip. Later chips sit on top of earlier ones. + */ @Composable private fun ReactionGlyphs(emoji: List, dimmed: Boolean) { val shown = if (emoji.size > 3) emoji.take(2) else emoji - Row( - modifier = Modifier.padding(horizontal = CodeTheme.dimens.staticGrid.x1).graphicsLayer { alpha = if (dimmed) 0.4f else 1f }, - verticalAlignment = Alignment.CenterVertically, + val count = shown.size + if (emoji.size > 3) 1 else 0 + val backdrop = CodeTheme.colors.background + val ringTint = lerp(backdrop, Color.White, 0.18f) + val chipTint = lerp(backdrop, Color.White, 0.32f) + val chipSize = 28.dp + // Each chip starts 65% of its width after the last, so neighbours overlap by 35%. + val stride = chipSize * 0.65f + Box( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.staticGrid.x2) + .width(chipSize + stride * (count - 1)) + .height(chipSize) + .graphicsLayer { alpha = if (dimmed) 0.4f else 1f }, ) { shown.forEachIndexed { index, glyph -> - Text( - text = glyph, - fontSize = 18.sp, - modifier = Modifier.then(if (index == 0) Modifier else Modifier.offset(x = (-6 * index).dp)).testTag(VIEWER_REACT_GLYPH_TAG), - ) + ReactionChip(chipSize, chipTint, ringTint, Modifier.offset(x = stride * index)) { + Text(text = glyph, fontSize = 14.sp, modifier = Modifier.testTag(VIEWER_REACT_GLYPH_TAG)) + } } if (emoji.size > 3) { - Text( - text = "+${emoji.size - 2}", - color = Color.White, - fontSize = 13.sp, - modifier = Modifier.offset(x = (-12).dp).testTag(VIEWER_REACT_MORE_TAG), - ) + ReactionChip(chipSize, chipTint, ringTint, Modifier.offset(x = stride * shown.size)) { + Text( + text = "+${emoji.size - 2}", + color = Color.White, + fontSize = 12.sp, + modifier = Modifier.testTag(VIEWER_REACT_MORE_TAG), + ) + } } } } +@Composable +private fun ReactionChip( + size: androidx.compose.ui.unit.Dp, + fill: Color, + ring: Color, + modifier: Modifier, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(fill) + .border(2.dp, ring, CircleShape), + contentAlignment = Alignment.Center, + ) { content() } +} + @Composable private fun reactDescription(own: List): String { val base = stringResource(R.string.action_addReaction)