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..f416367b89 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 @@ -1240,6 +1243,7 @@ Message + Share Something Went Wrong We were unable to block the user. Please try again 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..23ea0a1db5 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ProfileScreen.kt @@ -0,0 +1,105 @@ +package com.flipcash.app.messenger + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +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.ChatProfileViewModel +import com.flipcash.app.messenger.internal.screens.profile.PersonProfileScreen +import com.flipcash.app.messenger.internal.screens.profile.ProfileViewModel +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`, for someone the viewer reached by + * link rather than from a conversation. + * + * 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 lookup = hiltViewModel() + val viewModel = hiltViewModel() + val navigator = LocalCodeNavigator.current + val state by lookup.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(lookup, address) { + lookup.dispatchEvent(ProfileViewModel.Event.Load(address)) + } + + LaunchedEffect(lookup) { + lookup.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)) + } + } + } + + LaunchedEffect(viewModel, state.participant) { + state.participant?.let { + viewModel.dispatchEvent( + ChatProfileViewModel.Event.OnParticipantSet(it, fromServer = true) + ) + } + } + + 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() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + CodeCircularProgressIndicator( + strokeWidth = CodeTheme.dimens.thickBorder, + color = CodeTheme.colors.textSecondary, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x8), + ) + } + } + } 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/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/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)) ) ) } 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..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 @@ -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 @@ -34,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 @@ -65,7 +65,7 @@ import kotlin.time.Instant * * 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( @@ -73,14 +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 Message, 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( @@ -98,24 +137,30 @@ internal fun ChatProfileScreen( add(BlockUser) }, header = { + val person = state.participant as? ChatParticipant.TipUser 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 -> + // Share for anyone with a link; Message only where profileShortcutRecipient + // allows it. + shortcuts = person?.let { user -> { ProfileShortcuts( - cashSymbol = chatState.cashSymbol, - onMessage = { navigator.push(user.dmRoute()) }, - onSendCash = { navigator.push(user.dmRoute(openSendCash = true)) }, + cashSymbol = cashSymbol, + onMessage = recipient?.let { { navigator.push(it.dmRoute()) } }, + onSendCash = { + recipient?.let { navigator.push(it.dmRoute(openSendCash = true)) } + }, + onShare = { share(user) }, ) } }, @@ -131,7 +176,7 @@ internal fun ChatProfileScreen( // 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 @@ -147,8 +192,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 @@ -253,9 +298,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/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/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..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 @@ -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 @@ -25,17 +26,22 @@ 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 +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 - * 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. @@ -48,7 +54,8 @@ import com.getcode.theme.CodeTheme */ 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 @@ -58,7 +65,41 @@ internal fun profileShortcutRecipient( } /** - * The Message and Send Cash shortcuts under a profile's identity lines. + * 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, + ) + +/** + * 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, 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 @@ -67,24 +108,27 @@ internal fun profileShortcutRecipient( @Composable internal fun ProfileShortcuts( cashSymbol: String, - onMessage: () -> Unit, + onMessage: (() -> Unit)?, onSendCash: () -> Unit, modifier: Modifier = Modifier, + 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. @@ -100,6 +144,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), + ) + } + } } } 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/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()) } + } +} 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()