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 @@ -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
Expand Down Expand Up @@ -143,6 +144,7 @@ fun appEntryProvider(
annotatedEntry<AppRoute.Messaging.Report> { key ->
ReportFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
annotatedEntry<AppRoute.Messaging.ShareProfileToChats> { key -> ShareProfileToChatsScreen(key) }
annotatedEntry<AppRoute.Messaging.MuteChat> { key -> MuteChatScreen(key.chatId, key.chatType) }
annotatedEntry<AppRoute.Messaging.PhotoViewer> { key -> ChatPhotoViewerScreen(key.chatId, key.messageId) }
annotatedEntry<AppRoute.Messaging.E2eeDmInfo> { E2eeLearnMoreScreen(forGroup = false) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<GroupInviteResult>, Sheet
data object InviteToGroup :
ChatStep,
NavigationRetVal<GroupInviteResult>,
Sheet,
com.getcode.navigation.FullscreenSheet

@Parcelize
@Serializable
Expand Down
2 changes: 1 addition & 1 deletion apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1337,8 +1337,8 @@
<string name="action_claimUsername">Claim your username</string>
<string name="placeholder_minimumToChat">Not set</string>
<string name="title_shareUserProfile">Share User Profile</string>
<string name="title_shareProfileToChats">Share Profile</string>
<!-- Who the share sheet is for: display name, then @handle -->
<string name="subtitle_shareUserProfile">%1$s · @%2$s</string>
<string name="action_shareProfileLink">Share Profile</string>
<string name="action_showProfileCard">Show Profile Card</string>
<string name="action_copyLink">Copy Link</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProfileShareViewModel>()
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,
)
}
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions apps/flipcash/features/messenger/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) },
Expand Down
Original file line number Diff line number Diff line change
@@ -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<GroupInviteViewModel>()
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,
)
}
Loading
Loading