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 0f87080e7..2ce56b65c 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 @@ -43,6 +43,7 @@ import com.flipcash.app.messenger.ChatPhotoViewerScreen import com.flipcash.app.messenger.E2eeLearnMoreScreen import com.flipcash.app.messenger.MuteChatScreen import com.flipcash.app.messenger.ProfileScreen +import com.flipcash.app.messenger.ShareProfileToChatsScreen import com.flipcash.app.messenger.ReportFlowScreen import com.flipcash.app.discovery.TokenDiscoveryScreen import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator @@ -143,6 +144,7 @@ fun appEntryProvider( annotatedEntry { key -> ReportFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } + annotatedEntry { key -> ShareProfileToChatsScreen(key) } annotatedEntry { key -> MuteChatScreen(key.chatId, key.chatType) } annotatedEntry { key -> ChatPhotoViewerScreen(key.chatId, key.messageId) } annotatedEntry { E2eeLearnMoreScreen(forGroup = false) } 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 4c7a7eaa1..211d3e958 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 @@ -466,6 +466,26 @@ sealed interface AppRoute : NavKey, Parcelable { com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet + /** + * A person's profile link, shared by the system share sheet, copied, or sent into recent + * chats. Opened from the viewer's own profile and from someone else's, so it carries what + * the link and the share card are built from. + * + * @param directChatId the viewer's DM with the person, left out of the picker so nobody is + * sent their own profile. Null for the viewer's own profile, and for a person with no DM + * yet. + * + * Full screen, as iOS presents it as a cover. + */ + @Serializable + @Parcelize + data class ShareProfileToChats( + val userId: ID, + val username: String?, + val displayName: String?, + val directChatId: ChatId?, + ) : Messaging, com.getcode.navigation.Sheet, com.getcode.navigation.FullscreenSheet + /** * What end-to-end encryption covers in a DM, opened from the DM profile's footer. A * [com.getcode.navigation.WrapContentSheet], for the reason [MuteChat] is one. diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index 1f308f900..0c505a126 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -55,11 +55,16 @@ sealed interface ChatStep : FlowStep, Parcelable { * * Not a [WrapContentSheet]: the chat list can run past the screen, and a wrap-content body is * measured at its full height and then clipped, taking the message bar under it off the bottom. - * A full sheet bounds the body, so the list scrolls and the bar stays on screen. + * A full sheet bounds the body, so the list scrolls and the bar stays on screen. Full screen, + * as on iOS, which presents it as a cover. */ @Parcelize @Serializable - data object InviteToGroup : ChatStep, NavigationRetVal, Sheet + data object InviteToGroup : + ChatStep, + NavigationRetVal, + Sheet, + com.getcode.navigation.FullscreenSheet @Parcelize @Serializable diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index f9adca01a..4bd194596 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1337,8 +1337,8 @@ Claim your username Not set Share User Profile + Share Profile - %1$s · @%2$s Share Profile Show Profile Card Copy Link diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileShareScreen.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileShareScreen.kt index e6a7f4ad1..24b5ee594 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileShareScreen.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileShareScreen.kt @@ -10,51 +10,53 @@ import com.flipcash.features.menu.R import com.flipcash.shared.common.ui.profile.ProfileShareRow import com.flipcash.shared.common.ui.profile.ProfileShareSheet import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher private const val RowShare = "share" private const val RowCard = "card" -private const val RowCopy = "copy" /** * The share sheet for the viewer's own profile, behind `AppRoute.Menu.ShareProfile`. * * A row does not act on the tap. It asks the navigator to animate the sheet away and runs the - * action once that has finished, so the system share sheet or the card screen is not opened over a - * sheet that is still on its way out. + * action once that has finished, so the card screen or the share-to-chats sheet is not opened over + * a sheet that is still on its way out. */ @Composable fun ProfileShareScreen() { val viewModel = hiltViewModel() val navigator = LocalCodeNavigator.current + val dismissSheet = LocalBottomSheetDismissDispatcher.current val profile = remember(viewModel) { viewModel.profile } - val name = profile?.displayName.orEmpty() - val handle = profile?.username - val subtitle = if (handle.isNullOrEmpty()) { - name - } else { - stringResource(R.string.subtitle_shareUserProfile, name, handle) - } val rows = listOf( ProfileShareRow(RowShare, R.drawable.ic_share_os, stringResource(R.string.action_shareProfileLink)), ProfileShareRow(RowCard, R.drawable.ic_qr_code, stringResource(R.string.action_showProfileCard)), - ProfileShareRow(RowCopy, R.drawable.ic_copy, stringResource(R.string.action_copyLink)), ) ProfileShareSheet( title = stringResource(R.string.title_shareUserProfile), - subtitle = subtitle, rows = rows, onRow = { row -> // A second tap while the sheet animates out would replace the pending action and run both. if (navigator.pendingSheetDismiss != null) return@ProfileShareSheet navigator.pendingSheetDismiss = { when (row.id) { - RowShare -> viewModel.share() + RowShare -> viewModel.currentUserId?.let { userId -> + navigator.push( + AppRoute.Messaging.ShareProfileToChats( + userId = userId, + username = profile?.username, + displayName = profile?.displayName, + // Your own profile has no DM to leave out. + directChatId = null, + ) + ) + } RowCard -> navigator.push(AppRoute.Menu.ProfileCard) - RowCopy -> viewModel.copyLink() } } }, + onDismiss = dismissSheet, ) } diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileShareViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileShareViewModel.kt index 56946d859..9e37aa9dc 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileShareViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileShareViewModel.kt @@ -1,78 +1,25 @@ package com.flipcash.app.menu.internal -import android.content.ClipboardManager import androidx.lifecycle.ViewModel -import com.flipcash.app.bills.share.TipCodePreviewCache -import com.flipcash.app.core.extensions.setText -import com.flipcash.app.core.tipping.TipCardOwner -import com.flipcash.app.core.toast.SystemToastController -import com.flipcash.app.core.util.Linkify -import com.flipcash.app.shareable.ShareSheetController -import com.flipcash.app.shareable.Shareable -import com.flipcash.features.menu.R import com.flipcash.services.models.UserProfile import com.flipcash.services.user.UserManager import com.flipcash.shared.tipping.TippingCoordinator -import com.getcode.util.resources.ResourceHelper +import com.getcode.opencode.model.core.ID import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch import javax.inject.Inject -/** - * What the share sheet's rows do for the viewer's own profile. The sheet only chooses; the - * navigation host runs one of these once the sheet has finished leaving. - */ +/** What the You tab's share sheet needs to address the viewer's own profile. */ @HiltViewModel internal class ProfileShareViewModel @Inject constructor( private val userManager: UserManager, private val tippingCoordinator: TippingCoordinator, - private val tipCodePreviewCache: TipCodePreviewCache, - private val shareable: ShareSheetController, - private val clipboardManager: ClipboardManager, - private val toastController: SystemToastController, - private val resources: ResourceHelper, ) : ViewModel() { - // Not viewModelScope: the actions run after the sheet has finished leaving, and this view model - // is cleared with the sheet's entry before then. Each action is short and holds nothing but - // singletons, so there is nothing to leak by letting it finish. - private val actionScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - /** The profile being shared, read once when the sheet opens. */ val profile: UserProfile? get() = userManager.state.value.userProfile - /** The link a profile is shared by. Null only when there is no signed-in user to address. */ - private val link: String? - get() = tippingCoordinator.currentUserId?.let { userId -> - Linkify.tipcard(TipCardOwner.preferringUsername(profile?.username, userId)) - } - - /** Hands the profile link, with its card preview if ready, to the system share sheet. */ - fun share() { - val userId = tippingCoordinator.currentUserId ?: return - // Attach the eagerly-rendered preview if it's ready; null shares the URL alone. - actionScope.launch { - shareable.present( - Shareable.Profile( - userId = userId, - displayName = profile?.displayName, - username = profile?.username, - preview = tipCodePreviewCache.get(userId), - ) - ) - } - } - - fun copyLink() { - val link = link ?: return - clipboardManager.setText( - text = link, - label = resources.getString(R.string.title_clipboardLabelTipCardLink), - ) - toastController.showToast(R.string.action_copied, replacePrevious = true) - } + /** Null only when there is no signed-in user to address. */ + val currentUserId: ID? + get() = tippingCoordinator.currentUserId } diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 4ebd4e40b..756ccc898 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) testImplementation(testFixtures(project(":libs:coroutines"))) implementation(project(":apps:flipcash:shared:blob")) + implementation(project(":apps:flipcash:shared:bills")) implementation(project(":apps:flipcash:shared:blocklist")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:chat-ui")) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 19666e9e8..550aa4ad6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -25,6 +25,7 @@ import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.GroupInviteViewModel import com.flipcash.app.messenger.internal.screens.GroupInviteSheet +import com.flipcash.app.messenger.internal.screens.ShareToChatsSubject import com.flipcash.app.messenger.internal.screens.MessengerScreen import com.flipcash.app.messenger.internal.screens.EmojiPickerViewModel import com.flipcash.app.messenger.internal.screens.ReactionPickerSheet @@ -232,7 +233,7 @@ private fun FlowGroupInviteSheet() { val dismissSheet = LocalBottomSheetDismissDispatcher.current LaunchedEffect(inviteViewModel, state.chatId) { - state.chatId?.let(inviteViewModel::inviteTo) + state.chatId?.let(inviteViewModel::exclude) } // Returned to whichever screen opened the sheet (see openGroupInvite), which navigates. The @@ -248,7 +249,7 @@ private fun FlowGroupInviteSheet() { GroupInviteSheet( inviteUrl = state.groupInviteUrl, - group = state.subject as? ChatSubject.Group, + subject = ShareToChatsSubject.Group(state.subject as? ChatSubject.Group), state = inviteState, onShare = { viewModel.dispatchEvent(ChatViewModel.Event.InviteLinkShared) }, onCopy = { viewModel.dispatchEvent(ChatViewModel.Event.CopyInviteLink) }, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt new file mode 100644 index 000000000..52bd51fdb --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ShareProfileToChatsScreen.kt @@ -0,0 +1,73 @@ +package com.flipcash.app.messenger + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.tipping.TipCardOwner +import com.flipcash.app.core.util.Linkify +import com.flipcash.app.messenger.internal.GroupInviteViewModel +import com.flipcash.app.messenger.internal.screens.GroupInviteSheet +import com.flipcash.app.messenger.internal.screens.ShareToChatsSubject +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher +import com.getcode.ui.utils.rememberKeyboardController + +/** + * A person's profile link, shared by the system share sheet, copied, or sent into recent chats, + * behind `AppRoute.Messaging.ShareProfileToChats`. + * + * Opened from the root navigator by the viewer's own profile and by someone else's, so it names the + * chat to leave out on the route: the DM with that person, or nothing for the viewer's own. + */ +@Composable +fun ShareProfileToChatsScreen(route: AppRoute.Messaging.ShareProfileToChats) { + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val navigator = LocalCodeNavigator.current + // Exit through the sheet so it animates down rather than having its scene deleted mid-frame. + val dismissSheet = LocalBottomSheetDismissDispatcher.current + val keyboard = rememberKeyboardController() + + val link = remember(route.userId, route.username) { + Linkify.tipcard(TipCardOwner.preferringUsername(route.username, route.userId)) + } + val preview = remember(viewModel, route.userId) { viewModel.previewFor(route.userId) } + + LaunchedEffect(viewModel, route.directChatId) { viewModel.exclude(route.directChatId) } + + // The chat is opened once the sheet is gone, so it is not pushed under one still on its way + // out. The keyboard goes first: the message field has it up, and leaving with it still open + // drags the screen behind out from under it. + LaunchedEffect(viewModel) { + viewModel.invited.collect { chatId -> + keyboard.hideIfVisible { + navigator.pendingSheetDismiss = { + navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) + } + } + } + } + + GroupInviteSheet( + inviteUrl = link, + subject = ShareToChatsSubject.User( + userId = route.userId, + username = route.username, + displayName = route.displayName, + preview = preview, + ), + state = state, + // A person's link has no group analytics to fire. + onShare = {}, + onCopy = { viewModel.copyProfileLink(link) }, + onToggle = viewModel::toggle, + onMessageChanged = viewModel::onMessageChanged, + onInvite = { viewModel.invite(link) }, + onDismiss = dismissSheet, + ) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt index 9355064c9..750d2c271 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt @@ -1,13 +1,19 @@ package com.flipcash.app.messenger.internal +import android.content.ClipboardManager import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flipcash.app.bills.share.TipCodePreviewCache +import com.flipcash.app.core.extensions.setText +import com.flipcash.app.core.share.TipCodePreview +import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.chatListFeed import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.chat.ui.toConversationReference +import com.getcode.opencode.model.core.ID import com.getcode.util.resources.ResourceHelper import com.getcode.utils.trace import dagger.hilt.android.lifecycle.HiltViewModel @@ -28,7 +34,8 @@ import javax.inject.Inject /** * The invite sheet's "Recent Chats" (nodes 10330:19387, 10330:19549, 10329:12104): which chats, - * 1:1 or group, are picked, the message to go with the link, and sending both to each. + * 1:1 or group, are picked, the message to go with the link, and sending both to each. It serves a + * group's invite and a person's profile link alike; they differ only in which chat is left out. * * Scoped to the sheet rather than kept on the conversation's view model: a selection means nothing * once the sheet is gone, and the send has to outlive nothing but the sheet, which waits for it. @@ -38,13 +45,18 @@ internal class GroupInviteViewModel @Inject constructor( private val chatCoordinator: ChatCoordinator, private val userManager: UserManager, private val resources: ResourceHelper, + private val clipboardManager: ClipboardManager, + private val tipCodePreviewCache: TipCodePreviewCache, ) : ViewModel() { data class State( /** Null until the feed first emits, so an empty list means there are no chats to offer. */ val recentChats: List? = null, - /** The group the invites are for, which is left out of its own list. */ - val groupId: ChatId? = null, + /** + * The chat that is left out of the list: the group being invited to, or the DM with the + * person being shared. Null for the viewer's own profile, which leaves nothing out. + */ + val excludedChatId: ChatId? = null, /** Picked chats in the order they were tapped. The first is where the send lands. */ val selection: List = emptyList(), val message: String = "", @@ -53,9 +65,12 @@ internal class GroupInviteViewModel @Inject constructor( /** The message bar only comes up once there is someone to send to (node 10330:19549). */ val showsComposer: Boolean get() = selection.isNotEmpty() - /** [recentChats] without the group being invited to: sending a group its own link is a no-op. */ + /** + * [recentChats] without [excludedChatId]: sending a group its own link is a no-op, and + * sending a person their own profile is pointless. + */ val invitable: List? - get() = recentChats?.filterNot { it.chatId == groupId } + get() = recentChats?.filterNot { it.chatId == excludedChatId } } private val _state = MutableStateFlow(State()) @@ -81,14 +96,27 @@ internal class GroupInviteViewModel @Inject constructor( } } - /** Names the group the sheet invites to, so it drops out of the list and cannot be picked. */ - fun inviteTo(groupId: ChatId) { - _state.update { it.copy(groupId = groupId, selection = it.selection - groupId) } + /** Names the chat the sheet leaves out, so it drops out of the list and cannot be picked. */ + fun exclude(chatId: ChatId?) { + _state.update { state -> + state.copy(excludedChatId = chatId, selection = state.selection.filterNot { it == chatId }) + } + } + + /** The rendered share-sheet card for [userId], if one is ready. Null shares the link alone. */ + fun previewFor(userId: ID): TipCodePreview? = tipCodePreviewCache.get(userId) + + /** Puts a person's profile [link] on the clipboard. */ + fun copyProfileLink(link: String) { + clipboardManager.setText( + text = link, + label = resources.getString(R.string.title_clipboardLabelTipCardLink), + ) } fun toggle(chatId: ChatId) { _state.update { state -> - if (state.sending || chatId == state.groupId) return@update state + if (state.sending || chatId == state.excludedChatId) return@update state val selection = if (chatId in state.selection) { state.selection - chatId } else { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index 31fcbb8ae..c75f861de 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -65,6 +65,7 @@ import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.chat.GroupInviteResult import com.flipcash.app.core.media.rememberMediaUrl import com.flipcash.app.core.share.SharePreviewImage +import com.flipcash.app.core.share.TipCodePreview import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.GroupInviteViewModel import com.flipcash.app.shareable.LocalShareController @@ -74,6 +75,7 @@ import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.opencode.model.core.ID import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.results.NavResultOrCanceled import com.getcode.navigation.results.navigateForResult @@ -112,25 +114,47 @@ internal fun CodeNavigator.openGroupInvite() { } /** - * Node 10329:12104 — handing out the link to a group, and sending it straight into the + * What [GroupInviteSheet] hands out: a group's invite link, or a person's profile link. The two + * differ in their copy, in what the Share tile gives the system share sheet, and in whether the + * group's analytics fire; the rest of the sheet is the same. + */ +internal sealed interface ShareToChatsSubject { + /** + * @param group what is being invited to, for the group's name and picture. The sheet only opens + * on a group, so null is the frame before the subject resolves. + */ + data class Group(val group: ChatSubject.Group?) : ShareToChatsSubject + + /** + * @param preview the tip-code image for the system share sheet's card, when one is rendered. + */ + data class User( + val userId: ID, + val username: String?, + val displayName: String?, + val preview: TipCodePreview?, + ) : ShareToChatsSubject +} + +/** + * Node 10329:12104 — handing out a link to a group or a person, and sending it straight into the * viewer's other chats. * - * The Share and Copy tiles carry the same URL, built once by - * [com.flipcash.app.core.util.Linkify.groupChatInvite] from the chat's id: there is no invite RPC, - * the id *is* the invite, and one builder is what keeps the shared link, the copied link and the - * sent link identical. + * The Share and Copy tiles carry the same URL, built once by the caller: for a group by + * [com.flipcash.app.core.util.Linkify.groupChatInvite] from the chat's id (there is no invite RPC, + * the id *is* the invite), for a person by [com.flipcash.app.core.util.Linkify.tipcard]. One + * builder is what keeps the shared link, the copied link and the sent link identical. * * @param inviteUrl null for anyone who has nobody to invite — a DM, or a group this viewer has not * joined — in which case the sheet is the title bar alone. Both entry points are gated on the same * value, so this is a race with a leave rather than a state to design for. - * @param group what is being invited to, for the group's name and picture. The sheet only opens on - * a group, so null is the frame before the subject resolves. + * @param subject what is being handed out. * @param state the Recent Chats selection and the message to send with the link. */ @Composable internal fun GroupInviteSheet( inviteUrl: String?, - group: ChatSubject.Group?, + subject: ShareToChatsSubject, state: GroupInviteViewModel.State, onShare: () -> Unit, onCopy: () -> Unit, @@ -145,6 +169,7 @@ internal fun GroupInviteSheet( // Resolved here rather than in the share controller: the stored download URL expires, and // re-minting it needs the chat's profile to authorize it — neither of which the shareable // module can see. Same size and access as the avatar the conversation bar draws. + val group = (subject as? ShareToChatsSubject.Group)?.group val picture = group?.picture val pictureUrl = rememberMediaUrl( media = picture, @@ -214,13 +239,21 @@ internal fun GroupInviteSheet( onShare() scope.launch { shareController.present( - Shareable.GroupInvite( - url = inviteUrl, - title = group?.groupTitle, - imageUrl = pictureUrl.url, - imageCacheKey = picture - ?.cacheKeyForSize(SharePreviewImage.TARGET_PX), - ) + when (subject) { + is ShareToChatsSubject.Group -> Shareable.GroupInvite( + url = inviteUrl, + title = group?.groupTitle, + imageUrl = pictureUrl.url, + imageCacheKey = picture + ?.cacheKeyForSize(SharePreviewImage.TARGET_PX), + ) + is ShareToChatsSubject.User -> Shareable.Profile( + userId = subject.userId, + displayName = subject.displayName, + username = subject.username, + preview = subject.preview, + ) + } ) } }, @@ -229,8 +262,11 @@ internal fun GroupInviteSheet( modifier = Modifier.weight(1f), icon = if (copied) R.drawable.ic_check else R.drawable.ic_copy, label = stringResource( - if (copied) R.string.action_inviteLinkCopied - else R.string.action_copyInviteLink + when { + copied -> R.string.action_inviteLinkCopied + subject is ShareToChatsSubject.User -> R.string.action_copyLink + else -> R.string.action_copyInviteLink + } ), onClick = { onCopy() @@ -292,7 +328,9 @@ internal fun GroupInviteSheet( ) } .onSizeChanged { headerHeight = with(density) { it.height.toDp() } }, - title = stringResource(R.string.title_invitePeople), + title = stringResource( + if (subject is ShareToChatsSubject.User) R.string.title_shareProfileToChats else R.string.title_invitePeople + ), titleAlignment = Alignment.CenterHorizontally, contentPadding = PaddingValues(HeaderPadding), endContent = { AppBarDefaults.Close(hazeState = hazeState, onClick = onDismiss) }, @@ -318,6 +356,9 @@ internal fun GroupInviteSheet( .onSizeChanged { fieldHeight = with(density) { it.height.toDp() } }, hazeState = hazeState, sending = state.sending, + sendLabel = stringResource( + if (subject is ShareToChatsSubject.User) R.string.action_send else R.string.action_invite + ), onMessageChanged = onMessageChanged, onInvite = onInvite, ) @@ -440,13 +481,14 @@ private fun RecentChatRow( * and the sheet's view model reads it at send time. * * The chat screen's own composer and glass, so typing here feels like typing in a chat, but - * without its outline, since the Figma pill has none. An Invite label replaces the send arrow + * without its outline, since the Figma pill has none. A text label replaces the send arrow * because the link goes out whether or not a message is typed. */ @Composable private fun InviteComposer( hazeState: HazeState, sending: Boolean, + sendLabel: String, onMessageChanged: (String) -> Unit, onInvite: () -> Unit, modifier: Modifier = Modifier, @@ -471,7 +513,7 @@ private fun InviteComposer( hint = stringResource(R.string.hint_addAMessage), state = message, submit = ChatInputSubmit.Action( - label = stringResource(R.string.action_invite), + label = sendLabel, busy = sending, perform = onInvite, ), 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 d6a3de5d4..d5f5ba040 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 @@ -117,8 +117,6 @@ internal fun PersonProfileScreen( val person = state.participant as? ChatParticipant.TipUser val isSelf = person != null && person.userId == state.selfId val pinned = if (isSelf) null else state.pinnedAction - val share = rememberProfileShare() - var shareOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) } var pinnedHeight by remember { mutableStateOf(0.dp) } @@ -184,7 +182,18 @@ internal fun PersonProfileScreen( ProfileActionButton( icon = ImageVector.vectorResource(R.drawable.ic_share_os), contentDescription = stringResource(R.string.action_share), - onClick = { shareOpen = true }, + onClick = { + // The DM is what "Open Chat" opens, so it is the one chat + // that has nothing to gain from being sent this person. + navigator.push( + AppRoute.Messaging.ShareProfileToChats( + userId = person.userId, + username = person.profile.username, + displayName = person.profile.displayName, + directChatId = state.dmChatId, + ) + ) + }, ) } }, @@ -263,15 +272,6 @@ internal fun PersonProfileScreen( } } } - - if (shareOpen && person != null) { - ProfileShareSheetHost( - person = person, - onShare = { share(person) }, - onCopyLink = viewModel::copyLink, - onDismiss = { shareOpen = false }, - ) - } } @Composable 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 e737ea6be..f7a9d0d15 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 @@ -1,15 +1,10 @@ package com.flipcash.app.messenger.internal.screens.profile -import android.content.ClipboardManager import androidx.lifecycle.viewModelScope import com.flipcash.app.blocklist.BlocklistCoordinator import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatParticipant -import com.flipcash.app.core.extensions.setText -import com.flipcash.app.core.tipping.TipCardOwner -import com.flipcash.app.core.toast.SystemToastController -import com.flipcash.app.core.util.Linkify import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.messenger.internal.payment.StartChattingPayer import com.flipcash.features.messenger.R @@ -68,8 +63,6 @@ internal class ChatProfileViewModel @Inject constructor( private val tipPaymentDelegate: TipPaymentDelegate, private val e2eePolicy: E2eePolicy, private val startChattingPayer: StartChattingPayer, - private val clipboardManager: ClipboardManager, - private val toastController: SystemToastController, ) : BaseViewModel( initialState = State(selfId = userManager.accountId), updateStateForEvent = updateStateForEvent, @@ -196,16 +189,6 @@ internal class ChatProfileViewModel @Inject constructor( data class OpenScreen(val route: AppRoute, val asSheet: Boolean = false) : Event } - /** Puts the person's profile link on the clipboard, the one Share hands out. */ - fun copyLink() { - val person = stateFlow.value.participant as? ChatParticipant.TipUser ?: return - clipboardManager.setText( - text = Linkify.tipcard(TipCardOwner.preferringUsername(person.profile.username, person.userId)), - label = resources.getString(R.string.title_clipboardLabelTipCardLink), - ) - toastController.showToast(R.string.action_copied, replacePrevious = true) - } - init { eventFlow .filterIsInstance() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShare.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShare.kt deleted file mode 100644 index 077db911b..000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShare.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.flipcash.app.messenger.internal.screens.profile - -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.res.stringResource -import com.flipcash.app.core.chat.ChatParticipant -import com.flipcash.app.shareable.LocalShareController -import com.flipcash.app.shareable.Shareable -import com.flipcash.features.messenger.R -import com.flipcash.shared.common.ui.profile.ProfileShareRow -import com.flipcash.shared.common.ui.profile.ProfileShareSheet -import com.getcode.theme.CodeTheme -import kotlinx.coroutines.launch - -/** Hands a person's profile to the system share sheet. */ -@Composable -internal fun rememberProfileShare(): (ChatParticipant.TipUser) -> Unit { - val shareController = LocalShareController.current - val scope = rememberCoroutineScope() - return remember(shareController, scope) { - { user -> - scope.launch { - shareController.present( - Shareable.Profile( - userId = user.userId, - displayName = user.profile.displayName, - username = user.profile.username, - ) - ) - } - } - } -} - -private const val RowShare = "share" -private const val RowCopy = "copy" - -/** - * Share Profile and Copy Link for someone else's profile. No profile-card row: the card is the - * viewer's own to show. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun ProfileShareSheetHost( - person: ChatParticipant.TipUser, - onShare: () -> Unit, - onCopyLink: () -> Unit, - onDismiss: () -> Unit, -) { - val handle = person.profile.username - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = CodeTheme.colors.background, - ) { - LightStatusBarIcons() - ProfileShareSheet( - title = stringResource(R.string.title_shareUserProfile), - subtitle = if (handle.isNullOrEmpty()) { - person.profile.displayName - } else { - stringResource(R.string.subtitle_shareUserProfile, person.profile.displayName, handle) - }, - rows = listOf( - ProfileShareRow(RowShare, R.drawable.ic_share_os, stringResource(R.string.action_shareProfileLink)), - ProfileShareRow(RowCopy, R.drawable.ic_copy, stringResource(R.string.action_copyLink)), - ), - onRow = { row -> - onDismiss() - if (row.id == RowShare) onShare() else onCopyLink() - }, - ) - } -} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt index a90113796..bbf868f67 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt @@ -63,7 +63,13 @@ class GroupInviteViewModelTest { Dispatchers.resetMain() } - private fun viewModel() = GroupInviteViewModel(chatCoordinator, userManager, resources) + private fun viewModel() = GroupInviteViewModel( + chatCoordinator, + userManager, + resources, + clipboardManager = mockk(relaxed = true), + tipCodePreviewCache = mockk(relaxed = true), + ) @Test fun `the message bar is hidden until a chat is picked`() { @@ -173,13 +179,45 @@ class GroupInviteViewModelTest { val model = viewModel() advanceUntilIdle() - model.inviteTo(group) + model.exclude(group) model.toggle(group) assertEquals(listOf(alice), model.state.value.invitable?.map { it.chatId }) assertEquals(emptyList(), model.state.value.selection) } + @Test + fun `a person's DM is left out of the chats their profile is sent to`() = runTest(scheduler) { + val dm = ChatId(ByteArray(32) { 7 }) + every { chatCoordinator.feed(*anyVararg()) } returns flowOf( + listOf(summary(alice, ChatType.TIP_DM), summary(dm, ChatType.TIP_DM)), + ) + val model = viewModel() + advanceUntilIdle() + + model.exclude(dm) + model.toggle(dm) + + assertEquals(listOf(alice), model.state.value.invitable?.map { it.chatId }) + assertEquals(emptyList(), model.state.value.selection) + } + + @Test + fun `no exclusion offers every chat`() = runTest(scheduler) { + val group = ChatId(ByteArray(32) { 9 }) + every { chatCoordinator.feed(*anyVararg()) } returns flowOf( + listOf(summary(alice, ChatType.TIP_DM), summary(group, ChatType.GROUP)), + ) + val model = viewModel() + advanceUntilIdle() + + model.exclude(null) + model.toggle(alice) + + assertEquals(listOf(alice, group), model.state.value.invitable?.map { it.chatId }) + assertEquals(listOf(alice), model.state.value.selection) + } + @Test fun `nothing is sent with nobody picked`() = runTest(scheduler) { viewModel().invite(INVITE_URL) 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 index 0e65b9517..4f2bdbd0e 100644 --- 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 @@ -88,8 +88,6 @@ class ChatProfileViewModelTest { tipPaymentDelegate = tipPaymentDelegate, e2eePolicy = mockk(relaxed = true), startChattingPayer = payer, - clipboardManager = mockk(relaxed = true), - toastController = mockk(relaxed = true), ) private fun openFull(model: ChatProfileViewModel) = diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileShareSheet.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileShareSheet.kt index 234ad6668..e5477e72b 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileShareSheet.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileShareSheet.kt @@ -1,16 +1,16 @@ package com.flipcash.shared.common.ui.profile import androidx.annotation.DrawableRes +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.theme.FlipcashThemeWrapper @@ -18,6 +18,8 @@ import com.flipcash.shared.common.ui.R import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.ChoiceRow +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton /** * One choice in a [ProfileShareSheet]. The caller decides which rows exist, so a person's profile @@ -31,52 +33,49 @@ data class ProfileShareRow( ) /** - * The content of a profile's share sheet: [title] over [subtitle] (who is being shared), then one - * row per entry in [rows]. + * The content of the You tab's share sheet: [title] left-aligned, one row per entry in [rows], and + * a subtle Dismiss. * * It only draws and reports the tap. Hosting it in a sheet, dismissing that sheet, and running the - * chosen action once the sheet has finished leaving are the caller's. + * chosen action once the sheet has finished leaving are the caller's. Other people's profiles and + * groups share through the share-to-chats sheet instead. */ @Composable fun ProfileShareSheet( title: String, - subtitle: String, rows: List, onRow: (ProfileShareRow) -> Unit, + onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { Column( modifier = modifier .fillMaxWidth() + // The Add Money sheet's colour, as iOS draws this sheet. + .background(CodeTheme.colors.bannerThemed) .navigationBarsPadding(), ) { - AppBarWithTitle( - title = title, - titleAlignment = Alignment.CenterHorizontally, - ) - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset), - text = subtitle, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) + AppBarWithTitle(title = title) Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = CodeTheme.dimens.inset) - .padding(vertical = CodeTheme.dimens.grid.x4), + .padding(bottom = CodeTheme.dimens.grid.x4), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), ) { rows.forEach { row -> ChoiceRow( label = row.label, - icon = row.icon, + icon = painterResource(row.icon), onClick = { onRow(row) }, ) } + CodeButton( + modifier = Modifier.fillMaxWidth(), + buttonState = ButtonState.Subtle, + text = stringResource(R.string.action_dismiss), + onClick = onDismiss, + ) } } } @@ -87,12 +86,11 @@ fun ProfileShareSheet( private fun Preview_ProfileShareSheet() { ProfileShareSheet( title = "Share User Profile", - subtitle = "Ada Lovelace · @ada", rows = listOf( ProfileShareRow("share", R.drawable.ic_share_os, "Share Profile"), ProfileShareRow("card", R.drawable.ic_file_download, "Show Profile Card"), - ProfileShareRow("copy", R.drawable.ic_copy, "Copy Link"), ), onRow = {}, + onDismiss = {}, ) }