From 581ae857c14873b019af48f10e8322b6b410aebe Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 17:54:23 -0400 Subject: [PATCH] fix(profile): drop Open Chat when the profile was opened from that chat Back already returns to the chat, so the pinned Open Chat button only repeats it. Group profile opened from its chat: no pinned bar. Leave Chat moves to the end of the scroll and holds to the bottom when the page is short. Whether the chat is underneath is read once, because back pops the stack before the screen animates out and a live read brought the bar back for the exit. A group profile opened with nothing underneath keeps Open Chat and Leave Chat pinned. User profile: Open Chat is hidden only when the DM with that person is the chat underneath, so a member's portrait in a group still offers it. The encryption note now always sits in the scroll and holds to the bottom of a short page. With nothing pinned, the bottom edge gets the same progressive blur as the top (BottomScrollEdge). --- .../flipcash/app/messenger/ChatFlowScreen.kt | 9 ++- .../screens/profile/ChatProfileScreen.kt | 43 ++++++++--- .../screens/profile/GroupProfileScreen.kt | 73 +++++++++++++++---- .../screens/profile/ProfilePinnedAction.kt | 8 ++ .../screens/profile/ProfileActionsTest.kt | 28 +++++++ .../getcode/ui/components/BottomScrollEdge.kt | 59 +++++++++++++++ 6 files changed, 192 insertions(+), 28 deletions(-) create mode 100644 ui/components/src/main/kotlin/com/getcode/ui/components/BottomScrollEdge.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index ee5639881..4c53fa8b6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -272,11 +272,16 @@ private fun FlowChatProfileScreen(participant: ChatParticipant, origin: ProfileO viewModel.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant)) } + val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() + // Only a profile opened from the chat itself has that chat underneath; a member's portrait in + // a group is ProfileOrigin.Chat too, but its DM is not the group. + val chatUnderneath = chatState.chatId.takeIf { origin == ProfileOrigin.Chat } + ChatProfileScreen( viewModel = viewModel, + chatUnderneath = chatUnderneath, onOpenChat = { chatId -> - val chatState = chatViewModel.stateFlow.value - if (origin == ProfileOrigin.Chat && chatState.chatId == chatId) { + if (chatUnderneath != null && chatUnderneath == chatId) { // The chat is the one underneath: back to it rather than a second copy. flowNavigator.back() } else { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index e71527e44..f560ba7cb 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -7,9 +7,12 @@ import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.rememberScrollState @@ -57,6 +60,7 @@ import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.BottomScrollEdge import com.getcode.ui.components.CircularIconButton import com.getcode.ui.theme.CodeScaffold import dev.chrisbanes.haze.hazeSource @@ -73,6 +77,7 @@ import com.flipcash.app.core.chat.ChatStep @Composable internal fun ChatProfileScreen( viewModel: ChatProfileViewModel, + chatUnderneath: ChatId?, onOpenChat: (ChatId) -> Unit, ) { val flowNavigator = rememberFlowNavigator() @@ -80,6 +85,7 @@ internal fun ChatProfileScreen( viewModel = viewModel, onBack = { flowNavigator.back() }, onOpenChat = onOpenChat, + chatUnderneath = chatUnderneath, ) } @@ -93,6 +99,8 @@ internal fun PersonProfileScreen( viewModel: ChatProfileViewModel, onBack: () -> Unit, onOpenChat: (ChatId) -> Unit, + /** The chat this profile was opened from, when it is the DM with this person; null otherwise. */ + chatUnderneath: ChatId? = null, ) { val navigator = LocalCodeNavigator.current val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -121,7 +129,8 @@ internal fun PersonProfileScreen( val person = state.participant as? ChatParticipant.TipUser val isSelf = person != null && person.userId == state.selfId - val pinned = if (isSelf) null else state.pinnedAction + val pinned = state.pinnedAction + ?.takeUnless { isSelf || it.opensChatUnderneath(state.dmChatId, chatUnderneath) } var menuOpen by remember { mutableStateOf(false) } var pinnedHeight by remember { mutableStateOf(0.dp) } @@ -132,7 +141,7 @@ internal fun PersonProfileScreen( val hazeState = rememberHazeState() CodeScaffold { padding -> - Box( + BoxWithConstraints( modifier = Modifier .fillMaxSize() .padding(padding) @@ -145,6 +154,8 @@ internal fun PersonProfileScreen( // The whole page, so the pinned bar and the top edge blur whatever scrolls under them. .hazeSource(hazeState) .verticalScroll(scrollState) + // At least a screen tall, so a short page can hold the encryption note to the bottom. + .heightIn(min = maxHeight) .padding(bottom = clearance), ) { ProfileHeader( @@ -236,9 +247,28 @@ internal fun PersonProfileScreen( ) }, ) + // The encryption note closes the page, under the pinned button rather than on it. + if (state.dmExists && state.isEncrypted) { + // With nothing pinned, the note takes the bottom of a short page. + if (pinned == null) Spacer(modifier = Modifier.weight(1f)) + E2eeFooter( + modifier = Modifier.padding(top = CodeTheme.dimens.staticGrid.x4), + isEncrypted = true, + onLearnMore = { navigator.push(AppRoute.Messaging.E2eeDmInfo) }, + // The pinned button's clearance already covers the system bar. + clearNavigationBar = pinned == null, + ) + } } ProfileTopScrollEdge(hazeState = hazeState, scrollState = scrollState) + if (pinned == null) { + BottomScrollEdge( + modifier = Modifier.align(Alignment.BottomCenter), + hazeState = hazeState, + scrollState = scrollState, + ) + } AppBarWithTitle( onBackIconClicked = onBack, @@ -290,15 +320,6 @@ internal fun PersonProfileScreen( text = pinned.label(), isLoading = pinned == ProfilePinnedAction.OpeningChat, onClick = { viewModel.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) }, - above = if (state.dmExists && state.isEncrypted) { - { - E2eeFooter( - isEncrypted = true, - onLearnMore = { navigator.push(AppRoute.Messaging.E2eeDmInfo) }, - clearNavigationBar = false, - ) - } - } else null, hazeState = hazeState, onHeightChanged = { pinnedHeight = it }, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index b0265090e..86dc2278e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -9,11 +9,16 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -75,7 +80,10 @@ import com.getcode.solana.keys.Mint import com.getcode.theme.CodeTheme import com.getcode.theme.extraLarge import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.BottomScrollEdge import com.getcode.ui.components.CircularIconButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState @@ -117,9 +125,12 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // The chat is usually the screen underneath. A flow opened on the profile // (AppRoute.Messaging.Chat.openOnProfile) has nothing underneath, so the transcript is pushed on - // top and back from it returns here, as iOS does. + // top and back from it returns here, as iOS does. Read once: back pops the stack before this + // screen finishes animating out, and a live read would bring the pinned bar back for the exit. + val openedFromChat = remember { flowNavigator.canGoBack } + fun openChat() { - if (flowNavigator.canGoBack) { + if (openedFromChat) { flowNavigator.back() } else { flowNavigator.navigateTo(ChatStep.Conversation) @@ -145,11 +156,13 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { var menuOpen by remember { mutableStateOf(false) } var pinnedHeight by remember { mutableStateOf(0.dp) } - val clearance = pinnedHeight + // Opened from the chat, back already returns to it, so nothing is pinned: Leave Chat sits at + // the foot of the page instead. + val clearance = if (openedFromChat) 0.dp else pinnedHeight val hazeState = rememberHazeState() CodeScaffold { padding -> - Box( + BoxWithConstraints( modifier = Modifier .fillMaxSize() .padding(padding) @@ -162,6 +175,8 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // The whole page, so the pinned bar and the top edge blur whatever scrolls under them. .hazeSource(hazeState) .verticalScroll(scrollState) + // At least a screen tall, so a short page can hold Leave Chat to the bottom. + .heightIn(min = maxHeight) .padding(bottom = clearance), ) { ProfileHeader( @@ -291,10 +306,36 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { }, ) } - Box(modifier = Modifier.padding(bottom = CodeTheme.dimens.staticGrid.x6)) + if (openedFromChat && isMember) { + // Takes up whatever the page leaves over; nothing once it scrolls. + Spacer(modifier = Modifier.weight(1f)) + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x6), + buttonState = ButtonState.Subtle, + text = stringResource(R.string.action_leaveChat), + isLoading = state.leaving, + enabled = !state.leaving, + onClick = { viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat) }, + ) + } + Box( + modifier = Modifier + .then(if (openedFromChat) Modifier.navigationBarsPadding() else Modifier) + .padding(bottom = CodeTheme.dimens.staticGrid.x6) + ) } ProfileTopScrollEdge(hazeState = hazeState, scrollState = scrollState) + if (openedFromChat) { + BottomScrollEdge( + modifier = Modifier.align(Alignment.BottomCenter), + hazeState = hazeState, + scrollState = scrollState, + ) + } AppBarWithTitle( onBackIconClicked = { flowNavigator.back() }, @@ -337,16 +378,18 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // Open Chat in every standing, as in the design: a viewer short of a minimum is told // so, and buys, on the chat's own gate. - ProfilePinnedActionBar( - modifier = Modifier.align(Alignment.BottomCenter), - text = stringResource(R.string.action_openChat), - onClick = { openChat() }, - secondaryText = if (isMember) stringResource(R.string.action_leaveChat) else null, - onSecondaryClick = { viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat) }, - isSecondaryLoading = state.leaving, - hazeState = hazeState, - onHeightChanged = { pinnedHeight = it }, - ) + if (!openedFromChat) { + ProfilePinnedActionBar( + modifier = Modifier.align(Alignment.BottomCenter), + text = stringResource(R.string.action_openChat), + onClick = { openChat() }, + secondaryText = if (isMember) stringResource(R.string.action_leaveChat) else null, + onSecondaryClick = { viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat) }, + isSecondaryLoading = state.leaving, + hazeState = hazeState, + onHeightChanged = { pinnedHeight = it }, + ) + } } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt index 9aae82644..5312a08f9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfilePinnedAction.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.screens.profile import androidx.annotation.StringRes import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId import com.getcode.opencode.model.financial.Fiat /** The one primary action pinned to the bottom of another user's profile. */ @@ -35,6 +36,13 @@ internal fun resolvePinnedAction( else -> fee?.let(ProfilePinnedAction::StartChatting) } +/** + * Whether the pinned button is left off because it would only open [chatUnderneath], the chat + * this profile was opened from: back already returns there. + */ +internal fun ProfilePinnedAction.opensChatUnderneath(dmChatId: ChatId?, chatUnderneath: ChatId?): Boolean = + this == ProfilePinnedAction.OpenChat && chatUnderneath != null && dmChatId == chatUnderneath + /** * The rows under another user's profile, in order: the reversible and routine first, the one that * ends the conversation last. Muting is the DM's, so it only appears once one exists. diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt index 1c79b1f80..10f318394 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileActionsTest.kt @@ -1,6 +1,7 @@ package com.flipcash.app.messenger.internal.screens.profile import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId import com.getcode.opencode.model.financial.Fiat import org.junit.Test import kotlin.test.assertEquals @@ -102,6 +103,33 @@ class ProfileActionsTest { assertFalse(ChatProfileAction.Unblock.isDestructive) } + // -- opensChatUnderneath -- + + private val dm = ChatId(List(16) { 1.toByte() }) + private val group = ChatId(List(16) { 2.toByte() }) + + @Test + fun `open chat is left off when the DM is the chat underneath`() { + assertTrue(ProfilePinnedAction.OpenChat.opensChatUnderneath(dmChatId = dm, chatUnderneath = dm)) + } + + @Test + fun `open chat stays when the chat underneath is another chat`() { + assertFalse(ProfilePinnedAction.OpenChat.opensChatUnderneath(dmChatId = dm, chatUnderneath = group)) + } + + @Test + fun `open chat stays with no chat underneath`() { + assertFalse(ProfilePinnedAction.OpenChat.opensChatUnderneath(dmChatId = dm, chatUnderneath = null)) + assertFalse(ProfilePinnedAction.OpenChat.opensChatUnderneath(dmChatId = null, chatUnderneath = null)) + } + + @Test + fun `only open chat is ever left off`() { + assertFalse(ProfilePinnedAction.Unblock.opensChatUnderneath(dmChatId = dm, chatUnderneath = dm)) + assertFalse(ProfilePinnedAction.OpeningChat.opensChatUnderneath(dmChatId = dm, chatUnderneath = dm)) + } + // -- labelRes -- @Test diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/BottomScrollEdge.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/BottomScrollEdge.kt new file mode 100644 index 000000000..846ff5e31 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/BottomScrollEdge.kt @@ -0,0 +1,59 @@ +package com.getcode.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.hazeBlur +import dev.chrisbanes.haze.blur.materials.HazeMaterials + +/** + * [TopScrollEdge] turned over, for a page with nothing pinned to its foot: content blurs out toward + * the bottom of the screen as it scrolls under the navigation bar. + * + * Place it over the scroll content, aligned to the bottom, reading the same [hazeState] the content + * is a source for. It shows while [scrollState] has more to scroll to, so the page's last line reads + * clean once it is reached. [height] defaults to the navigation bar plus [fade]. + */ +@Composable +fun BottomScrollEdge( + hazeState: HazeState, + scrollState: ScrollState, + modifier: Modifier = Modifier, + fade: Dp = 40.dp, + height: Dp = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + fade, +) { + val visible by remember(scrollState) { derivedStateOf { scrollState.canScrollForward } } + val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) + + AnimatedVisibility(visible = visible, modifier = modifier, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(height) + .hazeBlur( + HazeInput.Sources(hazeState), + material.then { + progressive(HazeProgressive.verticalGradient(startIntensity = 0f, endIntensity = 1f)) + }, + ), + ) + } +}