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 @@ -21,6 +21,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
Expand Down Expand Up @@ -83,10 +84,11 @@ fun TokenCard(
displayName: String = token.name,
appreciationText: String? = null,
height: Dp = 224.dp,
// A chat's link card passes its bubble's outline, so the bill corners like the run around it.
shape: Shape = CodeTheme.shapes.medium,
onClick: (() -> Unit)? = null,
) {
val isUsdf = token.address == Mint.usdf
val shape = CodeTheme.shapes.medium
val brush = remember(token.billCustomizations, isUsdf) {
if (isUsdf) UsdfBrush else billCardBrush(token.billCustomizations)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.flipcash.app.core.chat

import android.os.Parcelable
import com.flipcash.services.models.chat.ChatId
import com.getcode.navigation.HalfSheet
import com.getcode.navigation.Sheet
import com.getcode.navigation.WrapContentSheet
Expand All @@ -13,6 +14,18 @@ import kotlinx.serialization.Serializable
@Serializable
data object ChatSendResult : Parcelable

/**
* The chat the invite sheet sends the viewer to once its invites are out: the first one picked.
*
* Returned rather than navigated to from the sheet, because the sheet's own navigation raced its
* dismissal and was dropped when the conversation's event collector was not on screen (the sheet
* opened from the group's profile). The screen that opened the sheet is still composed when the
* result arrives, so it navigates.
*/
@Parcelize
@Serializable
data class GroupInviteResult(val chatId: ChatId) : Parcelable

@Serializable
sealed interface ChatStep : FlowStep, Parcelable {
@Parcelize
Expand Down Expand Up @@ -44,16 +57,21 @@ sealed interface ChatStep : FlowStep, Parcelable {
ChatStep, NavigationRetVal<ChatSendResult>, Sheet, WrapContentSheet

/**
* Node 10127:118315 — the two ways to hand out a group's invite link.
* Nodes 10329:12104 and 10330:19549 — share or copy a group's invite link, or send it to
* recent 1:1 chats.
*
* A sheet over the conversation rather than a screen of its own: inviting is something you do
* *from* the group, not a place you go. Reached from the empty transcript's CTA and from the
* group's profile, which is why it hangs off the chat flow rather than the create flow that
* used to end on it.
*
* A [WrapContentSheet] over the conversation rather than a screen of its own: it is two rows,
* and inviting is something you do *from* the group, not a place you go. Reached from the empty
* transcript's CTA and from the group's profile, which is why it hangs off the chat flow rather
* than the create flow that used to end on it.
* 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.
*/
@Parcelize
@Serializable
data object InviteToGroup : ChatStep, Sheet, WrapContentSheet
data object InviteToGroup : ChatStep, NavigationRetVal<GroupInviteResult>, Sheet

@Parcelize
@Serializable
Expand Down
9 changes: 8 additions & 1 deletion apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -983,7 +983,8 @@
<string name="label_linkCard_expired">Expired</string>
<!-- Group invite link card. The untitled name is provisional, pending copy. -->
<string name="label_linkCard_untitledGroup">Group Chat</string>
<string name="action_linkCard_startChatting">Start Chatting</string>
<string name="action_linkCard_view">View</string>
<string name="action_linkCard_invitePeople">Invite People</string>
<string name="label_linkCard_groupUnavailable">Group Unavailable</string>

<!-- Chat message selection bar and its actions -->
Expand Down Expand Up @@ -1114,9 +1115,15 @@
<string name="title_inviteToJoinGroup">Invite to Join Group</string>
<string name="action_sendInviteLink">Send Invite Link</string>
<string name="action_copyInviteLink">Copy Invite Link</string>
<string name="action_inviteLinkCopied">Copied</string>
<string name="action_invitePeopleToJoin">Invite People To Join</string>
<!-- Sent ahead of the invite link in the share sheet; %1$s is the group title. Same wording as iOS. -->
<string name="message_groupInvite">Join %1$s on Flipcash and let\'s chat</string>
<!-- The invite sheet (nodes 10329:12104, 10330:19549). -->
<string name="title_invitePeople">Invite People</string>
<string name="action_shareInviteLink">Share</string>
<string name="label_recentChats">Recent Chats</string>
<string name="hint_addAMessage">Add a message</string>
<string name="title_tipIntro">Receive Tips From Everyone</string>
<string name="subtitle_tipIntro">Add your name to receive tips</string>
<string name="action_startReceivingTips">Start Receiving Tips</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
Expand All @@ -19,9 +20,11 @@ import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.chat.ChatParticipant
import com.flipcash.app.core.chat.ChatSendResult
import com.flipcash.app.core.chat.ChatStep
import com.flipcash.app.core.chat.GroupInviteResult
import com.flipcash.app.core.extensions.openAsSheet
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.StartSendCashOnceReady
import com.flipcash.app.messenger.internal.screens.GroupInviteSheet
import com.flipcash.app.messenger.internal.screens.MessengerScreen
Expand Down Expand Up @@ -271,15 +274,36 @@ private fun FlowInitPaymentScreen() {
private fun FlowGroupInviteSheet() {
val viewModel = flowSharedViewModel<ChatViewModel>()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val inviteViewModel = hiltViewModel<GroupInviteViewModel>()
val inviteState by inviteViewModel.state.collectAsStateWithLifecycle()
// Same dismissal rule as the other sheets in this flow: exit through the sheet so it animates
// down rather than having its scene deleted mid-frame.
val dismissSheet = LocalBottomSheetDismissDispatcher.current

LaunchedEffect(inviteViewModel, state.chatId) {
state.chatId?.let(inviteViewModel::inviteTo)
}

// Returned to whichever screen opened the sheet (see openGroupInvite), which navigates. The
// keyboard goes first: the message field has it up, and leaving with it still open drags the
// screen behind out from under it.
val keyboard = rememberKeyboardController()
val resultBack = resultBackNavigator<GroupInviteResult>(exit = dismissSheet)
LaunchedEffect(inviteViewModel) {
inviteViewModel.invited.collect { chatId ->
keyboard.hideIfVisible { resultBack.returnValue(GroupInviteResult(chatId)) }
}
}

GroupInviteSheet(
inviteUrl = state.groupInviteUrl,
group = state.subject as? ChatSubject.Group,
state = inviteState,
onShare = { viewModel.dispatchEvent(ChatViewModel.Event.InviteLinkShared) },
onCopy = { viewModel.dispatchEvent(ChatViewModel.Event.CopyInviteLink) },
onToggle = inviteViewModel::toggle,
onMessageChanged = inviteViewModel::onMessageChanged,
onInvite = { state.groupInviteUrl?.let(inviteViewModel::invite) },
onDismiss = dismissSheet,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatRuleRequirement
import com.flipcash.services.models.chat.ChatRules
import com.flipcash.services.models.chat.MediaItem
import com.flipcash.shared.chat.models.LinkCard

/**
* What the messenger screen is a conversation *with*.
Expand Down Expand Up @@ -156,3 +157,37 @@ internal data class RuleCurrency(
val nameInRequirement: String?
get() = name.takeUnless { isReserve }
}

/**
* The card an empty group shows in place of its info card: the same [LinkCard.GroupInvite] a
* transcript renders for an invite link, built from what this subject already knows rather than
* from a lookup, since a member looking at the empty group already has the chat's own record.
*/
internal fun ChatSubject.Group.toGroupInviteCard(
inviteUrl: String,
currencyName: String?,
): LinkCard.GroupInvite {
val balance = rules.balanceRequirement()
val staffOnly = rules.requiresStaff()
val requirement = if (balance != null || staffOnly) {
LinkCard.GroupInvite.Requirement(
amount = balance?.amount?.formatted(),
currencyName = currencyName,
staffOnly = staffOnly,
)
} else {
null
}
return LinkCard.GroupInvite(
url = inviteUrl,
start = 0,
end = inviteUrl.length,
chatId = chatId,
state = LinkCard.GroupInvite.State.Resolved(
title = groupTitle?.takeIf { it.isNotBlank() },
picture = picture,
memberCount = memberCount,
requirement = requirement,
),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,16 @@ internal class ChatViewModel @Inject constructor(
get() = (subject as? ChatSubject.Group)
?.takeIf { it.isMember == true }
?.let { Linkify.groupChatInvite(it.chatId) }

/**
* Whether the group's invite card heads the transcript in place of its info card: a group
* the viewer has joined that nobody else has (node 10330:19164). Decided by who is in the
* chat, not by whether it has messages, so posting into a solo group keeps the card. Once
* someone joins, the invite moves to the group's profile, as on iOS.
*/
val showsGroupInviteCard: Boolean
get() = groupInviteUrl != null &&
(subject as? ChatSubject.Group)?.memberCount?.let { it <= 1 } == true
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package com.flipcash.app.messenger.internal

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.util.resources.ResourceHelper
import com.getcode.utils.trace
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
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.
*
* 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.
*/
@HiltViewModel
internal class GroupInviteViewModel @Inject constructor(
private val chatCoordinator: ChatCoordinator,
private val userManager: UserManager,
private val resources: ResourceHelper,
) : ViewModel() {

data class State(
/** Null until the feed first emits, so an empty list means there are no chats to offer. */
val recentChats: List<ConversationReference>? = null,
/** The group the invites are for, which is left out of its own list. */
val groupId: ChatId? = null,
/** Picked chats in the order they were tapped. The first is where the send lands. */
val selection: List<ChatId> = emptyList(),
val message: String = "",
val sending: Boolean = false,
) {
/** 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. */
val invitable: List<ConversationReference>?
get() = recentChats?.filterNot { it.chatId == groupId }
}

private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()

// Buffered for the same reason as ReportViewModel.confirmed: it fires once, and dropping it
// would leave the sheet up after the invites went out.
private val _invited = Channel<ChatId>(Channel.BUFFERED)

/** Emits the first-selected chat once every send has answered, which is where the sheet goes. */
val invited: Flow<ChatId> = _invited.receiveAsFlow()

init {
// The Chats list's own set of chats. Contact DMs have their own inbox, and taking them too
// listed a person twice when they had both kinds of DM, plus nameless contact rows.
// One read, not a subscription: a chat receiving a message while the sheet is up would
// otherwise jump to the top and move the row out from under the viewer's finger.
viewModelScope.launch {
val summaries = chatCoordinator.chatListFeed().first()
val selfId = userManager.accountId
val chats = summaries.map { it.toConversationReference(selfId, emptyMap(), resources) }
_state.update { it.copy(recentChats = chats) }
}
}

/** 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) }
}

fun toggle(chatId: ChatId) {
_state.update { state ->
if (state.sending || chatId == state.groupId) return@update state
val selection = if (chatId in state.selection) {
state.selection - chatId
} else {
state.selection + chatId
}
state.copy(selection = selection)
}
}

fun onMessageChanged(message: String) {
_state.update { it.copy(message = message) }
}

/**
* Sends [inviteUrl] to every selected chat, then the typed message after it as its own bubble
* when there is one (node 10330:23177).
*
* Each chat is sent to on its own, and one failing does not stop the rest. A failed send leaves
* a failed row in that chat's transcript with its retry, the same as a failed send from the
* composer. A chat whose link failed does not get the message: a retry would put the card after
* the text it was meant to introduce.
*/
fun invite(inviteUrl: String) {
val current = _state.value
val first = current.selection.firstOrNull() ?: return
if (current.sending) return
_state.update { it.copy(sending = true) }

val message = current.message.trim()
viewModelScope.launch {
coroutineScope {
current.selection.map { chatId ->
async { sendInvite(chatId, inviteUrl, message) }
}.awaitAll()
}
_invited.send(first)
}
}

private suspend fun sendInvite(chatId: ChatId, inviteUrl: String, message: String) {
val linkSent = send(chatId, inviteUrl)
if (linkSent && message.isNotEmpty()) send(chatId, message)
}

private suspend fun send(chatId: ChatId, content: String): Boolean {
val result = try {
chatCoordinator.sendMessage(chatId, content)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
return result
.onFailure { trace("group invite failed to send - ${it.localizedMessage}") }
.isSuccess
}
}
Loading
Loading