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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -116,6 +117,7 @@ fun appEntryProvider(
annotatedEntry<AppRoute.Messaging.Chat> { key ->
ChatFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
annotatedEntry<AppRoute.Messaging.Profile> { key -> ProfileScreen(key.address) }
annotatedEntry<AppRoute.Messaging.NewChat> { NewChatScreen() }
annotatedEntry<AppRoute.Messaging.FindByUsername> { FindByUsernameScreen() }
annotatedEntry<AppRoute.Messaging.NewGroup> { key ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -68,7 +72,7 @@ sealed interface DeeplinkType: Parcelable {
is TokenInfo,
is TipChat,
is Tipcard,
is TipcardByUsername,
is Profile,
is GroupChatInvite,
-> true

Expand Down
4 changes: 4 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1194,6 +1194,9 @@
<string name="error_description_usernameNotFound">Nobody has claimed \@%1$s</string>
<string name="error_title_tipCardUnavailable">Couldn\'t Open Tip Card</string>
<string name="error_description_tipCardUnavailable">Please check your connection and try again</string>
<!-- A tapped `flipcash.com/{username}` or `flipcash.com/{uuid}` link whose profile couldn't be fetched. -->
<string name="error_title_profileUnavailable">Couldn\'t Open Profile</string>
<string name="error_description_profileUnavailable">Please check your connection and try again</string>

<string name="error_title_profilePhotoNotAllowed">This Photo is Not Allowed</string>
<string name="error_description_profilePhotoNotAllowedFlaggedOther">AI flagged this photo. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X</string>
Expand Down Expand Up @@ -1240,6 +1243,7 @@
<!-- The profile's two shortcuts into the DM with this person. "Send Cash" beside it is
action_sendCash. -->
<string name="action_message">Message</string>
<string name="action_shareProfile">Share</string>

<string name="error_title_failedToBlock">Something Went Wrong</string>
<string name="error_description_failedToBlock">We were unable to block the user. Please try again</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<ProfileViewModel>()
val viewModel = hiltViewModel<ChatProfileViewModel>()
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<ChatProfileViewModel.Event.BlockSuccessful>()
.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() },
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,22 +84,26 @@ 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/<uuid>`, or the legacy
// `/tip/<uuid>`. Whether anyone owns the id is the lookup's question.
// A person's link by id, the legacy `/tip/<uuid>`. Whether anyone owns the id is the
// lookup's question.
is DeeplinkType.Tipcard -> LinkCard.User(
url = target,
start = link.start,
end = link.end,
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/<uuid>`. 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
)
)
}
Expand Down
Loading
Loading