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/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/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/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index dd1c7fdfc4..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 @@ -1586,8 +1587,9 @@ Price curve for %1$s with %2$s purchased Close - Share - Share photo + 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 deleted file mode 100644 index 2d76f833ad..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.rememberSharePhoto -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 share = rememberSharePhoto(photo) - - Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { - ChatMediaViewer( - model = photo, - fullImageLoaded = fullImageLoaded, - onFullImageLoaded = { fullImageLoaded = true }, - onShare = share, - 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 4837752a0f..0000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatPhotoViewerViewModel.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.flipcash.app.messenger.internal - -import androidx.lifecycle.ViewModel -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, -) : ViewModel() { - - private val _photo = MutableStateFlow(null) - val photo: StateFlow = _photo - - 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 b0b7c8a48b..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 @@ -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 @@ -2660,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 } @@ -2755,6 +2766,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 +4077,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, @@ -4180,20 +4201,49 @@ 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)) + +/** 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 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. + */ +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 c4ce1979ee..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,9 @@ 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 import com.flipcash.app.messenger.internal.link.CashCardTap import com.flipcash.app.messenger.internal.screens.components.ChatTopBar @@ -73,7 +76,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 +134,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 +184,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 +573,35 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } - openPhotoId?.let { id -> + 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 = 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. + // 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), + 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) { + { 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/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..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 @@ -150,4 +150,48 @@ 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))) + } + + @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/QuickReactionStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt index d322191f84..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 @@ -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 b7382d7a8b..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 @@ -1,29 +1,52 @@ 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 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 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.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.width +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 import androidx.compose.material.icons.Icons +import com.flipcash.shared.chat.reactions.ReactionStrip +import com.flipcash.shared.chat.ui.QuickReactionStrip +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.outlined.IosShare +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.AddReaction import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -61,17 +84,22 @@ 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_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" +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 +108,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 (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. * * On its own the pull moves the image down, fading the black behind it, and past a fifth of the @@ -91,12 +120,20 @@ 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. 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 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 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. + * @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. * @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 +145,15 @@ fun ChatMediaViewer( model: Any?, fullImageLoaded: Boolean, onFullImageLoaded: () -> Unit, - onShare: () -> Unit, + onSave: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, + onReact: ((emoji: String) -> Unit)? = null, + reactionStrip: List = emptyList(), + ownReactions: List = emptyList(), + onOpenPicker: () -> Unit = {}, + onReply: (() -> Unit)? = null, + onForward: (() -> Unit)? = null, placeholder: Painter? = null, zoomState: ChatMediaZoomState = remember { ChatMediaZoomState() }, reduceMotion: Boolean = rememberReducedMotion(), @@ -128,7 +171,9 @@ 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) } + 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), @@ -146,6 +191,7 @@ fun ChatMediaViewer( ) if (onPainter != null) SideEffect { onPainter(painter) } + val hazeState = rememberHazeState() Box( modifier = modifier .fillMaxSize() @@ -166,6 +212,7 @@ fun ChatMediaViewer( Box( modifier = Modifier .fillMaxSize() + .hazeSource(hazeState) .testTag(VIEWER_IMAGE_TAG) .pointerInput(zoomState, reduceMotion) { detectTapGestures( @@ -310,18 +357,89 @@ fun ChatMediaViewer( enabled = interactive, visible = chromeVisible, tag = VIEWER_CLOSE_TAG, + hazeState = hazeState, 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), - ) + 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() + }, + glass = hazeState, + 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) + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.staticGrid.x4, vertical = CodeTheme.dimens.staticGrid.x3), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + ViewerPill(hazeState) { + if (onReact != null) { + ViewerPillButton( + icon = Icons.Outlined.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, + onClick = { stripOpen = !stripOpen }, + ) + } + 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(hazeState) { + 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() } @@ -340,6 +458,7 @@ private fun ViewerButton( visible: Boolean, tag: String, onClick: () -> Unit, + hazeState: HazeState, modifier: Modifier = Modifier, ) { IconButton( @@ -350,8 +469,7 @@ private fun ViewerButton( .statusBarsPadding() .padding(CodeTheme.dimens.staticGrid.x2) .size(CodeTheme.dimens.staticGrid.x8) - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.4f)) + .floatingGlass(hazeState) .testTag(tag), ) { Icon( @@ -362,27 +480,116 @@ 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: frosted glass over the photo, as the viewer's other chrome. */ +@Composable +private fun ViewerPill(hazeState: HazeState, content: @Composable () -> Unit) { + Row( + modifier = Modifier + .floatingGlass(hazeState), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), + verticalAlignment = Alignment.CenterVertically, + ) { content() } +} + +@Composable +private fun ViewerPillButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + enabled: Boolean, + tag: String, + 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, + enabled = enabled, + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x10) + .semantics { contentDescription = description } + .testTag(tag), + ) { + Icon( + imageVector = icon, + contentDescription = description, + tint = Color.White.copy(alpha = if (enabled) 1f else 0.4f), + ) + } +} /** - * 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. + * 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. */ -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 ReactionGlyphs(emoji: List, dimmed: Boolean) { + val shown = if (emoji.size > 3) emoji.take(2) else emoji + 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 -> + 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) { + 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), + ) + } + } } - context.startActivity( - Intent.createChooser(send, context.getString(R.string.title_chatMediaShare)) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) -}.isSuccess +} + +@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) + 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 c1a4fdd108..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 @@ -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 @@ -151,6 +152,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 +162,14 @@ fun ChatPhotoOverlay( messageId: Long, sources: ChatPhotoSources, onClosed: () -> Unit, + onSaveResult: (saved: Boolean) -> Unit, modifier: Modifier = Modifier, + onReact: ((emoji: String) -> Unit)? = null, + reactionStrip: List = emptyList(), + ownReactions: List = emptyList(), + onOpenPicker: () -> Unit = {}, + onReply: (() -> Unit)? = null, + onForward: (() -> Unit)? = null, reduceMotion: Boolean = rememberReducedMotion(), ) { val source = remember(messageId) { sources.sourceOf(messageId) } @@ -214,7 +225,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 +237,15 @@ 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() } }, + // Forward and the picker, like Reply, leave the photo before their screen opens. + onForward = onForward?.let { forward -> { presentation.dismiss(); forward() } }, + onReact = onReact, + reactionStrip = reactionStrip, + ownReactions = ownReactions, + onOpenPicker = { presentation.dismiss(); onOpenPicker() }, 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..bdc7d6849a --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoSave.kt @@ -0,0 +1,130 @@ +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 com.flipcash.shared.chat.media.ChatPhoto +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. 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. + */ +@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 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 + } catch (_: Throwable) { + false + } + currentOnResult(saved) + } + } +} + +/** 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. + */ +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..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 @@ -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 @@ -10,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 @@ -280,44 +286,219 @@ class ChatPhotoMediaUiTest { // region viewer - @Test - fun shareIsDisabledUntilTheFullImageLoads() { + private fun viewer( + loaded: Boolean, + zoom: ChatMediaZoomState = ChatMediaZoomState(), + onSave: () -> Unit = {}, + onReply: (() -> Unit)? = {}, + onForward: (() -> Unit)? = {}, + onReact: ((String) -> Unit)? = {}, + reactionStrip: List = strip, + ownReactions: List = emptyList(), + ) { rule.setContent { DesignSystem { ChatMediaViewer( - model = null, fullImageLoaded = false, onFullImageLoaded = {}, - onShare = {}, onDismiss = {}, + model = null, fullImageLoaded = loaded, onFullImageLoaded = {}, + onSave = onSave, onDismiss = {}, onReply = onReply, onForward = onForward, + onReact = onReact, reactionStrip = reactionStrip, ownReactions = ownReactions, + zoomState = zoom, reduceMotion = true, ) } } - rule.onNodeWithTag(VIEWER_SHARE_TAG).assertIsNotEnabled() + } + + 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_REACT_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 saveIsDisabledUntilTheFullImageLoads() { + viewer(loaded = false) + tapPhoto() + rule.onNodeWithTag(VIEWER_SAVE_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 saveIsEnabledOnceLoaded() { + viewer(loaded = true) + tapPhoto() + rule.onNodeWithTag(VIEWER_SAVE_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_SAVE_TAG).assertExists() + } + + @Test + fun reactIsHiddenWithoutItsHandler() { + viewer(loaded = true, onReact = null) + tapPhoto() + 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 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 shareIsEnabledOnceLoaded() { + fun reactUpdatesWhenTheOwnReactionsChangeAfterAPick() { + val own = androidx.compose.runtime.mutableStateOf(emptyList()) rule.setContent { DesignSystem { ChatMediaViewer( model = null, fullImageLoaded = true, onFullImageLoaded = {}, - onShare = {}, onDismiss = {}, + onSave = {}, onDismiss = {}, onReact = {}, reactionStrip = strip, + ownReactions = own.value, zoomState = ChatMediaZoomState(), reduceMotion = true, ) } } - rule.onNodeWithTag(VIEWER_SHARE_TAG).assertIsEnabled() + 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) + 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 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 +509,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 } 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))) + } +}