Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import com.flipcash.app.tipping.NewGroupFlowScreen
import com.flipcash.app.tipping.ChatsScreen
import com.flipcash.shared.transactionhistory.ActivityHistoryScreen
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.chat.ProfileAddress
import com.flipcash.app.core.chat.ProfileOrigin
import com.flipcash.app.core.navigation.DeeplinkAction
import com.flipcash.app.currency.RegionSelectionScreen
import com.flipcash.app.deposit.DepositFlowScreen
Expand Down Expand Up @@ -121,9 +124,16 @@ fun appEntryProvider(

// Messaging
annotatedEntry<AppRoute.Messaging.Chat> { key ->
ChatFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
// A chat is never opened by user id any more; the profile is where a person without a DM
// is reached. A saved route that still carries one lands there instead of on a gated chat.
val identifier = key.identifier
if (identifier is ChatIdentifier.ByUser) {
ProfileScreen(ProfileAddress.ById(identifier.userId), ProfileOrigin.Link)
} else {
ChatFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
}
annotatedEntry<AppRoute.Messaging.Profile> { key -> ProfileScreen(key.address) }
annotatedEntry<AppRoute.Messaging.Profile> { key -> ProfileScreen(key.address, key.origin) }
annotatedEntry<AppRoute.Messaging.NewChat> { NewChatScreen() }
annotatedEntry<AppRoute.Messaging.ArchivedChats> { ArchivedChatsScreen() }
annotatedEntry<AppRoute.Messaging.FindByUsername> { FindByUsernameScreen() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.navigation3.runtime.NavKey
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.chat.ChatStep
import com.flipcash.app.core.chat.ProfileAddress
import com.flipcash.app.core.chat.ProfileOrigin
import com.flipcash.app.core.chat.ReportSubject
import com.flipcash.app.core.reporting.ReportStep
import com.flipcash.app.core.chat.NewGroupStep
Expand Down Expand Up @@ -359,9 +360,6 @@ sealed interface AppRoute : NavKey, Parcelable {
// Open straight into composing a reply with the keyboard up. Only the post-tip
// hand-off (see TipCardDecorator) sets this; normal opens default to keyboard-closed.
val openKeyboard: Boolean = false,
// Open with send cash already started: the fee sheet if this payment opens the DM, the
// keypad if the DM exists. Only a profile's "Send Cash" shortcut sets this.
val openSendCash: Boolean = false,
) : Messaging, FlowRoute {
override val initialStack: List<NavKey>
get() = listOf(ChatStep.Conversation)
Expand Down Expand Up @@ -400,7 +398,10 @@ sealed interface AppRoute : NavKey, Parcelable {
*/
@Serializable
@Parcelize
data class Profile(val address: ProfileAddress) : Messaging
data class Profile(
val address: ProfileAddress,
val origin: ProfileOrigin = ProfileOrigin.Link,
) : Messaging

/** Node 9442:5825 — starting a chat by typing someone's `@handle`. */
@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,10 @@ sealed interface ChatIdentifier : Parcelable {
}

/**
* A tip DM addressed by the counterparty's Flipcash user id — the only identifier that can open
* a conversation which does not exist yet.
*
* [ByChatId] and [ByContact] both name a chat the server already has: one by its id, one by a
* phone number the server pre-derived an id for. Reaching someone by their `@handle` has
* neither, so this carries the user id, which is what the canonical TIP_DM id is derived
* from (`ChatCoordinator.generateChatId`) — deterministic and offline, so the chat opens on the
* derived id and the first tip lands in it.
*
* [profile] rides along because the caller looked it up to get [userId] in the first place: the
* header card renders from it on the first frame rather than waiting on a members fetch, which
* for a chat with no messages would have nothing to return.
* Retired: a tip DM addressed by the counterparty's Flipcash user id, which opened a gated chat
* before it existed. Nothing builds one now; a person without a DM is reached through their
* profile. It stays only so a route saved by an older build still decodes, and the app opens the
* profile for it instead of a chat.
*/
@Serializable
@Parcelize
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,6 @@ sealed interface ChatStep : FlowStep, Parcelable {
@Serializable
data object AmountEntry : ChatStep, NavigationRetVal<ChatSendResult>, Sheet

/**
* The payment that opens a tip DM, at the fee the recipient charges.
*
* Separate from [AmountEntry] because there is nothing to enter: the fee is the price of the
* conversation, so the sheet states it and asks only for the swipe. A [WrapContentSheet] for
* the same reason — three lines of content shouldn't claim the screen.
*/
@Parcelize
@Serializable
data object InitPayment :
ChatStep, NavigationRetVal<ChatSendResult>, Sheet, WrapContentSheet

/**
* Nodes 10329:12104 and 10330:19549 — share or copy a group's invite link, or send it to
* recent 1:1 chats.
Expand Down Expand Up @@ -157,4 +145,20 @@ enum class ProfileOrigin {
* somewhere new to go. iOS's `UserProfileOrigin.mention`.
*/
Mention,

/** A `flipcash.com/...` link. The default for `AppRoute.Messaging.Profile`. Blocking pops back. */
Link,

/** A scanned profile card. Blocking resets to the chat list, which the scan came from. */
Scan,

/** A username search result. Blocking resets to the chat list, like a scan. */
UsernameLookup,

/** A transaction's counterparty. Blocking pops back to the transaction. */
Transaction;

/** Whether blocking from here leaves the stack to the chat list rather than popping one entry. */
val resetsToChatsAfterBlock: Boolean
get() = this == Scan || this == UsernameLookup
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.flipcash.app.core.chat

import com.flipcash.app.core.AppRoute
import com.flipcash.services.models.chat.ChatId
import com.getcode.opencode.model.core.ID

/**
* Where "open a DM with [userId]" lands: the chat once it exists, else the profile.
*
* [dmChatId] is the DM's id only when it is openable, which is the caller's call to make: it has
* members and the person is not blocked. Null sends the viewer to the profile, tagged with
* [origin] so the profile knows how to leave after a block.
*/
fun dmDestination(userId: ID, dmChatId: ChatId?, origin: ProfileOrigin): AppRoute =
if (dmChatId != null) {
AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(dmChatId))
} else {
AppRoute.Messaging.Profile(ProfileAddress.ById(userId), origin)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ sealed interface DeeplinkType: Parcelable {

@Serializable data class TokenInfo(val mint: Mint): DeeplinkType, Navigatable

@Serializable data class TipChat(val identifier: ChatIdentifier): DeeplinkType, Navigatable
@Serializable data class TipChat(val chatId: ChatId): DeeplinkType, Navigatable

/**
* A group chat invite — `app.flipcash.com/chat/{uuid}`.
Expand Down
6 changes: 0 additions & 6 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1261,12 +1261,8 @@
<string name="title_shareProfile">Chat with %1$s on Flipcash</string>
<string name="subtitle_myTipCard">Share Your Tip Card to Get Tipped</string>
<string name="action_showTipCard">Show My Tip Card</string>
<string name="title_sendTip">Send a Tip</string>
<string name="action_sendToStartChatting">Send %1$s to Start Chatting</string>
<string name="action_swipeToTip">Swipe to Tip</string>
<string name="subtitle_tipHintMinimum">%1$s minimum</string>
<string name="error_title_tipMinimum">%1$s Minimum Tip</string>
<string name="error_description_tipMinimum">Please enter a higher amount</string>
<string name="label_viaTipCard">via Tip Card</string>

<!-- Minimum-to-chat entry (nodes 9541:10951, 9553:113170, 10074:18892). Backed by the
Expand All @@ -1287,8 +1283,6 @@
<string name="action_unblock">Unblock</string>
<!-- The chip under a blocked person's handle on their profile. -->
<string name="label_blocked">Blocked</string>
<!-- The pinned button on another person's profile when no start-chatting fee is known. -->
<string name="action_startChatting">Start Chatting</string>
<string name="action_openChat">Open Chat</string>
<!-- The top bar's overflow button on another person's profile. -->
<string name="action_moreProfileActions">More</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.flipcash.app.core.chat

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class ProfileOriginTest {

@Test
fun `a scan and a username lookup reset to the chat list after a block`() {
assertTrue(ProfileOrigin.Scan.resetsToChatsAfterBlock)
assertTrue(ProfileOrigin.UsernameLookup.resetsToChatsAfterBlock)
}

@Test
fun `every other origin pops one entry after a block`() {
val pops = ProfileOrigin.entries - setOf(ProfileOrigin.Scan, ProfileOrigin.UsernameLookup)

assertEquals(
setOf(ProfileOrigin.Chat, ProfileOrigin.Mention, ProfileOrigin.Link, ProfileOrigin.Transaction),
pops.toSet(),
)
pops.forEach { assertFalse(it.resetsToChatsAfterBlock, "$it") }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class DeeplinkScanPolicyTest {
fun `only payment routes are scannable`() {
assertTrue(DeeplinkType.CashLink(ENTROPY).isScannable)
assertTrue(DeeplinkType.TokenInfo(MINT).isScannable)
assertTrue(DeeplinkType.TipChat(ChatIdentifier.ByChatId(CHAT_ID)).isScannable)
assertTrue(DeeplinkType.TipChat(CHAT_ID).isScannable)
assertTrue(DeeplinkType.Tipcard(USER_ID).isScannable)
assertTrue(DeeplinkType.Profile(ProfileAddress.ByUsername(USERNAME)).isScannable)
assertTrue(DeeplinkType.GroupChatInvite(CHAT_ID).isScannable)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ 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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
Expand All @@ -25,15 +24,13 @@ import com.flipcash.app.core.extensions.openAsSheet
import com.flipcash.app.messenger.internal.ChatSubject
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.GroupInviteViewModel
import com.flipcash.app.messenger.internal.StartSendCashOnceReady
import com.flipcash.app.messenger.internal.screens.GroupInviteSheet
import com.flipcash.app.messenger.internal.screens.MessengerScreen
import com.flipcash.app.messenger.internal.screens.EmojiPickerViewModel
import com.flipcash.app.messenger.internal.screens.ReactionPickerSheet
import com.flipcash.app.messenger.internal.screens.ReactorsSheet
import com.flipcash.app.messenger.internal.screens.ReactorsViewModel
import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent
import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet
import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen
import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel
import com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen
Expand All @@ -55,10 +52,8 @@ import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher
import com.getcode.navigation.scenes.LocalSheetNavigator
import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy
import com.getcode.ui.utils.rememberKeyboardController
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach

@Composable
Expand All @@ -77,8 +72,8 @@ fun ChatFlowScreen(
// onRootReached, and so does system back — so this is the one place that has to do it.
// Popping with the IME still up drags the screen behind it out from under the keyboard.
onExit = { _, _ -> keyboard.hideIfVisible { navigator.pop() } },
entryProvider = chatEntryProvider(route.identifier, route.openKeyboard, route.openSendCash),
// ChatStep.AmountEntry, ChatStep.InitPayment and ChatStep.InviteToGroup are Sheets, so the
entryProvider = chatEntryProvider(route.identifier, route.openKeyboard),
// ChatStep.AmountEntry and ChatStep.InviteToGroup are Sheets, so the
// flow needs the sheet strategy to draw them as such; without it the step would fall
// through to SinglePane and cover the thread. Amount entry
// returns its result inside the flow (resultBackNavigator), so the strategy's own
Expand All @@ -95,17 +90,13 @@ fun ChatFlowScreen(
private fun chatEntryProvider(
identifier: ChatIdentifier,
openKeyboard: Boolean,
openSendCash: Boolean,
): (NavKey) -> NavEntry<NavKey> = entryProvider {
annotatedEntry<ChatStep.Conversation> {
FlowConversationScreen(identifier, openKeyboard, openSendCash)
FlowConversationScreen(identifier, openKeyboard)
}
annotatedEntry<ChatStep.AmountEntry> {
FlowAmountEntryScreen()
}
annotatedEntry<ChatStep.InitPayment> {
FlowInitPaymentScreen()
}
annotatedEntry<ChatStep.InviteToGroup> {
FlowGroupInviteSheet()
}
Expand Down Expand Up @@ -138,7 +129,6 @@ private fun chatEntryProvider(
private fun FlowConversationScreen(
identifier: ChatIdentifier,
openKeyboard: Boolean,
openSendCash: Boolean,
) {
val viewModel = flowSharedViewModel<ChatViewModel>()
val navigator = LocalCodeNavigator.current
Expand Down Expand Up @@ -177,18 +167,6 @@ private fun FlowConversationScreen(
}
}

LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance<ChatViewModel.Event.NavigateToInitPayment>()
.collect {
navigator.navigateForResult<ChatSendResult>(ChatStep.InitPayment) { result ->
if (result is NavResultOrCanceled.ReturnValue) {
viewModel.dispatchEvent(ChatViewModel.Event.OnStartMessageInput)
}
}
}
}

LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance<ChatViewModel.Event.OpenScreen>()
Expand All @@ -209,16 +187,6 @@ private fun FlowConversationScreen(
}
}

// After the collectors above, so the step OnSendCash navigates to has someone listening.
// Not keyed on first composition the way openKeyboard is: the handler drops the event until
// the participant is set, and picks the wrong step until the fee is known.
val sendCashReady by remember(viewModel) {
viewModel.stateFlow.map { it.sendCashReady }.distinctUntilChanged()
}.collectAsStateWithLifecycle(initialValue = false)
StartSendCashOnceReady(requested = openSendCash, ready = sendCashReady) {
viewModel.dispatchEvent(ChatViewModel.Event.OnSendCash)
}

MessengerScreen(viewModel)
}

Expand Down Expand Up @@ -247,27 +215,6 @@ private fun FlowAmountEntryScreen() {
)
}

@Composable
private fun FlowInitPaymentScreen() {
val viewModel = flowSharedViewModel<ChatViewModel>()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
// Same dismissal rule as amount entry: exit through the sheet so it animates down rather than
// having its scene deleted mid-frame.
val dismissSheet = LocalBottomSheetDismissDispatcher.current
val resultBack = resultBackNavigator<ChatSendResult>(exit = dismissSheet)

ChatInitPaymentSheet(
fee = state.chatInitFee,
token = state.token,
sendProgress = state.sendProgress,
onConfirm = { viewModel.dispatchEvent(ChatViewModel.Event.OnInitPaymentConfirmed) },
onSendComplete = { resultBack.returnValue(ChatSendResult) },
sendComplete = remember(viewModel) {
viewModel.eventFlow.filterIsInstance<ChatViewModel.Event.SendComplete>().map { }
},
)
}

/**
* The invite sheet, on the conversation's own view model.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.chat.ProfileAddress
import com.flipcash.app.core.chat.ProfileOrigin
import com.flipcash.app.core.extensions.navigateAll
import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel
import com.flipcash.app.messenger.internal.screens.profile.PersonProfileScreen
Expand All @@ -33,9 +34,13 @@ import kotlinx.coroutines.flow.filterIsInstance
* The same screen a chat's profile shows, with no chat behind it: [ProfileViewModel] turns the
* link's address into a person, then [ChatProfileViewModel] takes over as it does in a chat, which
* is what runs Block. With no chat there is no Mute row.
*
* [origin] decides where a block lands: a link or a transaction pops back to where it came from, a
* scan or a username search resets to the chat list, since what led here has no use once the
* person is blocked.
*/
@Composable
fun ProfileScreen(address: ProfileAddress) {
fun ProfileScreen(address: ProfileAddress, origin: ProfileOrigin = ProfileOrigin.Link) {
val lookup = hiltViewModel<ProfileViewModel>()
val viewModel = hiltViewModel<ChatProfileViewModel>()
val navigator = LocalCodeNavigator.current
Expand Down Expand Up @@ -68,12 +73,17 @@ fun ProfileScreen(address: ProfileAddress) {
}
}

LaunchedEffect(viewModel) {
// No chat to leave, as a DM's profile does after a block: back to wherever the link was
// opened from.
LaunchedEffect(viewModel, origin) {
// No chat to leave, as a DM's profile does after a block.
viewModel.eventFlow
.filterIsInstance<ChatProfileViewModel.Event.BlockSuccessful>()
.collect { navigator.pop() }
.collect {
if (origin.resetsToChatsAfterBlock) {
navigator.navigateAll(listOf(AppRoute.Tabs.Chats))
} else {
navigator.pop()
}
}
}

if (state.participant == null) {
Expand Down
Loading
Loading