From 45756e7dfa16f4ded8f68638c7831440c3e47a70 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 17:40:17 -0400 Subject: [PATCH 1/7] feat(router): open a person's flipcash.com link on their profile A tapped `flipcash.com/` or `flipcash.com/` used to present that user's tip card, and TipCardDecorator then pushed their DM 450ms later. It now opens their profile, over the Chats tab, with a Message shortcut into the same DM route the chat profile uses. - `DeeplinkType.TipcardByUsername` becomes `DeeplinkType.Profile`, which also takes the bare-host UUID form. `/tip/` stays `Tipcard` and still presents the card. - New `AppRoute.Messaging.Profile(ProfileAddress)` and `ProfileScreen`, reusing `ProfileHeader`, `ProfileShortcuts` and `dmRoute()`. `ProfileViewModel` resolves the handle or id. - A scanned profile link still presents the tip card: a printed code is there to be paid. - Your own link still lands on the You tab. A cold-start self-link by handle, which the router can't match yet, is caught by the screen once the lookup returns your id. - An unclaimed handle shows "Nobody has claimed @x" and a failed lookup shows "Couldn't Open Profile"; either pops the screen. --- .../ui/navigation/AppScreenContent.kt | 2 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 15 ++ .../flipcash/app/core/chat/ProfileAddress.kt | 45 +++++ .../app/core/navigation/DeeplinkType.kt | 18 +- .../core/src/main/res/values/strings.xml | 3 + .../core/navigation/DeeplinkScanPolicyTest.kt | 3 +- .../flipcash/app/messenger/ProfileScreen.kt | 100 ++++++++++ .../internal/link/LinkCardClassifier.kt | 15 +- .../screens/profile/ChatProfileScreen.kt | 7 - .../screens/profile/ProfileShortcuts.kt | 12 ++ .../screens/profile/ProfileViewModel.kt | 150 ++++++++++++++ .../internal/link/LinkCardClassifierTest.kt | 5 +- .../screens/profile/ProfileViewModelTest.kt | 183 ++++++++++++++++++ .../flipcash/app/scanner/internal/Scanner.kt | 10 +- .../com/flipcash/app/analytics/Conversions.kt | 2 +- .../flipcash/app/analytics/ConversionsTest.kt | 5 +- .../flipcash/app/router/internal/AppRouter.kt | 37 +++- .../app/router/internal/AppRouterTest.kt | 82 +++++--- 18 files changed, 635 insertions(+), 59 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ProfileAddress.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileViewModelTest.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index d01b698ac7..1b86cb1893 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 @@ -37,6 +37,7 @@ import com.flipcash.app.deposit.DepositFlowScreen import com.flipcash.app.invite.InviteContactScreen import com.flipcash.app.messenger.ChatFlowScreen import com.flipcash.app.messenger.MuteChatScreen +import com.flipcash.app.messenger.ProfileScreen import com.flipcash.app.messenger.ReportFlowScreen import com.flipcash.app.discovery.TokenDiscoveryScreen import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator @@ -116,6 +117,7 @@ fun appEntryProvider( annotatedEntry { key -> ChatFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } + annotatedEntry { key -> ProfileScreen(key.address) } annotatedEntry { NewChatScreen() } annotatedEntry { FindByUsernameScreen() } annotatedEntry { key -> 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 252e04a1a6..1ffcdca970 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 @@ -4,6 +4,7 @@ import android.os.Parcelable 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.ReportSubject import com.flipcash.app.core.reporting.ReportStep import com.flipcash.app.core.chat.NewGroupStep @@ -359,6 +360,20 @@ sealed interface AppRoute : NavKey, Parcelable { @Parcelize data object NewChat : Messaging + /** + * Another person's profile, opened on its own rather than from inside a chat — the + * destination of a `flipcash.com/{username}` or `flipcash.com/{uuid}` link. + * + * A top-level route rather than [ChatStep.Profile]: that step belongs to a conversation + * and reads the person off the chat's view model, and a link names a person with no + * conversation behind it. Its Message shortcut pushes [Chat] on top of it. + * + * Not [Menu.UserProfile], which is the viewer's own account. + */ + @Serializable + @Parcelize + data class Profile(val address: ProfileAddress) : Messaging + /** Node 9442:5825 — starting a chat by typing someone's `@handle`. */ @Serializable @Parcelize diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ProfileAddress.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ProfileAddress.kt new file mode 100644 index 0000000000..810aac7056 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ProfileAddress.kt @@ -0,0 +1,45 @@ +package com.flipcash.app.core.chat + +import android.os.Parcelable +import com.flipcash.app.core.tipping.TipCardOwner +import com.getcode.opencode.model.core.ID +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * Whose profile `AppRoute.Messaging.Profile` shows, in whichever way the link that opened it named + * them: `flipcash.com/{uuid}` by account id, `flipcash.com/{username}` by handle. + * + * The same fork as [TipCardOwner], and for the same reason — turning a handle into an id is a + * server round trip, which belongs to the screen rather than to the router. Its own type because + * it is a navigation key, so it has to be [Serializable] and [Parcelable], which a tip card's + * owner never needed to be. + */ +@Serializable +@Parcelize +sealed interface ProfileAddress : Parcelable { + + /** By account id. */ + @Serializable + @Parcelize + data class ById(val userId: ID) : ProfileAddress + + /** By claimed public handle, unresolved. */ + @Serializable + @Parcelize + data class ByUsername(val username: String) : ProfileAddress + + /** + * The same person as the owner of a tip card — for the surfaces that still present the card + * for a profile link, such as the scanner reading a printed one. + */ + val tipCardOwner: TipCardOwner + get() = when (this) { + is ById -> TipCardOwner.ById(userId) + is ByUsername -> TipCardOwner.ByUsername(username) + } + + /** Whether this addresses the signed-in account. See [TipCardOwner.isSelf] for the rules. */ + fun isSelf(accountId: ID?, username: String?): Boolean = + tipCardOwner.isSelf(accountId, username) +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt index 2a756c3514..527b6dd805 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt @@ -3,6 +3,7 @@ package com.flipcash.app.core.navigation import android.net.Uri import android.os.Parcelable import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.chat.ProfileAddress import com.flipcash.services.models.chat.ChatId import com.getcode.opencode.model.core.ID import com.getcode.solana.keys.Mint @@ -32,17 +33,20 @@ sealed interface DeeplinkType: Parcelable { @Serializable data class GroupChatInvite(val chatId: ChatId): DeeplinkType, Navigatable /** - * A tip card addressed by account id — `flipcash.com/{uuid}`, or the older - * `app.flipcash.com/tip/{uuid}` that links already shared still carry. + * A tip card addressed by account id — the older `app.flipcash.com/tip/{uuid}` that links + * already shared still carry. */ @Serializable data class Tipcard(val userId: ID): DeeplinkType /** - * A `flipcash.com/{username}` link — the same destination as [Tipcard], addressed by the - * owner's public handle. The id it resolves to is the server's to supply, so it stays a - * username all the way to the session. + * A person's link on the bare host — `flipcash.com/{username}`, or `flipcash.com/{uuid}` for + * an account with no handle. Tapped, it opens their profile; scanned, it is still their tip + * card, since a printed code is there to be paid. + * + * The handle stays unresolved all the way to the screen: the id it names is the server's to + * supply. */ - @Serializable data class TipcardByUsername(val username: String): DeeplinkType + @Serializable data class Profile(val address: ProfileAddress): DeeplinkType @Serializable data class EmailVerification( @@ -68,7 +72,7 @@ sealed interface DeeplinkType: Parcelable { is TokenInfo, is TipChat, is Tipcard, - is TipcardByUsername, + is Profile, is GroupChatInvite, -> true diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index b2b0235051..61084fa414 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1194,6 +1194,9 @@ Nobody has claimed \@%1$s Couldn\'t Open Tip Card Please check your connection and try again + + Couldn\'t Open Profile + Please check your connection and try again This Photo is Not Allowed AI flagged this photo. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt index d88b143a84..1682bbf948 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt @@ -1,6 +1,7 @@ package com.flipcash.app.core.navigation import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.chat.ProfileAddress import com.flipcash.services.models.chat.ChatId import com.getcode.solana.keys.Mint import kotlin.test.Test @@ -22,7 +23,7 @@ class DeeplinkScanPolicyTest { assertTrue(DeeplinkType.TokenInfo(MINT).isScannable) assertTrue(DeeplinkType.TipChat(ChatIdentifier.ByChatId(CHAT_ID)).isScannable) assertTrue(DeeplinkType.Tipcard(USER_ID).isScannable) - assertTrue(DeeplinkType.TipcardByUsername(USERNAME).isScannable) + assertTrue(DeeplinkType.Profile(ProfileAddress.ByUsername(USERNAME)).isScannable) assertTrue(DeeplinkType.GroupChatInvite(CHAT_ID).isScannable) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt new file mode 100644 index 0000000000..f0936d2695 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt @@ -0,0 +1,100 @@ +package com.flipcash.app.messenger + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ProfileAddress +import com.flipcash.app.core.extensions.navigateAll +import com.flipcash.app.messenger.internal.screens.profile.ProfileHeader +import com.flipcash.app.messenger.internal.screens.profile.ProfileShortcuts +import com.flipcash.app.messenger.internal.screens.profile.ProfileViewModel +import com.flipcash.app.messenger.internal.screens.profile.dmRoute +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.ui.theme.CodeScaffold +import kotlinx.coroutines.flow.filter + +/** + * Another person's profile behind `AppRoute.Messaging.Profile`: the header a chat profile shows, + * with the Message shortcut under it, for someone the viewer reached by link rather than from a + * conversation. + * + * No rows below the header. Report, block and mute act on a person the viewer has a chat with, + * and they are one tap away in the DM that Message opens. + */ +@Composable +fun ProfileScreen(address: ProfileAddress) { + val viewModel = hiltViewModel() + val navigator = LocalCodeNavigator.current + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(viewModel, address) { + viewModel.dispatchEvent(ProfileViewModel.Event.Load(address)) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filter { + it is ProfileViewModel.Event.Unavailable || it is ProfileViewModel.Event.OwnProfile + } + .collect { event -> + when (event) { + // The failure is on the bottom bar already; there's nothing here to show. + ProfileViewModel.Event.Unavailable -> navigator.pop() + // Where the router sends a self-link it can recognise. See AppRouter.profile. + else -> navigator.navigateAll(listOf(AppRoute.Tabs.Menu)) + } + } + } + + CodeScaffold( + topBar = { + AppBarWithTitle(onBackIconClicked = { navigator.pop() }) + }, + ) { innerPadding -> + val participant = state.participant + if (participant == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + CodeCircularProgressIndicator( + strokeWidth = CodeTheme.dimens.thickBorder, + color = CodeTheme.colors.textSecondary, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x8), + ) + } + } else { + ProfileHeader( + participant = participant, + joinDate = participant.profile.joinedAt, + shortcuts = { + ProfileShortcuts( + cashSymbol = state.cashSymbol, + onMessage = { navigator.push(participant.dmRoute()) }, + onSendCash = { navigator.push(participant.dmRoute(openSendCash = true)) }, + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(innerPadding) + // The chat profile's top inset, so the two read as the same screen. + .padding(top = CodeTheme.dimens.grid.x7), + ) + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt index 43e50a7b38..04c26113a9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.link import android.net.Uri import androidx.core.net.toUri +import com.flipcash.app.core.chat.ProfileAddress import com.flipcash.app.core.links.FIRST_PARTY_HOSTS import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.router.Router @@ -83,8 +84,8 @@ internal class LinkCardClassifier @Inject constructor( chatId = type.chatId, state = LinkCard.GroupInvite.State.Loading, ).takeIf { target.toUri().pathSegments.size == GROUP_INVITE_SEGMENTS } - // A person's link, by id: the no-handle `flipcash.com/`, or the legacy - // `/tip/`. Whether anyone owns the id is the lookup's question. + // A person's link by id, the legacy `/tip/`. Whether anyone owns the id is the + // lookup's question. is DeeplinkType.Tipcard -> LinkCard.User( url = target, start = link.start, @@ -92,13 +93,17 @@ internal class LinkCardClassifier @Inject constructor( identity = LinkCard.User.Identity.ById(type.userId), state = LinkCard.User.State.Loading, ) - // By handle. The router has already refused the website's own pages + // A person's link on the bare host: by handle, or by id for the no-handle + // `flipcash.com/`. The router has already refused the website's own pages // (`AppRouter.reservedProfilePaths`), which is what keeps `/download` a plain link. - is DeeplinkType.TipcardByUsername -> LinkCard.User( + is DeeplinkType.Profile -> LinkCard.User( url = target, start = link.start, end = link.end, - identity = LinkCard.User.Identity.ByUsername(type.username), + identity = when (val address = type.address) { + is ProfileAddress.ById -> LinkCard.User.Identity.ById(address.userId) + is ProfileAddress.ByUsername -> LinkCard.User.Identity.ByUsername(address.username) + }, state = LinkCard.User.State.Loading, ) else -> null 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 a9670f6fd6..3752d6354a 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 @@ -21,7 +21,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.chat.ReportSubject @@ -253,9 +252,3 @@ internal fun ProfileHeader( } } } - -private fun ChatParticipant.TipUser.dmRoute(openSendCash: Boolean = false) = - AppRoute.Messaging.Chat( - identifier = ChatIdentifier.ByUser(userId, profile), - openSendCash = openSendCash, - ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt index 8157d37ead..790ffbde83 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt @@ -25,6 +25,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant // Used by the held-back Send Cash shortcut below. // import com.flipcash.app.messenger.internal.screens.components.CondensedSymbolFontSize @@ -57,6 +59,16 @@ internal fun profileShortcutRecipient( return user } +/** + * The DM a profile's shortcuts open with [this] person: Message opens it, Send Cash opens it with + * a payment already started. + */ +internal fun ChatParticipant.TipUser.dmRoute(openSendCash: Boolean = false) = + AppRoute.Messaging.Chat( + identifier = ChatIdentifier.ByUser(userId, profile), + openSendCash = openSendCash, + ) + /** * The Message and Send Cash shortcuts under a profile's identity lines. * diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileViewModel.kt new file mode 100644 index 0000000000..865de45501 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileViewModel.kt @@ -0,0 +1,150 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.chat.ProfileAddress +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.GetUserProfileError +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.exchange.Exchange +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.trace +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +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 +import javax.inject.Inject +import com.flipcash.core.R as CoreR + +/** + * Another person's profile opened on its own, from a `flipcash.com/{username}` or + * `flipcash.com/{uuid}` link, rather than from inside a chat. + * + * [ChatProfileViewModel] is handed a participant the chat already knows. A link hands over only + * an address, so this one's job is the lookup: resolve the address to a profile, or say why it + * couldn't and let the screen leave. + * + * The failure is announced the way a tip card link's was before this screen replaced it (see + * `TipCardDelegate.announceUnresolvable`): an unclaimed handle is information, not an error, and + * only a failed fetch is ours to apologise for. + */ +@HiltViewModel +internal class ProfileViewModel @Inject constructor( + private val profiles: ProfileController, + private val userManager: UserManager, + private val exchange: Exchange, + private val resources: ResourceHelper, + dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + data class State( + val participant: ChatParticipant.TipUser? = null, + /** The Send Cash shortcut's glyph, as the chat derives it. */ + val cashSymbol: String = "$", + ) + + sealed interface Event { + data class Load(val address: ProfileAddress) : Event + data class Loaded(val participant: ChatParticipant.TipUser) : Event + data class OnCurrencySymbolUpdated(val symbol: String) : Event + + /** Nobody to show; the failure has been announced and the screen should leave. */ + data object Unavailable : Event + + /** + * The address turned out to be the viewer's own, which only the lookup can tell for a + * handle opened before this account's profile arrived. See `AppRouter.profile`. + */ + data object OwnProfile : Event + } + + init { + exchange.observePreferredRate() + .onEach { rate -> + exchange.getCurrency(rate.currency.name)?.let { currency -> + dispatchEvent(Event.OnCurrencySymbolUpdated(currency.symbol.ifEmpty { "$" })) + } + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { it.address } + .distinctUntilChanged() + .onEach { address -> + lookup(address) + .onSuccess { profile -> + val userId = profile.userId + when { + // Without an id there's nothing to open a DM with. + userId == null -> { + trace( + tag = "Profile", + message = "Profile for $address carries no user id", + ) + announceUnavailable(address, cause = null) + dispatchEvent(Event.Unavailable) + } + + userId == userManager.accountId -> dispatchEvent(Event.OwnProfile) + + else -> dispatchEvent( + Event.Loaded(ChatParticipant.TipUser(userId, profile)) + ) + } + } + .onFailure { cause -> + trace( + tag = "Profile", + message = "Failed to resolve profile for $address", + error = cause, + ) + announceUnavailable(address, cause) + dispatchEvent(Event.Unavailable) + } + } + .launchIn(viewModelScope) + } + + private suspend fun lookup(address: ProfileAddress) = when (address) { + is ProfileAddress.ById -> profiles.getProfileForUser(address.userId) + is ProfileAddress.ByUsername -> profiles.getProfileForUsername(address.username) + } + + private fun announceUnavailable(address: ProfileAddress, cause: Throwable?) { + if (address is ProfileAddress.ByUsername && cause is GetUserProfileError.NotFound) { + BottomBarManager.showInfo( + title = resources.getString(CoreR.string.error_title_usernameNotFound), + message = resources.getString( + CoreR.string.error_description_usernameNotFound, + address.username, + ), + ) + } else { + BottomBarManager.showError( + title = resources.getString(CoreR.string.error_title_profileUnavailable), + message = resources.getString(CoreR.string.error_description_profileUnavailable), + ) + } + } + + companion object { + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + when (event) { + is Event.Loaded -> { state -> state.copy(participant = event.participant) } + is Event.OnCurrencySymbolUpdated -> { state -> state.copy(cashSymbol = event.symbol) } + is Event.Load, + Event.Unavailable, + Event.OwnProfile -> { state -> state } + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt index 0de0327e1a..287d8d386c 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.link +import com.flipcash.app.core.chat.ProfileAddress import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.router.Router import com.flipcash.shared.chat.models.LinkCard @@ -276,10 +277,10 @@ private fun DeepLink.profileLinkForTest(): DeeplinkType? { if (uri.host?.removePrefix("www.") != "flipcash.com") return null val segment = uri.pathSegments.singleOrNull()?.lowercase() ?: return null if (Regex("^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$").matches(segment)) { - return DeeplinkType.Tipcard(UUID.fromString(segment).bytes) + return DeeplinkType.Profile(ProfileAddress.ById(UUID.fromString(segment).bytes)) } return if (segment.isUsernameShaped() && segment !in reservedProfilePathsForTest) { - DeeplinkType.TipcardByUsername(segment) + DeeplinkType.Profile(ProfileAddress.ByUsername(segment)) } else { null } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileViewModelTest.kt new file mode 100644 index 0000000000..e08676e3b1 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileViewModelTest.kt @@ -0,0 +1,183 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.chat.ProfileAddress +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.GetUserProfileError +import com.flipcash.services.models.UserProfile +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.exchange.Exchange +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant +import com.flipcash.core.R as CoreR + +/** + * A profile opened by link: the address resolves to a person, or the screen is told to leave with + * the same bottom-bar message a tip card link used to show. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val myId: List = List(16) { 1 } + private val theirId: List = List(16) { 2 } + + private val profiles = mockk() + private val userManager = mockk { every { accountId } returns myId } + private val exchange = mockk { every { observePreferredRate() } returns emptyFlow() } + private val resources = mockk { + every { getString(CoreR.string.error_title_usernameNotFound) } returns "usernameNotFound" + every { getString(CoreR.string.error_description_usernameNotFound, *anyVararg()) } returns "" + every { getString(CoreR.string.error_title_profileUnavailable) } returns "profileUnavailable" + every { getString(CoreR.string.error_description_profileUnavailable) } returns "" + } + + @Before + fun setUp() { + BottomBarManager.clear() + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + private fun viewModel() = ProfileViewModel( + profiles = profiles, + userManager = userManager, + exchange = exchange, + resources = resources, + dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), + ) + + private fun profile(userId: List? = theirId) = UserProfile( + displayName = "Sally", + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + joinedAt = Instant.fromEpochSeconds(1_710_000_000), + userId = userId, + username = "sally_streamer", + ) + + /** Loads [address] and returns the events the screen would act on. */ + private fun kotlinx.coroutines.test.TestScope.load( + model: ProfileViewModel, + address: ProfileAddress, + ): List { + val events = mutableListOf() + backgroundScope.launch(mainCoroutineRule.dispatcher) { model.eventFlow.collect(events::add) } + model.dispatchEvent(ProfileViewModel.Event.Load(address)) + return events + } + + private fun barTitles() = BottomBarManager.messages.value.map { it.title } + + @Test + fun `a handle resolves to that person`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { profiles.getProfileForUsername("sally_streamer") } returns Result.success(profile()) + val model = viewModel() + + load(model, ProfileAddress.ByUsername("sally_streamer")) + + assertEquals(ChatParticipant.TipUser(theirId, profile()), model.stateFlow.value.participant) + assertTrue(barTitles().isEmpty()) + } + + @Test + fun `an id resolves through the id lookup`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { profiles.getProfileForUser(theirId) } returns Result.success(profile()) + val model = viewModel() + + load(model, ProfileAddress.ById(theirId)) + + assertEquals(theirId, model.stateFlow.value.participant?.userId) + coVerify(exactly = 0) { profiles.getProfileForUsername(any()) } + } + + @Test + fun `an unclaimed handle says nobody has it and leaves`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { profiles.getProfileForUsername("nobody_here") } returns + Result.failure(GetUserProfileError.NotFound()) + val model = viewModel() + + val events = load(model, ProfileAddress.ByUsername("nobody_here")) + + assertEquals(listOf("usernameNotFound"), barTitles()) + assertTrue(ProfileViewModel.Event.Unavailable in events) + assertNull(model.stateFlow.value.participant) + } + + @Test + fun `a failed fetch is reported as an error and leaves`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { profiles.getProfileForUsername("sally_streamer") } returns + Result.failure(IllegalStateException("offline")) + val model = viewModel() + + val events = load(model, ProfileAddress.ByUsername("sally_streamer")) + + assertEquals(listOf("profileUnavailable"), barTitles()) + assertTrue(ProfileViewModel.Event.Unavailable in events) + } + + // An id nobody owns is a failed lookup, not an unclaimed handle: there's no name to put in + // "Nobody has claimed @…", which is how the tip card path told the two apart as well. + @Test + fun `an id nobody owns is reported as unavailable`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { profiles.getProfileForUser(theirId) } returns + Result.failure(GetUserProfileError.NotFound()) + val model = viewModel() + + val events = load(model, ProfileAddress.ById(theirId)) + + assertEquals(listOf("profileUnavailable"), barTitles()) + assertTrue(ProfileViewModel.Event.Unavailable in events) + } + + @Test + fun `a profile without an id can't be messaged and leaves`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { profiles.getProfileForUsername("sally_streamer") } returns + Result.success(profile(userId = null)) + val model = viewModel() + + val events = load(model, ProfileAddress.ByUsername("sally_streamer")) + + assertTrue(ProfileViewModel.Event.Unavailable in events) + assertNull(model.stateFlow.value.participant) + } + + // The router can't match a handle before this account's own profile has arrived. + @Test + fun `a handle that turns out to be the viewer's own is handed back`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { profiles.getProfileForUsername("me_myself") } returns + Result.success(profile(userId = myId)) + val model = viewModel() + + val events = load(model, ProfileAddress.ByUsername("me_myself")) + + assertTrue(ProfileViewModel.Event.OwnProfile in events) + assertNull(model.stateFlow.value.participant) + assertTrue(barTitles().isEmpty()) + } +} diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index bbb3ec61bf..24ee30c945 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -145,11 +145,11 @@ internal fun Scanner() { is DeeplinkType.Tipcard -> { session.resolveTipCard(TipCardOwner.ById(deeplink.userId)) } - // A printed or on-screen `flipcash.com/{username}` is the - // same card as a scanned `flipcash.com/{id}`, addressed by - // handle. - is DeeplinkType.TipcardByUsername -> { - session.resolveTipCard(TipCardOwner.ByUsername(deeplink.username)) + // A printed or on-screen `flipcash.com/{username}` (or + // `/{id}`) is the owner's tip card. A tapped one opens their + // profile instead; a scanned one is there to be paid. + is DeeplinkType.Profile -> { + session.resolveTipCard(deeplink.address.tipCardOwner) } } } diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt index e6bb7ecbda..1fb7603de4 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt @@ -123,7 +123,7 @@ val DeeplinkType.analytics: String is DeeplinkType.TipChat -> "TipChat" is DeeplinkType.GroupChatInvite -> "GroupChatInvite" is DeeplinkType.Tipcard -> "Tipcard" - is DeeplinkType.TipcardByUsername -> "TipcardByUsername" + is DeeplinkType.Profile -> "Profile" is DeeplinkType.EmailVerification -> "EmailVerification" } diff --git a/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt b/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt index b792b4d486..eb072da7e6 100644 --- a/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt +++ b/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt @@ -2,6 +2,7 @@ package com.flipcash.app.analytics import com.flipcash.analytics.Amount import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.chat.ProfileAddress import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.MuteChatError @@ -77,14 +78,14 @@ class ConversionsTest { DeeplinkType.TipChat(identifier = ChatIdentifier.ByChatId(chatId)), DeeplinkType.GroupChatInvite(chatId = chatId), DeeplinkType.Tipcard(userId = id), - DeeplinkType.TipcardByUsername(username = "someone"), + DeeplinkType.Profile(ProfileAddress.ByUsername("someone")), DeeplinkType.EmailVerification(email = "a@b.c", code = "123"), ) assertEquals( listOf( "Login", "CashLink", "TokenInfo", "TipChat", - "GroupChatInvite", "Tipcard", "TipcardByUsername", "EmailVerification", + "GroupChatInvite", "Tipcard", "Profile", "EmailVerification", ), types.map { it.analytics }, ) diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index 4621f02c30..f3b33f1d86 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -4,6 +4,7 @@ import android.net.Uri import androidx.core.net.toUri 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.navigation.DeeplinkAction import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.services.models.chat.ChatId @@ -39,7 +40,7 @@ internal class AppRouter( ) : Router { /** - * How the signed-in account can be addressed, for matching a tip card link against itself. + * How the signed-in account can be addressed, for matching a tip card or profile link against itself. * Both nullable and read together: see [TipCardOwner.isSelf] for why the id and the handle * cannot be sourced from one profile. */ @@ -60,7 +61,7 @@ internal class AppRouter( const val JUMP_HOST = "jump.flipcash.com" /** - * The bare host, which serves the website *and* every user's tip card link — by handle + * The bare host, which serves the website *and* every user's profile link — by handle * (`flipcash.com/sally_streamer`) or by account id (`flipcash.com/{uuid}`). Distinct from * the `app.` / `send.` hosts, whose whole path space belongs to the app. */ @@ -142,7 +143,28 @@ internal class AppRouter( is DeeplinkType.Tipcard -> tipCard(TipCardOwner.ById(type.userId)) - is DeeplinkType.TipcardByUsername -> tipCard(TipCardOwner.ByUsername(type.username)) + is DeeplinkType.Profile -> profile(type.address) + } + } + + /** + * Where a person's `flipcash.com` link goes: their profile, over the Chats tab, so its + * Message shortcut opens the DM from the tab that holds it — the same stack a tip chat link + * lands on. + * + * Your own goes where your own tip card link does, and for the same reason: there's no DM + * with yourself, and the You tab is the surface that shows you. A handle can only be matched + * once this account's profile has arrived, so a cold-started self-link by handle gets past + * this check and is caught by the screen instead, against the id the lookup returns. + */ + private fun profile(address: ProfileAddress): DeeplinkAction { + val self = currentUserProvider() + return if (address.isSelf(self.id, self.username)) { + DeeplinkAction.Navigate(listOf(AppRoute.Tabs.Menu)) + } else { + DeeplinkAction.Navigate( + listOf(AppRoute.Tabs.Chats, AppRoute.Messaging.Profile(address)) + ) } } @@ -317,7 +339,7 @@ private fun DeepLink.isTipChat(): Boolean = private fun DeepLink.isTipCard(): Boolean = tip.contains(pathSegments.getOrNull(0)) /** - * A tip card link on the bare host: one path segment naming its owner, either as a handle + * A person's link on the bare host: one path segment naming them, either as a handle * (`flipcash.com/sally_streamer`) or as an account id (`flipcash.com/{uuid}`). * * A handle has to clear two more conditions than the id does — the server's charset, and not being @@ -339,11 +361,12 @@ private fun DeepLink.handleProfileLink(): DeeplinkType? { // Lowercased, not just matched case-insensitively: handles are lowercase on the wire and a // UUID is written lowercase, and this string is what the lookup is keyed by. val segment = pathSegments.singleOrNull()?.lowercase() ?: return null - return if (segment.isUuidShaped()) { - DeeplinkType.Tipcard(UUID.fromString(segment).bytes) + val address = if (segment.isUuidShaped()) { + ProfileAddress.ById(UUID.fromString(segment).bytes) } else { - DeeplinkType.TipcardByUsername(segment) + ProfileAddress.ByUsername(segment) } + return DeeplinkType.Profile(address) } /** diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index ad75cb0dcc..cfc95380e6 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -3,6 +3,7 @@ package com.flipcash.app.router.internal import android.util.Base64 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.navigation.DeeplinkAction import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.core.util.Linkify @@ -495,26 +496,26 @@ class AppRouterTest { // endregion - // region classify + dispatch — tip card links on the bare host + // region classify + dispatch — profile links on the bare host @Test fun `classify recognizes a vanity profile link`() { val type = router.classify(DeepLink(Linkify.tipcard(TipCardOwner.ByUsername("sally_streamer")))) - assertIs(type) - assertEquals("sally_streamer", type.username) + assertIs(type) + assertEquals(ProfileAddress.ByUsername("sally_streamer"), type.address) } @Test fun `classify lowercases a vanity profile link`() { val type = router.classify(DeepLink("https://flipcash.com/Sally_Streamer")) - assertIs(type) - assertEquals("sally_streamer", type.username) + assertIs(type) + assertEquals(ProfileAddress.ByUsername("sally_streamer"), type.address) } @Test fun `classify recognizes a vanity profile link on the www host`() { val type = router.classify(DeepLink("https://www.flipcash.com/sally_streamer")) - assertIs(type) + assertIs(type) } @Test @@ -552,45 +553,67 @@ class AppRouterTest { } @Test - fun `classify recognizes a tip card link addressed by account id`() { + fun `classify recognizes a profile link addressed by account id`() { val userId = "11111111-2222-3333-4444-555555555555" val type = router.classify(DeepLink("https://flipcash.com/$userId")) - assertIs(type) - assertEquals(UUID.fromString(userId).bytes, type.userId) + assertIs(type) + assertEquals(ProfileAddress.ById(UUID.fromString(userId).bytes), type.address) } @Test fun `classify recognizes an id link written by Linkify`() { val userId = UUID.fromString("11111111-2222-3333-4444-555555555555").bytes val type = router.classify(DeepLink(Linkify.tipcard(TipCardOwner.ById(userId)))) - assertIs(type) - assertEquals(userId, type.userId) + assertIs(type) + assertEquals(ProfileAddress.ById(userId), type.address) } // A link is typed, printed, or auto-capitalised in any case; the id it resolves to is the same. @Test fun `classify recognizes an id link in mixed case`() { val type = router.classify(DeepLink("https://flipcash.com/AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")) - assertIs(type) - assertEquals(UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").bytes, type.userId) + assertIs(type) + assertEquals( + ProfileAddress.ById(UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").bytes), + type.address, + ) } @Test fun `classify recognizes an id link on the www host`() { val type = router.classify(DeepLink("https://www.flipcash.com/11111111-2222-3333-4444-555555555555")) - assertIs(type) + assertIs(type) } @Test - fun `dispatch presents the tip card for another user's id link`() { + fun `dispatch opens the profile over the Chats tab for another user's id link`() { loggedIn() currentUserId = UUID.fromString("22222222-2222-2222-2222-222222222222").bytes val userId = "11111111-1111-1111-1111-111111111111" val action = router.dispatch(DeepLink("https://flipcash.com/$userId")) - assertIs(action) - assertEquals(TipCardOwner.ById(UUID.fromString(userId).bytes), action.owner) + assertIs(action) + assertEquals( + listOf( + AppRoute.Tabs.Chats, + AppRoute.Messaging.Profile(ProfileAddress.ById(UUID.fromString(userId).bytes)), + ), + action.routes, + ) + } + + // Unknown until this account's profile arrives; the screen catches a self-link by id then. + @Test + fun `dispatch opens the profile for an id link when the current user id is unknown`() { + loggedIn() + currentUserId = null + + val userId = "11111111-1111-1111-1111-111111111111" + val action = router.dispatch(DeepLink("https://flipcash.com/$userId")) + + assertIs(action) + assertIs(action.routes.last()) } @Test @@ -633,16 +656,31 @@ class AppRouterTest { } @Test - fun `dispatch presents the tip card for a vanity profile link`() { + fun `dispatch opens the profile over the Chats tab for a vanity profile link`() { loggedIn() + currentUsername = "someone_else" val action = router.dispatch(DeepLink(Linkify.tipcard(TipCardOwner.ByUsername("sally_streamer")))) + assertIs(action) + assertEquals( + listOf( + AppRoute.Tabs.Chats, + AppRoute.Messaging.Profile(ProfileAddress.ByUsername("sally_streamer")), + ), + action.routes, + ) + } + + // The only entry that still presents the card by link: a profile link no longer does. + @Test + fun `dispatch still presents the tip card for a legacy tip path`() { + loggedIn() + val userId = "11111111-1111-1111-1111-111111111111" + val action = router.dispatch(DeepLink("https://app.flipcash.com/tip/$userId")) assertIs(action) - assertEquals(TipCardOwner.ByUsername("sally_streamer"), action.owner) } - // Your own handle diverts to the You tab here rather than after resolution: the session - // announces a self-tip through a replay-less event only the scanner collects, so a link - // opened onto any other tab would drop it silently. + // Your own handle diverts to the You tab, as your own tip card link does: there is no DM with + // yourself, and the You tab is the surface that shows you. @Test fun `dispatch routes your own vanity profile link to the You tab`() { loggedIn() From dd0fb90132875d4d2c4dc0e4580072deb422eae3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 17:50:38 -0400 Subject: [PATCH 2/7] feat(chat): open a person link card on their profile Tapping another person's link card in a chat pushed the DM with them. It now pushes their profile, the screen their flipcash.com link opens, and its Message shortcut goes on to the DM. Your own card and a card for the person you're already talking to behave as before. --- .../app/messenger/internal/screens/MessengerScreen.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 a567961c54..7a93e4272b 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 @@ -31,6 +31,7 @@ import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.ProfileAddress import com.flipcash.app.core.links.ExternalLinkUriHandler import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.messenger.internal.ChatSubject @@ -148,10 +149,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { else -> keyboard.hideIfVisible { viewModel.dispatchEvent( ChatViewModel.Event.OpenScreen( - // The DM the tip flow ends at, pushed as the group card pushes - // its chat, so Back returns here. Its own fee sheet handles any - // fee to message them. - AppRoute.Messaging.Chat(ChatIdentifier.ByUser(action.userId, action.profile)) + // The profile their flipcash.com link opens, pushed as the group + // card pushes its chat, so Back returns here. Its Message shortcut + // goes on to the DM. + AppRoute.Messaging.Profile(ProfileAddress.ById(action.userId)) ) ) } From 74c5dfb7b6cfc5aed59c6388fa89385be373022f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 17:50:38 -0400 Subject: [PATCH 3/7] feat(chat): add Share to a person's profile The profile a link opens gets a Share shortcut next to Message. It shares the same flipcash.com link through Shareable.TipCard, in the handle form when they have one, titled with their name. The chat profile keeps just Message: ProfileShortcuts takes Share as an optional callback and only the standalone screen passes it. --- .../core/src/main/res/values/strings.xml | 1 + .../flipcash/app/messenger/ProfileScreen.kt | 21 ++++++++++++++++++- .../screens/profile/ProfileShortcuts.kt | 18 +++++++++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 61084fa414..f416367b89 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1243,6 +1243,7 @@ Message + Share Something Went Wrong We were unable to block the user. Please try again diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt index f0936d2695..d6e7c4ee99 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -19,16 +20,19 @@ import com.flipcash.app.messenger.internal.screens.profile.ProfileHeader import com.flipcash.app.messenger.internal.screens.profile.ProfileShortcuts import com.flipcash.app.messenger.internal.screens.profile.ProfileViewModel import com.flipcash.app.messenger.internal.screens.profile.dmRoute +import com.flipcash.app.shareable.LocalShareController +import com.flipcash.app.shareable.Shareable import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.theme.CodeScaffold import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch /** * Another person's profile behind `AppRoute.Messaging.Profile`: the header a chat profile shows, - * with the Message shortcut under it, for someone the viewer reached by link rather than from a + * with the Message and Share shortcuts under it, for someone the viewer reached by link rather than from a * conversation. * * No rows below the header. Report, block and mute act on a person the viewer has a chat with, @@ -38,6 +42,8 @@ import kotlinx.coroutines.flow.filter fun ProfileScreen(address: ProfileAddress) { val viewModel = hiltViewModel() val navigator = LocalCodeNavigator.current + val shareController = LocalShareController.current + val scope = rememberCoroutineScope() val state by viewModel.stateFlow.collectAsStateWithLifecycle() LaunchedEffect(viewModel, address) { @@ -87,6 +93,19 @@ fun ProfileScreen(address: ProfileAddress) { cashSymbol = state.cashSymbol, onMessage = { navigator.push(participant.dmRoute()) }, onSendCash = { navigator.push(participant.dmRoute(openSendCash = true)) }, + onShare = { + scope.launch { + // The link this screen opens from, in the handle form when they + // have one, as the You tab shares your own. + shareController.present( + Shareable.TipCard( + userId = participant.userId, + title = participant.profile.displayName, + username = participant.profile.username, + ) + ) + } + }, ) }, modifier = Modifier diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt index 790ffbde83..9b636c54fd 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt @@ -70,7 +70,8 @@ internal fun ChatParticipant.TipUser.dmRoute(openSendCash: Boolean = false) = ) /** - * The Message and Send Cash shortcuts under a profile's identity lines. + * The Message and Send Cash shortcuts under a profile's identity lines, and Share when [onShare] is + * set. * * Two fixed-width columns, so the pair stays centered and the circles stay the same distance apart * whatever the labels say. The Send Cash glyph is [cashSymbol] rather than an icon, so it names the @@ -82,6 +83,8 @@ internal fun ProfileShortcuts( onMessage: () -> Unit, onSendCash: () -> Unit, modifier: Modifier = Modifier, + // Null leaves Share out. The chat profile has none: the person is already in a conversation. + onShare: (() -> Unit)? = null, ) { Row( modifier = modifier, @@ -112,6 +115,19 @@ internal fun ProfileShortcuts( // softWrap = false, // ) // } + if (onShare != null) { + ProfileShortcut( + label = stringResource(R.string.action_shareProfile), + onClick = onShare, + ) { + Icon( + painter = painterResource(R.drawable.ic_share_os), + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + ) + } + } } } From 19d3d31b99e7bb033fb73c6d36aad1b5d42af86a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 17:57:09 -0400 Subject: [PATCH 4/7] feat(chat): add Share to a group member's profile Tapping a member's avatar in a group opens the in-chat profile, which already shows Message for them. It now shows Share too, with the same link and title as the profile a link opens. A DM's profile still has no shortcuts. The share handler moves into rememberProfileShare() so both screens build the Shareable the same way. --- .../flipcash/app/messenger/ProfileScreen.kt | 22 ++------------ .../screens/profile/ChatProfileScreen.kt | 4 ++- .../screens/profile/ProfileShortcuts.kt | 29 ++++++++++++++++++- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt index d6e7c4ee99..4b7de6ef14 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -20,15 +19,13 @@ import com.flipcash.app.messenger.internal.screens.profile.ProfileHeader import com.flipcash.app.messenger.internal.screens.profile.ProfileShortcuts import com.flipcash.app.messenger.internal.screens.profile.ProfileViewModel import com.flipcash.app.messenger.internal.screens.profile.dmRoute -import com.flipcash.app.shareable.LocalShareController -import com.flipcash.app.shareable.Shareable +import com.flipcash.app.messenger.internal.screens.profile.rememberProfileShare import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.theme.CodeScaffold import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.launch /** * Another person's profile behind `AppRoute.Messaging.Profile`: the header a chat profile shows, @@ -42,8 +39,7 @@ import kotlinx.coroutines.launch fun ProfileScreen(address: ProfileAddress) { val viewModel = hiltViewModel() val navigator = LocalCodeNavigator.current - val shareController = LocalShareController.current - val scope = rememberCoroutineScope() + val share = rememberProfileShare() val state by viewModel.stateFlow.collectAsStateWithLifecycle() LaunchedEffect(viewModel, address) { @@ -93,19 +89,7 @@ fun ProfileScreen(address: ProfileAddress) { cashSymbol = state.cashSymbol, onMessage = { navigator.push(participant.dmRoute()) }, onSendCash = { navigator.push(participant.dmRoute(openSendCash = true)) }, - onShare = { - scope.launch { - // The link this screen opens from, in the handle form when they - // have one, as the You tab shares your own. - shareController.present( - Shareable.TipCard( - userId = participant.userId, - title = participant.profile.displayName, - username = participant.profile.username, - ) - ) - } - }, + onShare = { share(participant) }, ) }, modifier = Modifier 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 3752d6354a..0e16256cc3 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 @@ -62,7 +62,7 @@ import kotlin.time.Instant * never reach this screen ([com.flipcash.app.messenger.internal.ChatSubject.Contact] answers * `canViewProfile` false). * - * The Message and Send Cash shortcuts follow the same split, the other way round: they show on a + * The Message, Send Cash and Share shortcuts follow the same split, the other way round: they show on a * group member's profile and not on a tip DM's, where they would only reopen the chat behind it. * [profileShortcutRecipient] has the whole rule. */ @@ -76,6 +76,7 @@ internal fun ChatProfileScreen( val state by viewModel.stateFlow.collectAsStateWithLifecycle() val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() val isTipDm = chatState.chatType == ChatType.TIP_DM + val share = rememberProfileShare() CodeScaffold( topBar = { @@ -115,6 +116,7 @@ internal fun ChatProfileScreen( cashSymbol = chatState.cashSymbol, onMessage = { navigator.push(user.dmRoute()) }, onSendCash = { navigator.push(user.dmRoute(openSendCash = true)) }, + onShare = { share(user) }, ) } }, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt index 9b636c54fd..072248b2ca 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -30,10 +31,13 @@ import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant // Used by the held-back Send Cash shortcut below. // import com.flipcash.app.messenger.internal.screens.components.CondensedSymbolFontSize +import com.flipcash.app.shareable.LocalShareController +import com.flipcash.app.shareable.Shareable import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatType import com.getcode.opencode.model.core.ID import com.getcode.theme.CodeTheme +import kotlinx.coroutines.launch /** * Who the profile's Message and Send Cash shortcuts would open a DM with, or null to leave them @@ -69,6 +73,29 @@ internal fun ChatParticipant.TipUser.dmRoute(openSendCash: Boolean = false) = openSendCash = openSendCash, ) +/** + * What a profile's Share shortcut does: hands the person's `flipcash.com` link to the Sharesheet, + * in the handle form when they have one, as the You tab shares your own. + */ +@Composable +internal fun rememberProfileShare(): (ChatParticipant.TipUser) -> Unit { + val shareController = LocalShareController.current + val scope = rememberCoroutineScope() + return remember(shareController, scope) { + { user -> + scope.launch { + shareController.present( + Shareable.TipCard( + userId = user.userId, + title = user.profile.displayName, + username = user.profile.username, + ) + ) + } + } + } +} + /** * The Message and Send Cash shortcuts under a profile's identity lines, and Share when [onShare] is * set. @@ -83,7 +110,7 @@ internal fun ProfileShortcuts( onMessage: () -> Unit, onSendCash: () -> Unit, modifier: Modifier = Modifier, - // Null leaves Share out. The chat profile has none: the person is already in a conversation. + // Null leaves Share out. onShare: (() -> Unit)? = null, ) { Row( From 0c17a1269bc8ae0ab820ad7c6e8f86a33f3a25fd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 18:03:37 -0400 Subject: [PATCH 5/7] feat(chat): show a linked profile on the chat profile's screen The profile a flipcash.com link opens had its own layout with only the Message and Share shortcuts. It now renders the same screen as a chat's profile, so it has Report and Block, and its rows follow the rules that screen already has. ChatProfileScreen splits into the chat wrapper and PersonProfileScreen, which takes the chat as nullable. With no chat there is no Mute row or muted chip, and the shortcuts show. ProfileViewModel still does the lookup, then hands the person to ChatProfileViewModel, which runs Block. A successful block pops the profile, since there is no chat to leave. --- .../flipcash/app/messenger/ProfileScreen.kt | 84 +++++++++---------- .../screens/profile/ChatProfileScreen.kt | 55 ++++++++++-- .../screens/profile/ProfileShortcuts.kt | 3 +- 3 files changed, 91 insertions(+), 51 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt index 4b7de6ef14..2e5ecfbd59 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt @@ -2,7 +2,6 @@ package com.flipcash.app.messenger import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -15,39 +14,38 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ProfileAddress import com.flipcash.app.core.extensions.navigateAll -import com.flipcash.app.messenger.internal.screens.profile.ProfileHeader -import com.flipcash.app.messenger.internal.screens.profile.ProfileShortcuts +import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel +import com.flipcash.app.messenger.internal.screens.profile.PersonProfileScreen import com.flipcash.app.messenger.internal.screens.profile.ProfileViewModel -import com.flipcash.app.messenger.internal.screens.profile.dmRoute -import com.flipcash.app.messenger.internal.screens.profile.rememberProfileShare import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.theme.CodeScaffold import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance /** - * Another person's profile behind `AppRoute.Messaging.Profile`: the header a chat profile shows, - * with the Message and Share shortcuts under it, for someone the viewer reached by link rather than from a - * conversation. + * Another person's profile behind `AppRoute.Messaging.Profile`, for someone the viewer reached by + * link rather than from a conversation. * - * No rows below the header. Report, block and mute act on a person the viewer has a chat with, - * and they are one tap away in the DM that Message opens. + * 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. */ @Composable fun ProfileScreen(address: ProfileAddress) { - val viewModel = hiltViewModel() + val lookup = hiltViewModel() + val viewModel = hiltViewModel() val navigator = LocalCodeNavigator.current - val share = rememberProfileShare() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val state by lookup.stateFlow.collectAsStateWithLifecycle() - LaunchedEffect(viewModel, address) { - viewModel.dispatchEvent(ProfileViewModel.Event.Load(address)) + LaunchedEffect(lookup, address) { + lookup.dispatchEvent(ProfileViewModel.Event.Load(address)) } - LaunchedEffect(viewModel) { - viewModel.eventFlow + LaunchedEffect(lookup) { + lookup.eventFlow .filter { it is ProfileViewModel.Event.Unavailable || it is ProfileViewModel.Event.OwnProfile } @@ -61,13 +59,26 @@ fun ProfileScreen(address: ProfileAddress) { } } - CodeScaffold( - topBar = { - AppBarWithTitle(onBackIconClicked = { navigator.pop() }) - }, - ) { innerPadding -> - val participant = state.participant - if (participant == null) { + LaunchedEffect(viewModel, state.participant) { + state.participant?.let { + viewModel.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(it)) + } + } + + LaunchedEffect(viewModel) { + // No chat to leave, as a DM's profile does after a block: back to wherever the link was + // opened from. + viewModel.eventFlow + .filterIsInstance() + .collect { navigator.pop() } + } + + if (state.participant == null) { + CodeScaffold( + topBar = { + AppBarWithTitle(onBackIconClicked = { navigator.pop() }) + }, + ) { innerPadding -> Box( modifier = Modifier .fillMaxSize() @@ -80,24 +91,13 @@ fun ProfileScreen(address: ProfileAddress) { modifier = Modifier.size(CodeTheme.dimens.staticGrid.x8), ) } - } else { - ProfileHeader( - participant = participant, - joinDate = participant.profile.joinedAt, - shortcuts = { - ProfileShortcuts( - cashSymbol = state.cashSymbol, - onMessage = { navigator.push(participant.dmRoute()) }, - onSendCash = { navigator.push(participant.dmRoute(openSendCash = true)) }, - onShare = { share(participant) }, - ) - }, - modifier = Modifier - .fillMaxWidth() - .padding(innerPadding) - // The chat profile's top inset, so the two read as the same screen. - .padding(top = CodeTheme.dimens.grid.x7), - ) } + } else { + PersonProfileScreen( + viewModel = viewModel, + chat = null, + cashSymbol = state.cashSymbol, + onBack = { navigator.pop() }, + ) } } 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 0e16256cc3..c4290e5dc8 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 @@ -33,6 +33,7 @@ import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.asSubject import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ViewerState import com.getcode.navigation.flow.rememberFlowNavigator @@ -72,15 +73,53 @@ internal fun ChatProfileScreen( chatViewModel: ChatViewModel, ) { val flowNavigator = rememberFlowNavigator() + val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() + + PersonProfileScreen( + viewModel = viewModel, + chat = ProfileChat( + chatId = chatState.chatId, + chatType = chatState.chatType, + viewerState = chatState.viewerState, + ), + cashSymbol = chatState.cashSymbol, + onBack = { flowNavigator.back() }, + ) +} + +/** + * The conversation a person's profile was opened from, for the parts of the screen that act on the + * chat rather than the person: the mute row and the muted chip. + */ +internal data class ProfileChat( + val chatId: ChatId?, + val chatType: ChatType, + val viewerState: ViewerState?, +) + +/** + * A person's profile, from a chat or from a link: the header, the shortcuts under it, and the + * Mute, Report and Block rows. + * + * [chat] is null when no conversation is behind the screen, as for a `flipcash.com` link. That + * leaves out the mute row and chip, which belong to a chat, and keeps the shortcuts, which a tip + * DM's profile hides. See [ChatProfileScreen] for the rules. + */ +@Composable +internal fun PersonProfileScreen( + viewModel: ChatProfileViewModel, + chat: ProfileChat?, + cashSymbol: String, + onBack: () -> Unit, +) { val navigator = LocalCodeNavigator.current val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() - val isTipDm = chatState.chatType == ChatType.TIP_DM + val isTipDm = chat?.chatType == ChatType.TIP_DM val share = rememberProfileShare() CodeScaffold( topBar = { - AppBarWithTitle(onBackIconClicked = { flowNavigator.back() }) + AppBarWithTitle(onBackIconClicked = onBack) }, ) { innerPadding -> MenuList( @@ -100,20 +139,20 @@ internal fun ChatProfileScreen( header = { val recipient = profileShortcutRecipient( participant = state.participant, - chatType = chatState.chatType, + chatType = chat?.chatType, selfId = state.selfId, ) ProfileHeader( participant = state.participant, joinDate = state.joinDate, // Only where the mute is this person's chat; see the KDoc above. - viewerState = chatState.viewerState.takeIf { isTipDm }, + viewerState = chat?.viewerState?.takeIf { isTipDm }, // Not flowNavigator, for the reason Report isn't: the DM is a top-level route, // so LocalCodeNavigator hands it up and it opens over this chat. shortcuts = recipient?.let { user -> { ProfileShortcuts( - cashSymbol = chatState.cashSymbol, + cashSymbol = cashSymbol, onMessage = { navigator.push(user.dmRoute()) }, onSendCash = { navigator.push(user.dmRoute(openSendCash = true)) }, onShare = { share(user) }, @@ -148,8 +187,8 @@ internal fun ChatProfileScreen( // navigates either way rather than acting on one of them here. The outer // navigator, as with Report: the sheet is a top-level route shared with the // chat list, so it opens over the chat rather than inside it. - ChatProfileAction.Mute -> chatState.chatId?.let { chatId -> - navigator.push(AppRoute.Messaging.MuteChat(chatId, chatState.chatType)) + ChatProfileAction.Mute -> chat?.chatId?.let { chatId -> + navigator.push(AppRoute.Messaging.MuteChat(chatId, chat.chatType)) } // Not flowNavigator: Report is a top-level route rather than a step of // this flow, and LocalCodeNavigator hands a non-FlowStep route up to its diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt index 072248b2ca..6e1f46bfae 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt @@ -54,7 +54,8 @@ import kotlinx.coroutines.launch */ internal fun profileShortcutRecipient( participant: ChatParticipant?, - chatType: ChatType, + // Null when no chat is behind the profile, as for one a link opened. + chatType: ChatType?, selfId: ID?, ): ChatParticipant.TipUser? { if (chatType == ChatType.TIP_DM) return null From a69c032f1f4f569e160d5c5efe5cb4b41255bd93 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 18:06:40 -0400 Subject: [PATCH 6/7] fix(chat): don't refetch a linked profile's join date ChatProfileViewModel fetches the person's profile again to get a join date the chat's cached copy lacks. A profile a link opened was fetched a moment earlier, so that second request was redundant. OnParticipantSet now takes fromServer, and ProfileScreen sets it to use the join date it already has. --- .../flipcash/app/messenger/ProfileScreen.kt | 4 +- .../screens/profile/ChatProfileViewModel.kt | 23 +++-- .../profile/ChatProfileViewModelTest.kt | 85 +++++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt index 2e5ecfbd59..23ea0a1db5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt @@ -61,7 +61,9 @@ fun ProfileScreen(address: ProfileAddress) { LaunchedEffect(viewModel, state.participant) { state.participant?.let { - viewModel.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(it)) + viewModel.dispatchEvent( + ChatProfileViewModel.Event.OnParticipantSet(it, fromServer = true) + ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt index 2c3be22505..66dcbd8a9c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -18,6 +18,7 @@ import com.getcode.view.LoadingSuccessState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -50,7 +51,14 @@ internal class ChatProfileViewModel @Inject constructor( ) sealed interface Event { - data class OnParticipantSet(val participant: ChatParticipant) : Event + /** + * [fromServer] says the participant's profile was just fetched, as a link's lookup does, so + * its join date is current and there is no need to fetch it again. + */ + data class OnParticipantSet( + val participant: ChatParticipant, + val fromServer: Boolean = false, + ) : Event data class JoinDateLoaded(val joinDate: Instant?) : Event data object BlockUser : Event data class BlockConfirmed(val participant: ChatParticipant.TipUser) : Event @@ -61,14 +69,17 @@ internal class ChatProfileViewModel @Inject constructor( init { eventFlow .filterIsInstance() - .map { it.participant } - .filterIsInstance() + .filter { it.participant is ChatParticipant.TipUser } .distinctUntilChanged() - .onEach { (userId, profile) -> + .onEach { event -> + val (userId, profile) = event.participant as ChatParticipant.TipUser // The cached member profile doesn't carry a join date, so resolve it from the // server profile, falling back to whatever the participant already had. - val joinDate = profiles.getProfileForUser(userId).getOrNull()?.joinedAt - ?: profile.joinedAt + val joinDate = if (event.fromServer) { + profile.joinedAt + } else { + profiles.getProfileForUser(userId).getOrNull()?.joinedAt ?: profile.joinedAt + } dispatchEvent(Event.JoinDateLoaded(joinDate)) } .launchIn(viewModelScope) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt new file mode 100644 index 0000000000..f7f1e9df89 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt @@ -0,0 +1,85 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import com.flipcash.app.blocklist.BlocklistCoordinator +import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.UserProfile +import com.flipcash.services.user.UserManager +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** Where a person's profile gets its join date from. */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatProfileViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val theirId: List = List(16) { 2 } + private val cachedJoin = Instant.fromEpochSeconds(1_700_000_000) + private val serverJoin = Instant.fromEpochSeconds(1_710_000_000) + + private val profiles = mockk() + private val userManager = mockk { every { accountId } returns List(16) { 1 } } + + private fun viewModel() = ChatProfileViewModel( + contactCoordinator = mockk(relaxed = true), + userManager = userManager, + featureFlags = mockk(relaxed = true), + blocklist = mockk(relaxed = true), + profiles = profiles, + dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), + resources = mockk(relaxed = true), + ) + + private fun participant(joinedAt: Instant?) = ChatParticipant.TipUser( + userId = theirId, + profile = UserProfile( + displayName = "Sally", + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + joinedAt = joinedAt, + userId = theirId, + username = "sally_streamer", + ), + ) + + @Test + fun `a chat's cached participant fetches the join date`() = runTest { + coEvery { profiles.getProfileForUser(theirId) } returns + Result.success(participant(serverJoin).profile) + val model = viewModel() + + model.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant(cachedJoin))) + + assertEquals(serverJoin, model.stateFlow.value.joinDate) + coVerify(exactly = 1) { profiles.getProfileForUser(theirId) } + } + + @Test + fun `a freshly fetched profile keeps its join date without fetching again`() = runTest { + val model = viewModel() + + model.dispatchEvent( + ChatProfileViewModel.Event.OnParticipantSet(participant(serverJoin), fromServer = true) + ) + + assertEquals(serverJoin, model.stateFlow.value.joinDate) + coVerify(exactly = 0) { profiles.getProfileForUser(any()) } + } +} From 2f9b20c0e7f0f849f4011911f372ac7436b83d43 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 18:10:10 -0400 Subject: [PATCH 7/7] feat(chat): show Share on every person's profile Share followed Message's rule, so a tip DM's profile and your own had none. It now shows for any person with a link: the DM's counterpart, a group member, yourself, and a profile a link opened. Message and Send Cash keep profileShortcutRecipient's rule, so ProfileShortcuts takes onMessage as nullable. --- .../screens/profile/ChatProfileScreen.kt | 21 ++++++++----- .../screens/profile/ProfileShortcuts.kt | 31 ++++++++++--------- 2 files changed, 29 insertions(+), 23 deletions(-) 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 c4290e5dc8..51d290363c 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 @@ -63,9 +63,9 @@ import kotlin.time.Instant * never reach this screen ([com.flipcash.app.messenger.internal.ChatSubject.Contact] answers * `canViewProfile` false). * - * The Message, Send Cash and Share shortcuts follow the same split, the other way round: they show on a + * The Message and Send Cash shortcuts follow the same split, the other way round: they show on a * group member's profile and not on a tip DM's, where they would only reopen the chat behind it. - * [profileShortcutRecipient] has the whole rule. + * [profileShortcutRecipient] has the whole rule. Share shows on every person's profile. */ @Composable internal fun ChatProfileScreen( @@ -102,8 +102,8 @@ internal data class ProfileChat( * Mute, Report and Block rows. * * [chat] is null when no conversation is behind the screen, as for a `flipcash.com` link. That - * leaves out the mute row and chip, which belong to a chat, and keeps the shortcuts, which a tip - * DM's profile hides. See [ChatProfileScreen] for the rules. + * leaves out the mute row and chip, which belong to a chat, and keeps Message, which a tip DM's + * profile hides. See [ChatProfileScreen] for the rules. */ @Composable internal fun PersonProfileScreen( @@ -137,6 +137,7 @@ internal fun PersonProfileScreen( add(BlockUser) }, header = { + val person = state.participant as? ChatParticipant.TipUser val recipient = profileShortcutRecipient( participant = state.participant, chatType = chat?.chatType, @@ -149,12 +150,16 @@ internal fun PersonProfileScreen( viewerState = chat?.viewerState?.takeIf { isTipDm }, // Not flowNavigator, for the reason Report isn't: the DM is a top-level route, // so LocalCodeNavigator hands it up and it opens over this chat. - shortcuts = recipient?.let { user -> + // Share for anyone with a link; Message only where profileShortcutRecipient + // allows it. + shortcuts = person?.let { user -> { ProfileShortcuts( cashSymbol = cashSymbol, - onMessage = { navigator.push(user.dmRoute()) }, - onSendCash = { navigator.push(user.dmRoute(openSendCash = true)) }, + onMessage = recipient?.let { { navigator.push(it.dmRoute()) } }, + onSendCash = { + recipient?.let { navigator.push(it.dmRoute(openSendCash = true)) } + }, onShare = { share(user) }, ) } @@ -171,7 +176,7 @@ internal fun PersonProfileScreen( // None under the shortcuts: the first row's own 25dp inset is the // gap, and ProfileHeader matches it above them so they sit centered // between the join date and the list. - bottom = if (recipient != null) { + bottom = if (person != null) { 0.dp } else { CodeTheme.dimens.grid.x8 diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt index 6e1f46bfae..a99684afe4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt @@ -41,7 +41,7 @@ import kotlinx.coroutines.launch /** * Who the profile's Message and Send Cash shortcuts would open a DM with, or null to leave them - * out. + * out. Share doesn't follow this rule: every person's profile has it. * * - Not from a tip DM: a DM's profile is always the other person in it, so the shortcuts would * only reopen the chat the viewer came from. @@ -98,8 +98,8 @@ internal fun rememberProfileShare(): (ChatParticipant.TipUser) -> Unit { } /** - * The Message and Send Cash shortcuts under a profile's identity lines, and Share when [onShare] is - * set. + * The Message, Send Cash and Share shortcuts under a profile's identity lines. A null callback + * leaves its shortcut out. * * Two fixed-width columns, so the pair stays centered and the circles stay the same distance apart * whatever the labels say. The Send Cash glyph is [cashSymbol] rather than an icon, so it names the @@ -108,26 +108,27 @@ internal fun rememberProfileShare(): (ChatParticipant.TipUser) -> Unit { @Composable internal fun ProfileShortcuts( cashSymbol: String, - onMessage: () -> Unit, + onMessage: (() -> Unit)?, onSendCash: () -> Unit, modifier: Modifier = Modifier, - // Null leaves Share out. onShare: (() -> Unit)? = null, ) { Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3), ) { - ProfileShortcut( - label = stringResource(R.string.action_message), - onClick = onMessage, - ) { - Icon( - painter = painterResource(R.drawable.ic_chat_bubble), - contentDescription = null, - tint = CodeTheme.colors.textMain, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), - ) + if (onMessage != null) { + ProfileShortcut( + label = stringResource(R.string.action_message), + onClick = onMessage, + ) { + Icon( + painter = painterResource(R.drawable.ic_chat_bubble), + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + ) + } } // Send Cash is held back for now. The route flag and the chat's once-ready dispatch behind // it stay wired, so bringing it back is uncommenting this.