diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt index b09823fd32..d40058f547 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/TokenCard.kt @@ -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 @@ -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) } 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 ca747ce83b..ae32609a1b 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 @@ -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 @@ -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 @@ -44,16 +57,21 @@ sealed interface ChatStep : FlowStep, Parcelable { ChatStep, NavigationRetVal, 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, Sheet @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 9b52eedc84..68da137f42 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -983,7 +983,8 @@ Expired Group Chat - Start Chatting + View + Invite People Group Unavailable @@ -1114,9 +1115,15 @@ Invite to Join Group Send Invite Link Copy Invite Link + Copied Invite People To Join Join %1$s on Flipcash and let\'s chat + + Invite People + Share + Recent Chats + Add a message Receive Tips From Everyone Add your name to receive tips Start Receiving Tips 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 87f878fa87..0af571aa84 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 @@ -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 @@ -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 @@ -271,15 +274,36 @@ private fun FlowInitPaymentScreen() { private fun FlowGroupInviteSheet() { val viewModel = flowSharedViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val inviteViewModel = hiltViewModel() + 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(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, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt index d1a055e96e..dc6384f732 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt @@ -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*. @@ -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, + ), + ) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index e9854270c8..0b736967d6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -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 } /** 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 new file mode 100644 index 0000000000..9355064c9e --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt @@ -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? = 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 = 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? + get() = recentChats?.filterNot { it.chatId == groupId } + } + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _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(Channel.BUFFERED) + + /** Emits the first-selected chat once every send has answered, which is where the sheet goes. */ + val invited: Flow = _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 + } +} 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 993a220d37..e5adf3cc0f 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 @@ -1,48 +1,141 @@ package com.flipcash.app.messenger.internal.screens +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.spring +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource 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 androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +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.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.GroupInviteViewModel import com.flipcash.app.shareable.LocalShareController import com.flipcash.app.shareable.Shareable import com.flipcash.features.messenger.R 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.navigation.core.CodeNavigator +import com.getcode.navigation.results.NavResultOrCanceled +import com.getcode.navigation.results.navigateForResult import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.theme.White50 import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.components.ChoiceRow +import com.getcode.ui.components.chat.ChatInput +import com.getcode.ui.components.chat.ChatInputSubmit +import com.getcode.ui.utils.sheetResignmentBehavior +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.hazeBlur +import dev.chrisbanes.haze.blur.materials.HazeMaterials +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.launch /** - * Node 10127:118315 — the two ways to hand out the link to a group. + * Opens the invite sheet over the current step and, once its invites are out, the chat picked + * first. * - * Both actions carry the same URL, built once by + * The sheet returns that chat rather than navigating itself. It is opened from the transcript and + * from the group's profile, and only the screen that opened it is sure to be composed when the + * sends finish, so that screen does the navigating. + */ +internal fun CodeNavigator.openGroupInvite() { + navigateForResult(ChatStep.InviteToGroup) { result -> + if (result is NavResultOrCanceled.ReturnValue) { + navigate(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(result.value.chatId))) + } + } +} + +/** + * Node 10329:12104 — handing out the link to a group, 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 and the copied link - * identical. + * the id *is* the invite, and 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 state the Recent Chats selection and the message to send with the link. */ @Composable internal fun GroupInviteSheet( inviteUrl: String?, group: ChatSubject.Group?, + state: GroupInviteViewModel.State, onShare: () -> Unit, onCopy: () -> Unit, + onToggle: (ChatId) -> Unit, + onMessageChanged: (String) -> Unit, + onInvite: () -> Unit, onDismiss: () -> Unit, ) { val shareController = LocalShareController.current @@ -60,49 +153,349 @@ internal fun GroupInviteSheet( ?: BlobAccessContext.Owned, ) - Column( + val hazeState = rememberHazeState() + val listState = rememberLazyListState() + val density = LocalDensity.current + val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) + val scrimColor = CodeTheme.colors.surface + var headerHeight by remember { mutableStateOf(0.dp) } + var fieldHeight by remember { mutableStateOf(0.dp) } + var copied by remember { mutableStateOf(false) } + + // Held above the gesture bar, and dropped once the keyboard is up: the keyboard already lifts + // the whole sheet clear of it, so counting both would leave a gap over the keys. + val navigationBar = WindowInsets.navigationBars + .exclude(WindowInsets.ime) + .asPaddingValues() + .calculateBottomPadding() + // The field sits inside the bottom scrim, which is as tall as the header, so the two edges fade + // the same depth. iOS reaches the screen edge; here the gesture bar keeps it higher. + val composerBottom = maxOf(headerHeight - fieldHeight, ComposerMinBottom, navigationBar) + val recentChats = state.invitable.orEmpty() + + // No pinned header: the title bar and, once a chat is picked, the message field float over the + // one list, which scrolls under both and fades out at each edge (node 10330:19387). + Box( modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding(), + .fillMaxSize() + .imePadding(), ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + // Without this, a fling that reaches the top of the list carries on into the + // sheet and dismisses it. + .sheetResignmentBehavior(listState) + .hazeSource(hazeState), + state = listState, + contentPadding = PaddingValues( + top = headerHeight, + bottom = if (state.showsComposer) { + maxOf(headerHeight, composerBottom + fieldHeight) + } else { + headerHeight + }, + ), + ) { + if (inviteUrl != null) { + item(key = "links") { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ListInset, vertical = TileRowVertical), + horizontalArrangement = Arrangement.spacedBy(TileGap), + ) { + InviteLinkTile( + modifier = Modifier.weight(1f), + icon = R.drawable.ic_share_os, + label = stringResource(R.string.action_shareInviteLink), + onClick = { + onShare() + scope.launch { + shareController.present( + Shareable.GroupInvite( + url = inviteUrl, + title = group?.groupTitle, + imageUrl = pictureUrl.url, + imageCacheKey = picture + ?.cacheKeyForSize(SharePreviewImage.TARGET_PX), + ) + ) + } + }, + ) + InviteLinkTile( + 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 + ), + onClick = { + onCopy() + copied = true + }, + ) + } + } + if (recentChats.isNotEmpty()) { + item(key = "header") { RecentChatsHeader() } + } + items(recentChats, key = { it.chatId.toString() }) { chat -> + RecentChatRow( + chat = chat, + selected = chat.chatId in state.selection, + enabled = !state.sending, + onClick = { onToggle(chat.chatId) }, + ) + } + } + } + + // Always drawn, whether or not the field is up, so the edge stays still while the field + // slides through it. A colour scrim like the top one, not a blur. + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(headerHeight) + .drawBehind { + val scrimHeight = size.height + ScrimTail.toPx() + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, scrimColor), + startY = size.height - scrimHeight, + endY = size.height, + ), + topLeft = Offset(0f, size.height - scrimHeight), + size = size.copy(height = scrimHeight), + ) + }, + ) + AppBarWithTitle( - title = stringResource(R.string.title_inviteToJoinGroup), + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + // The Chats list's scrim: the surface colour fading out a little past the bar, + // drawn behind it so the tail stays out of the bar's measured height. + .drawBehind { + val scrimHeight = size.height + ScrimTail.toPx() + drawRect( + brush = Brush.verticalGradient( + colors = listOf(scrimColor, Color.Transparent), + startY = 0f, + endY = scrimHeight, + ), + size = size.copy(height = scrimHeight), + ) + } + .onSizeChanged { headerHeight = with(density) { it.height.toDp() } }, + title = stringResource(R.string.title_invitePeople), titleAlignment = Alignment.CenterHorizontally, - endContent = { AppBarDefaults.Close(onClick = onDismiss) }, + contentPadding = PaddingValues(HeaderPadding), + endContent = { AppBarDefaults.Close(hazeState = hazeState, onClick = onDismiss) }, ) - if (inviteUrl != null) { - Column( + // Slides rather than fades: a fading blur shows the unblurred list through it for the + // length of the fade. + AnimatedVisibility( + modifier = Modifier.align(Alignment.BottomCenter), + visible = inviteUrl != null && state.showsComposer, + enter = slideInVertically(ComposerSpring) { with(density) { ComposerTravel.roundToPx() } }, + exit = slideOutVertically(ComposerSpring) { with(density) { ComposerTravel.roundToPx() } }, + ) { + // Inset in a wrapper: ChatInput clips to its pill before applying its modifier, so + // padding passed to it would round the padded box while the glass drew square inside. + Box( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.inset) - .padding(top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x4), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + .padding(horizontal = ListInset) + .padding(bottom = composerBottom), ) { - ChoiceRow( - label = stringResource(R.string.action_sendInviteLink), - icon = R.drawable.ic_at, - onClick = { - onShare() - scope.launch { - shareController.present( - Shareable.GroupInvite( - url = inviteUrl, - title = group?.groupTitle, - imageUrl = pictureUrl.url, - imageCacheKey = picture - ?.cacheKeyForSize(SharePreviewImage.TARGET_PX), - ) - ) - } - }, + InviteComposer( + modifier = Modifier + .onSizeChanged { fieldHeight = with(density) { it.height.toDp() } }, + hazeState = hazeState, + sending = state.sending, + onMessageChanged = onMessageChanged, + onInvite = onInvite, ) - ChoiceRow( - label = stringResource(R.string.action_copyInviteLink), - icon = R.drawable.ic_copy, - onClick = onCopy, + } + } + } +} + +/** + * Node 10330:19400 — Share or Copy: a glyph over its label, the pair splitting the row. The label + * wraps rather than truncates, so the tile grows past its minimum height instead. + */ +@Composable +private fun InviteLinkTile( + icon: Int, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .heightIn(min = TileMinHeight) + .clip(TileShape) + .background(White05) + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = TilePaddingHorizontal, vertical = TilePaddingVertical), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(TileIconGap, Alignment.CenterVertically), + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(TileIconSize), + ) + Text( + text = label, + style = CodeTheme.typography.textSmall, + color = White50, + textAlign = TextAlign.Center, + ) + } +} + +/** Node 10330:19568 — the list's label, an ordinary row that scrolls with the chats. */ +@Composable +private fun RecentChatsHeader() { + Column(modifier = Modifier.padding(horizontal = ListInset)) { + Text( + modifier = Modifier.padding(vertical = HeaderRowVertical), + text = stringResource(R.string.label_recentChats), + style = CodeTheme.typography.textSmall, + color = White50, + ) + HorizontalDivider(color = CodeTheme.colors.divider) + } +} + +/** + * Node 10330:19387 — one chat, 1:1 or group, picked or not. The whole row takes the tap. Its divider starts + * at the name, not the avatar, as in node 10330:19585. + */ +@Composable +private fun RecentChatRow( + chat: ConversationReference, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, role = Role.Checkbox, onClick = onClick) + .padding(horizontal = ListInset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(RowGap), + ) { + ContactAvatar( + image = chat.image, + displayName = chat.name.orEmpty(), + access = chat.avatarAccess, + modifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x6) + .clip(CircleShape), + ) + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x4), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = chat.name.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // The same mark the token and currency pickers use, so this reads as the same kind + // of list. + Image( + painter = painterResource( + if (selected) R.drawable.ic_checked else R.drawable.ic_unchecked + ), + contentDescription = null, ) } + HorizontalDivider(color = CodeTheme.colors.divider) } } } + +/** + * Node 10329:11963 — the message to go with the link, and the send. The field keeps its own text, + * 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 + * because the link goes out whether or not a message is typed. + */ +@Composable +private fun InviteComposer( + hazeState: HazeState, + sending: Boolean, + onMessageChanged: (String) -> Unit, + onInvite: () -> Unit, + modifier: Modifier = Modifier, +) { + // One fill, node 10329:11963's 46% #1E1E1E, over a blur of the rows beneath. ChatInput's own + // fill is dropped because stacking it on the tint made the pill opaque and hid its edges. + // The HazeBlurStyle builder is not a @Composable scope, so the theme read is hoisted above it. + val backdrop = CodeTheme.colors.surface + val glass = HazeBlurStyle { + blurRadius(ComposerBlurRadius) + backgroundColor(backdrop) + colorEffects(listOf(HazeColorEffect.tint(ComposerFill))) + } + val message = rememberTextFieldState() + LaunchedEffect(message) { + snapshotFlow { message.text.toString() }.collect(onMessageChanged) + } + ChatInput( + modifier = modifier.hazeBlur(HazeInput.Sources(hazeState), glass), + containerColor = Color.Transparent, + enabled = !sending, + hint = stringResource(R.string.hint_addAMessage), + state = message, + submit = ChatInputSubmit.Action( + label = stringResource(R.string.action_invite), + busy = sending, + perform = onInvite, + ), + ) +} + +// Node 10330:19387 and its children. Fixed rather than the theme's inset, which narrows on small +// screens, because the design holds these at 20 at every width. +private val ListInset = 20.dp +private val ComposerFill = Color(0x751E1E1E) +private val ComposerBlurRadius = 24.dp +private val HeaderPadding = 16.dp +private val HeaderRowVertical = 16.dp +private val TileRowVertical = 12.dp +private val TileGap = 10.dp +private val TileMinHeight = 95.dp +private val TileShape = RoundedCornerShape(6.dp) +private val TilePaddingHorizontal = 8.dp +private val TilePaddingVertical = 16.dp +private val TileIconSize = 28.dp +private val TileIconGap = 6.dp + +// Node 10330:19572: the gap between avatar and name, which is also where the row's divider starts. +private val RowGap = 16.dp + +private val ComposerMinBottom = 8.dp + +/** How far each edge scrim runs past its bar, as on the Chats list. */ +private val ScrimTail = 48.dp +// Far enough that the field starts fully below the sheet's bottom edge. +private val ComposerTravel = 120.dp +// iOS's spring(response: 0.35): stiffness (2π / 0.35)² and its default damping. +private val ComposerSpring = spring(dampingRatio = 0.825f, stiffness = 322f) 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 9b66896d82..3bc1d3c932 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 @@ -198,7 +198,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { // and going straight to the system share picker would bury it. The sheet reads the // url off the same state the CTA that got here is gated on. viewModel.dispatchEvent(ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.CHAT)) - keyboard.hideIfVisible { navigator.push(ChatStep.InviteToGroup) } + keyboard.hideIfVisible { navigator.openGroupInvite() } } is ChatAction.ViewProfile -> { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 43ea29d912..3598efaf21 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -5,11 +5,13 @@ import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.lazy.LazyListState @@ -41,8 +43,9 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey +import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel -import com.flipcash.shared.chat.ui.ChatAnimations +import com.flipcash.app.messenger.internal.toGroupInviteCard import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessagePointer @@ -55,8 +58,11 @@ import com.flipcash.shared.chat.models.LocalChatActionHandler import com.flipcash.shared.chat.models.LocalLinkCardResolution import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.reactions.ReactionRefreshPlanner +import com.flipcash.shared.chat.ui.ChatAnimations +import com.flipcash.shared.chat.ui.GroupInviteCardDefaults +import com.flipcash.shared.chat.ui.GroupInviteLinkCard +import com.flipcash.shared.chat.ui.transcriptCardWidth import com.getcode.theme.CodeTheme -import com.getcode.ui.theme.CodeButton import com.getcode.ui.utils.rememberKeyboardController import com.getcode.util.vibration.LocalVibrator import kotlinx.coroutines.delay @@ -381,37 +387,13 @@ internal fun MessageList( // This prevents these items from being the only content before messages // load, which would cause the list to start at the wrong scroll position. if (messages.itemCount > 0 || refreshSettled) { - // Emitted before the info card, and so drawn below it: this list is reverseLayout, - // where the last item is the topmost one. Both of the items below belong under the - // card the way the designs show them, and neither can appear while there are - // messages — one stands in for a transcript, the other says there isn't one yet. - if (messages.itemCount == 0) { - val inviteUrl = state.groupInviteUrl - when { - // Node 10127:117171. The blur over this list is what makes it a preview; - // see GatedTranscriptPlaceholder for why it is drawn rather than fetched. - state.obscuresTranscript -> item(key = "gated-transcript-placeholder") { - GatedTranscriptPlaceholder(modifier = Modifier.fillParentMaxWidth()) - } - - // Node 10127:118280 — the group the creator has just made. Inviting - // someone is the only thing to do with an empty group, so it is offered - // here rather than left to the profile screen. - inviteUrl != null -> item(key = "group-invite-action") { - Box( - modifier = Modifier - .fillParentMaxWidth() - .padding(top = CodeTheme.dimens.grid.x4), - contentAlignment = Alignment.Center, - ) { - CodeButton( - text = stringResource(R.string.action_invitePeopleToJoin), - shape = CircleShape, - enabled = !selecting, - onClick = { onAction(ChatAction.InviteToGroup) }, - ) - } - } + // Node 10127:117171. Emitted before the info card, and so drawn below it: this list + // is reverseLayout, where the last item is the topmost one. The blur over this list + // is what makes it a preview; see GatedTranscriptPlaceholder for why it is drawn + // rather than fetched. + if (messages.itemCount == 0 && state.obscuresTranscript) { + item(key = "gated-transcript-placeholder") { + GatedTranscriptPlaceholder(modifier = Modifier.fillParentMaxWidth()) } } @@ -438,27 +420,63 @@ internal fun MessageList( } } - // Chat start shows contact info container - item { - Box( - modifier = Modifier - .fillParentMaxWidth(), - contentAlignment = Alignment.Center, - ) { - ChatInfoCard( - subject = state.subject, + // Chat start shows contact info container. A group nobody else has joined shows its + // invite card there instead (node 10330:19164), whether or not it has messages: + // inviting someone is the thing left to do, and it is the same card a DM transcript + // renders for the link, with a different CTA and destination. + val inviteUrl = state.groupInviteUrl + val inviteGroup = state.subject as? ChatSubject.Group + if (state.showsGroupInviteCard && inviteUrl != null && inviteGroup != null) { + item(key = "group-invite-card") { + val card = remember(inviteGroup, inviteUrl, state.ruleCurrency) { + inviteGroup.toGroupInviteCard( + inviteUrl = inviteUrl, + currencyName = state.ruleCurrency?.nameInRequirement, + ) + } + BoxWithConstraints( + modifier = Modifier.fillParentMaxWidth(), + contentAlignment = Alignment.Center, + ) { + // The width a link card gets in a DM transcript, so this reads as the + // same card sent there (iOS sizes both the same way). + val cardWidth = transcriptCardWidth( + rowWidth = maxWidth, + rowInset = CodeTheme.dimens.inset, + ) + Box(Modifier.width(cardWidth)) { + GroupInviteLinkCard( + card = card, + minHeight = cardWidth * GroupInviteCardDefaults.ASPECT, + ctaLabel = stringResource(R.string.action_linkCard_invitePeople), + onStart = { onAction(ChatAction.InviteToGroup) } + .takeIf { !selecting }, + ) + } + } + } + } else { + item { + Box( modifier = Modifier - .fillMaxWidth(0.63f), - onRefreshContact = { onAction(ChatAction.RefreshContact) }, - // null hides the chevron and makes the card non-tappable when the - // profile isn't viewable (non-tip-DM chats). - onOpenProfile = if (canViewProfile && !selecting) { - { onAction(ChatAction.ViewProfile) } - } else { - null - }, - currencyName = state.ruleCurrency?.nameInRequirement, - ) + .fillParentMaxWidth(), + contentAlignment = Alignment.Center, + ) { + ChatInfoCard( + subject = state.subject, + modifier = Modifier + .fillMaxWidth(0.63f), + onRefreshContact = { onAction(ChatAction.RefreshContact) }, + // null hides the chevron and makes the card non-tappable when the + // profile isn't viewable (non-tip-DM chats). + onOpenProfile = if (canViewProfile && !selecting) { + { onAction(ChatAction.ViewProfile) } + } else { + null + }, + currencyName = state.ruleCurrency?.nameInRequirement, + ) + } } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index 3d647ba321..4111aba867 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -35,6 +35,7 @@ import com.flipcash.analytics.GroupInviteSheetSource import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.chat.ReportSubject import com.flipcash.app.core.AppRoute +import com.flipcash.app.messenger.internal.screens.openGroupInvite import com.getcode.navigation.core.LocalCodeNavigator import com.flipcash.app.menu.MenuItem import com.flipcash.app.menu.MenuList @@ -145,7 +146,7 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent( ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.PROFILE) ) - flowNavigator.navigateTo(ChatStep.InviteToGroup) + navigator.openGroupInvite() } // Both muting and unmuting go through the picker, which is why this row // navigates either way rather than acting on one of them here. The outer diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt index 20f1d048e5..40414880db 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt @@ -43,11 +43,15 @@ class ChatViewModelStateTest { private val gatedRules = ChatRules(listener = listOf(unmet), speaker = emptyList()) - private fun group(isMember: Boolean?, rules: ChatRules? = gatedRules) = ChatSubject.Group( + private fun group( + isMember: Boolean?, + rules: ChatRules? = gatedRules, + memberCount: Long = 1L, + ) = ChatSubject.Group( chatId = groupChatId, groupTitle = "Ballers", picture = null, - memberCount = 1L, + memberCount = memberCount, rules = rules, isMember = isMember, ) @@ -239,6 +243,18 @@ class ChatViewModelStateTest { assertNull(ChatViewModel.State().groupInviteUrl) } + @Test + fun `the invite card heads a group only while the viewer is its only member`() { + // Messages have no say: a solo group the viewer has posted in still has nobody to talk to. + assertTrue(ChatViewModel.State(subject = group(isMember = true)).showsGroupInviteCard) + assertFalse( + ChatViewModel.State(subject = group(isMember = true, memberCount = 2L)).showsGroupInviteCard, + ) + assertFalse(ChatViewModel.State(subject = group(isMember = false)).showsGroupInviteCard) + assertFalse(ChatViewModel.State(subject = group(isMember = null)).showsGroupInviteCard) + assertFalse(ChatViewModel.State(subject = dm).showsGroupInviteCard) + } + @Test fun `a chat id with no uuid form yields no link rather than a malformed one`() { // A DM's id is a 32-byte hash. Reaching this through a Group subject is unreachable in the 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 new file mode 100644 index 0000000000..a90113796d --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt @@ -0,0 +1,205 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatSummary +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlin.time.Instant +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * The invite sheet's selection and send (nodes 10330:19549, 10330:23177): the bar waits for a + * pick, each chat gets the link and then the message, one chat failing leaves the rest alone, and + * the sheet lands on the chat picked first. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class GroupInviteViewModelTest { + + private val chatCoordinator = mockk(relaxed = true) { + every { feed(*anyVararg()) } returns flowOf(emptyList()) + coEvery { sendMessage(any(), any(), any()) } returns Result.success(mockk()) + } + private val userManager = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + + private val scheduler = TestCoroutineScheduler() + + private val alice = ChatId(ByteArray(32) { 1 }) + private val bob = ChatId(ByteArray(32) { 2 }) + private val carol = ChatId(ByteArray(32) { 3 }) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher(scheduler)) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel() = GroupInviteViewModel(chatCoordinator, userManager, resources) + + @Test + fun `the message bar is hidden until a chat is picked`() { + val model = viewModel() + assertFalse(model.state.value.showsComposer) + + model.toggle(alice) + assertTrue(model.state.value.showsComposer) + + model.toggle(alice) + assertFalse(model.state.value.showsComposer) + } + + @Test + fun `each chat gets the link and then the message`() = runTest(scheduler) { + val model = viewModel() + model.toggle(alice) + model.toggle(bob) + model.onMessageChanged("You should join this chat!") + + model.invite(INVITE_URL) + advanceUntilIdle() + + coVerifyOrder { + chatCoordinator.sendMessage(alice, INVITE_URL, null) + chatCoordinator.sendMessage(alice, "You should join this chat!", null) + } + coVerifyOrder { + chatCoordinator.sendMessage(bob, INVITE_URL, null) + chatCoordinator.sendMessage(bob, "You should join this chat!", null) + } + } + + @Test + fun `a blank message sends only the link`() = runTest(scheduler) { + val model = viewModel() + model.toggle(alice) + model.onMessageChanged(" ") + + model.invite(INVITE_URL) + advanceUntilIdle() + + coVerify(exactly = 1) { chatCoordinator.sendMessage(alice, any(), any()) } + coVerify { chatCoordinator.sendMessage(alice, INVITE_URL, null) } + } + + @Test + fun `the sheet lands on the chat picked first, not the one listed first`() = runTest(scheduler) { + val model = viewModel() + model.toggle(carol) + model.toggle(alice) + + model.invite(INVITE_URL) + + assertEquals(carol, model.invited.first()) + } + + @Test + fun `one chat failing does not stop the others`() = runTest(scheduler) { + coEvery { chatCoordinator.sendMessage(alice, any(), any()) } returns + Result.failure(IllegalStateException("offline")) + coEvery { chatCoordinator.sendMessage(bob, any(), any()) } throws IllegalStateException("boom") + val model = viewModel() + model.toggle(alice) + model.toggle(bob) + model.toggle(carol) + model.onMessageChanged("hi") + + model.invite(INVITE_URL) + + // Still navigates, to the first pick, even though its own send failed. + assertEquals(alice, model.invited.first()) + coVerify { chatCoordinator.sendMessage(carol, INVITE_URL, null) } + coVerify { chatCoordinator.sendMessage(carol, "hi", null) } + // A chat whose link failed is not sent the message that was meant to follow it. + coVerify(exactly = 0) { chatCoordinator.sendMessage(alice, "hi", any()) } + coVerify(exactly = 0) { chatCoordinator.sendMessage(bob, "hi", any()) } + } + + @Test + fun `the list is the feed as the sheet opened, not later updates`() = runTest(scheduler) { + val feed = MutableSharedFlow>(replay = 1) + every { chatCoordinator.feed(*anyVararg()) } returns feed + feed.emit(emptyList()) + + val model = viewModel() + advanceUntilIdle() + assertEquals(emptyList(), model.state.value.recentChats) + + feed.emit(listOf(mockk(relaxed = true))) + advanceUntilIdle() + assertEquals(emptyList(), model.state.value.recentChats) + } + + @Test + fun `the list offers the chats the Chats list shows`() { + viewModel() + verify { chatCoordinator.feed(ChatType.TIP_DM, ChatType.GROUP) } + } + + @Test + fun `the group being invited to is left out and cannot be picked`() = 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.inviteTo(group) + model.toggle(group) + + assertEquals(listOf(alice), model.state.value.invitable?.map { it.chatId }) + assertEquals(emptyList(), model.state.value.selection) + } + + @Test + fun `nothing is sent with nobody picked`() = runTest(scheduler) { + viewModel().invite(INVITE_URL) + advanceUntilIdle() + + coVerify(exactly = 0) { chatCoordinator.sendMessage(any(), any(), any()) } + } + + private companion object { + const val INVITE_URL = "https://app.flipcash.com/chat/6f1c3a9e" + } + + private fun summary(chatId: ChatId, type: ChatType) = ChatSummary( + metadata = ChatMetadata( + chatId = chatId, + type = type, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1000), + ), + unreadCount = 0, + ) +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt index 2dc5a4335d..c1d9062781 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -63,15 +64,21 @@ import com.getcode.ui.theme.CodeButton * * Nothing from the roster reaches here, so there are no member avatars and no member names, even as * a fallback title; an untitled group is named generically. + * + * Drawn in two places that want different things from the button, so [ctaLabel] and [onStart] come + * from the caller: a DM transcript's link card says "View" and opens the group, and the group's own + * empty state says "Invite People" and opens the invite sheet. [shape] is the transcript bubble's + * outline there, and the card's own radius where it stands alone. */ @Composable -internal fun GroupInviteLinkCard( +fun GroupInviteLinkCard( card: LinkCard.GroupInvite, minHeight: Dp, + ctaLabel: String, onStart: (() -> Unit)?, modifier: Modifier = Modifier, + shape: CornerBasedShape = GroupInviteCardDefaults.SHAPE, ) { - val shape = GroupInviteCardDefaults.SHAPE val state = card.state if (state is LinkCard.GroupInvite.State.Loading) { @@ -124,11 +131,11 @@ internal fun GroupInviteLinkCard( modifier = Modifier .fillMaxSize() .padding( - start = CodeTheme.dimens.inset, - end = CodeTheme.dimens.inset, + start = GroupInviteCardDefaults.PADDING, + end = GroupInviteCardDefaults.PADDING, // The avatar's centre sits on the band's bottom edge. top = GroupInviteCardDefaults.BAND_HEIGHT - GroupInviteCardDefaults.AVATAR / 2, - bottom = CodeTheme.dimens.inset, + bottom = GroupInviteCardDefaults.PADDING, ), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -146,7 +153,7 @@ internal fun GroupInviteLinkCard( ) Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + modifier = Modifier.padding(top = GroupInviteCardDefaults.TITLE_GAP), text = title, style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, @@ -179,7 +186,7 @@ internal fun GroupInviteLinkCard( modifier = Modifier .fillMaxWidth() .height(GroupInviteCardDefaults.BUTTON_HEIGHT), - text = stringResource(R.string.action_linkCard_startChatting), + text = ctaLabel, buttonState = ButtonState.Filled, overrideContentPadding = true, contentPadding = GroupInviteCardDefaults.NO_PADDING, @@ -211,7 +218,7 @@ internal fun GroupInviteLinkCard( private fun RequirementLines(requirement: LinkCard.GroupInvite.Requirement) { requirement.amount?.let { amount -> Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + modifier = Modifier.padding(top = GroupInviteCardDefaults.REQUIREMENT_GAP), text = if (requirement.currencyName != null) { stringResource(R.string.label_chat_balanceRequirement, amount, requirement.currencyName) } else { @@ -224,7 +231,7 @@ private fun RequirementLines(requirement: LinkCard.GroupInvite.Requirement) { } if (requirement.staffOnly) { Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + modifier = Modifier.padding(top = GroupInviteCardDefaults.REQUIREMENT_GAP), text = stringResource(R.string.label_chat_staffRequirement), style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, @@ -233,30 +240,45 @@ private fun RequirementLines(requirement: LinkCard.GroupInvite.Requirement) { } } -internal object GroupInviteCardDefaults { +object GroupInviteCardDefaults { /** The group header card's radius. */ val SHAPE = RoundedCornerShape(12.dp) /** The group header card's stroke: white at 10%. Also the avatar's ring. */ - val STROKE = Color.White.copy(alpha = 0.1f) + internal val STROKE = Color.White.copy(alpha = 0.1f) + + internal val BAND_HEIGHT = 46.dp - val BAND_HEIGHT = 46.dp + /** + * iOS's `GroupCardView.Layout`: the card's side and bottom padding, and the gaps above the + * title and the requirement. Fixed, not the theme's inset, which widens on larger phones and + * made the card taller than iOS's. + */ + internal val PADDING = 12.dp + internal val TITLE_GAP = 13.dp + internal val REQUIREMENT_GAP = 11.dp /** Provisional, pending design sign-off. */ - const val BAND_OPACITY = 0.28f + internal const val BAND_OPACITY = 0.28f - val AVATAR = 64.dp + internal val AVATAR = 64.dp /** Title to member count. */ - val MEMBER_COUNT_GAP = 2.dp + internal val MEMBER_COUNT_GAP = 2.dp /** The least gap between the text and the button. */ - val BUTTON_GAP = 16.dp + internal val BUTTON_GAP = 16.dp /** iOS's compact button height. Android has no compact style, so the standard one is sized. */ - val BUTTON_HEIGHT = 44.dp + internal val BUTTON_HEIGHT = 44.dp + + internal val NO_PADDING = PaddingValues(0.dp) - val NO_PADDING = PaddingValues(0.dp) + /** + * 224dp of card across 328dp of usable width, the wallet deck's proportions. The transcript + * measures an invite card at this; the group's empty state uses it so the two agree. + */ + const val ASPECT = 224f / 328f } // region Previews @@ -304,6 +326,7 @@ private fun PreviewCard(state: LinkCard.GroupInvite.State) { GroupInviteLinkCard( card = previewGroupCard(state), minHeight = 280.dp * 224f / 328f, + ctaLabel = "View", onStart = {}, ) } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 7cd35393f0..4a36cfacff 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text @@ -45,7 +46,11 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.flipcash.app.core.tokens.brandedName import com.flipcash.app.core.ui.TokenCard @@ -82,23 +87,29 @@ import com.getcode.ui.utils.ConstraintMode * [card] arrives with its lookup still to do and the card runs it — see [rememberResolvedCard]. * The transcript hands over what it can read off the message text, which is everything but the * amount and the claim, and is not held up by the rest. + * + * [shape] is the outline of the bubble the card stands in for, from [bubbleShape], so a card in a + * run squares the corners facing its neighbours exactly as a text bubble there would. Every kind + * cuts its own pieces from it: the voucher's two halves and the stub it leaves behind, the bill, + * the invite's frame. */ @Composable internal fun LinkCardView( card: LinkCard, + shape: CornerBasedShape, onClick: ((LinkCard) -> Unit)?, modifier: Modifier = Modifier, onLongClick: (() -> Unit)? = null, ) { val live = rememberResolvedCard(card) - val shape = CodeTheme.shapes.medium if (live is LinkCard.User) { // Hugs its text like a message rather than filling the row, and takes its own tap. UserLinkCard( card = live, onClick = onClick?.let { click -> { click(live) } }, - modifier = modifier, + modifier = modifier.semantics { linkCardShape = shape }, + shape = shape, onLongClick = onLongClick, ) return @@ -110,6 +121,7 @@ internal fun LinkCardView( BoxWithConstraints( modifier = modifier .fillMaxWidth() + .semantics { linkCardShape = shape } .addIf(onLongClick != null) { Modifier.pointerInput(onLongClick) { detectTapGestures(onLongPress = { onLongClick?.invoke() }) @@ -120,6 +132,10 @@ internal fun LinkCardView( card = live, minHeight = maxWidth * LinkCardDefaults.CARD_ASPECT, onStart = onClick?.let { click -> { click(live) } }, + // A transcript card opens the group named on it, so it says "View"; the group's + // own empty state draws this card with "Invite People". + ctaLabel = stringResource(R.string.action_linkCard_view), + shape = shape, ) } return @@ -131,6 +147,7 @@ internal fun LinkCardView( BoxWithConstraints( modifier = modifier .fillMaxWidth() + .semantics { linkCardShape = shape } // One target for the whole card in every state: an unresolved link is unresolved, not // broken, and a spent one still opens to the page that says so. .addIf(onClick != null || onLongClick != null) { @@ -144,8 +161,8 @@ internal fun LinkCardView( ) { val height: Dp = maxWidth * LinkCardDefaults.CARD_ASPECT when (live) { - is LinkCard.Cash -> CashLinkCard(card = live, height = height) - is LinkCard.TokenInfo -> TokenLinkCard(card = live, height = height) + is LinkCard.Cash -> CashLinkCard(card = live, height = height, shape = shape) + is LinkCard.TokenInfo -> TokenLinkCard(card = live, height = height, shape = shape) // Drawn above; unreachable here. is LinkCard.GroupInvite, is LinkCard.User -> Unit } @@ -194,6 +211,7 @@ private fun rememberResolvedCard(card: LinkCard): LinkCard { private fun CashLinkCard( card: LinkCard.Cash, height: Dp, + shape: CornerBasedShape, ) { // Unresolved is also the unavailable state: a lookup that failed, timed out or was switched off // renders here. The same voucher, with nothing filled in — same size, same chrome — so nothing @@ -209,6 +227,7 @@ private fun CashLinkCard( CashVoucher( height = height, + shape = shape, loading = loading, tokenName = state?.token?.brandedName() ?: stringResource(R.string.label_linkCard_cash), @@ -293,6 +312,7 @@ private fun TokenRow(tokenName: String, tokenImage: Any?) { private fun TokenLinkCard( card: LinkCard.TokenInfo, height: Dp, + shape: CornerBasedShape, ) { when (val state = card.state) { is LinkCard.TokenInfo.State.Resolved -> TokenCard( @@ -302,6 +322,7 @@ private fun TokenLinkCard( balanceText = "", displayName = state.token.brandedName(), height = height, + shape = shape, ) // The address is the one thing known about the mint before the network answers, and it is @@ -310,12 +331,14 @@ private fun TokenLinkCard( LinkCard.TokenInfo.State.Loading -> UnresolvedTokenCard( mint = card.mint, height = height, + shape = shape, loading = true, ) LinkCard.TokenInfo.State.Unresolved -> UnresolvedTokenCard( mint = card.mint, height = height, + shape = shape, loading = false, ) } @@ -334,9 +357,9 @@ private fun TokenLinkCard( private fun UnresolvedTokenCard( mint: Mint, height: Dp, + shape: CornerBasedShape, loading: Boolean, ) { - val shape = CodeTheme.shapes.medium Box( modifier = Modifier .fillMaxWidth() @@ -384,10 +407,14 @@ private const val ABBREVIATED_MINT_CHARS = 4 * Tearing costs no height. The stub's band is held open whether or not the stub is drawn in it, * because the claim state arrives from the lookup after the row is first drawn — a card that * changed size on resolve would shove the transcript around under the reader. + * + * The upper piece takes [shape]'s top corners and the stub its bottom ones, so the two together + * are the bubble's outline with a seam across it. */ @Composable private fun CashVoucher( height: Dp, + shape: CornerBasedShape, tokenName: String, tokenImage: Any?, amount: String?, @@ -396,7 +423,6 @@ private fun CashVoucher( loading: Boolean, stub: @Composable () -> Unit, ) { - val corner = CodeTheme.shapes.medium.topStart val square = CornerSize(0.dp) val inset = CodeTheme.dimens.inset val stubHeight = height * LinkCardDefaults.STUB_FRACTION @@ -408,7 +434,7 @@ private fun CashVoucher( ) { VoucherPiece( modifier = Modifier.weight(1f), - shape = RoundedCornerShape(corner, corner, square, square), + shape = shape.copy(bottomEnd = square, bottomStart = square), spent = spent, notchAtBottom = true, // Nothing left to tear along once it has been torn. @@ -462,13 +488,14 @@ private fun CashVoucher( // [stub] standing inside it. GhostStub( modifier = Modifier.height(stubHeight), - corner = corner, + bottomStart = shape.bottomStart, + bottomEnd = shape.bottomEnd, content = stub, ) } else { VoucherPiece( modifier = Modifier.height(stubHeight), - shape = RoundedCornerShape(square, square, corner, corner), + shape = shape.copy(topStart = square, topEnd = square), spent = spent, notchAtBottom = false, scored = false, @@ -498,7 +525,8 @@ private fun CashVoucher( @Composable private fun GhostStub( modifier: Modifier, - corner: CornerSize, + bottomStart: CornerSize, + bottomEnd: CornerSize, content: @Composable () -> Unit, ) { val density = LocalDensity.current @@ -516,7 +544,13 @@ private fun GhostStub( .fillMaxWidth() .drawBehind { val r = notchRadius - val c = corner.toPx(size, this) + val start = bottomStart.toPx(size, this) + val end = bottomEnd.toPx(size, this) + val (left, right) = if (layoutDirection == LayoutDirection.Ltr) { + start to end + } else { + end to start + } val w = size.width val h = size.height val outline = Path().apply { @@ -525,10 +559,10 @@ private fun GhostStub( arcTo(Rect(Offset(-r, -r), Size(r * 2, r * 2)), 90f, -90f, false) lineTo(w - r, 0f) arcTo(Rect(Offset(w - r, -r), Size(r * 2, r * 2)), 180f, -90f, false) - lineTo(w, h - c) - arcTo(Rect(Offset(w - c * 2, h - c * 2), Size(c * 2, c * 2)), 0f, 90f, false) - lineTo(c, h) - arcTo(Rect(Offset(0f, h - c * 2), Size(c * 2, c * 2)), 90f, 90f, false) + lineTo(w, h - right) + arcTo(Rect(Offset(w - right * 2, h - right * 2), Size(right * 2, right * 2)), 0f, 90f, false) + lineTo(left, h) + arcTo(Rect(Offset(0f, h - left * 2), Size(left * 2, left * 2)), 90f, 90f, false) close() } drawPath( @@ -551,7 +585,7 @@ private fun GhostStub( @Composable private fun VoucherPiece( modifier: Modifier, - shape: RoundedCornerShape, + shape: Shape, spent: Boolean, notchAtBottom: Boolean, scored: Boolean, @@ -675,6 +709,13 @@ private fun StubPill(text: String) { ) } +/** + * The outline a card was drawn with, for tests: it is otherwise only visible as pixels, and the + * point of it is that it matches [bubbleShape] at the card's place in its run. + */ +internal val LinkCardShapeKey = SemanticsPropertyKey("LinkCardShape") +private var SemanticsPropertyReceiver.linkCardShape by LinkCardShapeKey + private object LinkCardDefaults { /** * 224dp of card across 328dp of usable width — the wallet deck's own numbers on a 360dp phone diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 27cc6cbe88..75b11d1e18 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -82,6 +82,18 @@ import com.getcode.ui.core.addIf enum class BubblePosition { Solo, First, Middle, Last } private const val BUBBLE_MAX_WIDTH_FRACTION = 0.78f + +/** + * How wide a link card is drawn in a transcript whose rows are inset by [rowInset] on each side. + * + * The same fraction as a text bubble, but of the transcript's full width rather than of the inset + * row, which is how iOS sizes its link cards and its group's head card + * (`ChatViewController.maxBubbleWidthFraction`). Taken of the inset row, the cards came out about + * 30dp narrower than iOS's on the same screen. + */ +fun transcriptCardWidth(rowWidth: Dp, rowInset: Dp): Dp = + (rowWidth + rowInset * 2) * BUBBLE_MAX_WIDTH_FRACTION + private val EDITED_MARKER_GAP = 6.dp private const val CASH_BUBBLE_MAX_WIDTH_FRACTION = 0.64f @@ -106,7 +118,10 @@ fun ContentBubble( val actionHandler = LocalChatActionHandler.current val jumbo = remember(item) { item.rendersBareEmoji() } BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val bubbleMaxWidth = when (item.content) { + val isCardRow = item.linkCard != null && item.part == MessagePart.Card + val bubbleMaxWidth = if (isCardRow) { + transcriptCardWidth(rowWidth = maxWidth, rowInset = CodeTheme.dimens.inset) + } else when (item.content) { is MessageContent.Text -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION is MessageContent.Cash -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION is MessageContent.Deleted -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION @@ -270,7 +285,8 @@ fun ChatListItem.ContentBubble.rendersBareEmoji(): Boolean { /** * Whether this row draws with no bubble behind it: a bare emoji, or the card row of a split - * message. Either one breaks the bubble run and moves the "Edited" marker to the line under it. + * message. Either one moves the "Edited" marker to the line under it; only the emoji breaks the + * bubble run (see [groupsWith]). */ fun ChatListItem.ContentBubble.rendersBare(): Boolean = part == MessagePart.Card || rendersBareEmoji() @@ -522,9 +538,13 @@ internal const val JUMBO_EMOJI_TAG = "bubble_jumbo_emoji" /** * The card row of a split message, with no bubble behind it. * - * Everything the bubble would have contributed is already the card's: the fill, the rounded corners - * and the tap. What is not the card's is the flash a jump leaves on the message it landed on, which - * belongs to the transcript rather than to the bubble -- so this goes through [Bubble] as the jumbo + * Everything the bubble would have contributed is already the card's: the fill, the corners and the + * tap. The corners are still the bubble's -- [bubbleShape] at this row's [position] -- handed to the + * card so its frame, its pieces and its border all follow them, and it squares off against its + * neighbours in a run the way a text bubble does. + * + * What is not the card's is the flash a jump leaves on the message it landed on, which belongs to + * the transcript rather than to the bubble -- so this goes through [Bubble] as the jumbo * emoji does, with `bare` dropping the fill and the horizontal inset and leaving the flash, the * width ceiling and the corner clip where every other bubble already gets them. * @@ -546,11 +566,13 @@ private fun BareLinkCard( attention: () -> Float = { 0f }, ) { val onCardClick = rememberLinkCardClick() + val shape = bubbleShape(position, isFromSelf) Bubble( isFromSelf = isFromSelf, position = position, maxWidth = maxWidth, modifier = modifier, + shape = shape, bare = true, // The card runs to the full width the bubble would have had, and to the full height. Its // own inset is inside its frame, so padding here would be a second one -- and with no fill @@ -578,6 +600,7 @@ private fun BareLinkCard( LinkCardView( card = card, modifier = Modifier.addIf(card is LinkCard.User) { Modifier.fillMaxWidth() }, + shape = shape, // Dropped with the backdrop up, like every other target on the row. onClick = if (interactive) onCardClick else null, // The card takes the press for its own tap, so it has to hand the transcript's @@ -775,6 +798,7 @@ private fun Bubble( horizontalPadding: Dp = BubbleDefaults.paddingHorizontal, verticalPadding: Dp = BubbleDefaults.paddingVertical, bare: Boolean = false, + shape: Shape = bubbleShape(position, isFromSelf), attention: () -> Float = { 0f }, content: @Composable BoxScope.() -> Unit, ) { @@ -783,7 +807,6 @@ private fun Bubble( } else { CodeTheme.colors.chat.incomingBubble } - val shape = bubbleShape(position, isFromSelf) Box( modifier = modifier .widthIn(min = minWidth, max = maxWidth) @@ -826,7 +849,7 @@ private fun Bubble( } @Composable -fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): Shape { +fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): RoundedCornerShape { val l = BubbleDefaults.cornerLarge val s = BubbleDefaults.cornerSmall @@ -874,10 +897,14 @@ private data class BubbleCorners( /** * Whether [item] tucks into the same run as the bubble [other] next to it. * - * A bare emoji or a card row breaks the run on both sides. It draws no bubble, so there is no edge for its + * A bare emoji breaks the run on both sides. It draws no bubble, so there is no edge for its * neighbour to square itself against, and a squared corner facing open space reads as half a bubble - * with the other half missing. Either side being bare is enough, so the message under an emoji + * with the other half missing. Either side being an emoji is enough, so the message under one * closes its top corners the same way the message above it closes its bottom ones. + * + * A card row does not break it. The card is drawn in the bubble's outline (see [BareLinkCard]), so + * it has the edge an emoji lacks, and an invite -- a card and then the note sent with it -- reads + * as one run rather than two unrelated messages. */ private fun groupsWith( item: ChatListItem.ContentBubble, @@ -886,8 +913,8 @@ private fun groupsWith( ): Boolean = other != null && item.isSameAuthorAs(other) && config.isGrouped(item.timestamp, other.timestamp) && - !item.rendersBare() && - !other.rendersBare() + !item.rendersBareEmoji() && + !other.rendersBareEmoji() fun bubblePositionOf( index: Int, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt index da5493fe45..318fdef210 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.geometry.isUnspecified import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity @@ -48,8 +49,9 @@ import kotlin.time.Instant * text needs up to the width it is given. Tapped as a whole; a card with no account behind it takes * no tap. * - * Built from the person's public profile only, and shares nothing with the tip card. The corner - * and border are the group invite card's; the rest is in [UserLinkCardDefaults]. + * Built from the person's public profile only, and shares nothing with the tip card. [shape] is + * the outline of the bubble the card stands in for, as for every link card; the border is the + * group invite card's, and the rest is in [UserLinkCardDefaults]. * * At accessibility font sizes the avatar moves above the text and every line wraps, where a row * would cut each one short. @@ -63,9 +65,9 @@ internal fun UserLinkCard( card: LinkCard.User, onClick: (() -> Unit)?, modifier: Modifier = Modifier, + shape: Shape = GroupInviteCardDefaults.SHAPE, onLongClick: (() -> Unit)? = null, ) { - val shape = GroupInviteCardDefaults.SHAPE val state = card.state if (state is LinkCard.User.State.Loading) { @@ -220,8 +222,9 @@ private fun Identity( } /** - * This card's own values, one for one with iOS's `LinkUserCardContent.Layout`. The corner radius - * and border are [GroupInviteCardDefaults.SHAPE] and [GroupInviteCardDefaults.STROKE]. + * This card's own values, one for one with iOS's `LinkUserCardContent.Layout`. In a transcript the + * outline is the bubble's; outside one it is [GroupInviteCardDefaults.SHAPE]. The border is + * [GroupInviteCardDefaults.STROKE]. */ internal object UserLinkCardDefaults { /** The black over the decoded BlurHash backdrop. Provisional, pending design sign-off. */ diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt index c8a8d49d25..353451f3ba 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.chat.ui +import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.LinkCard @@ -131,7 +132,7 @@ class BubblePositionTest { } @Test - fun `a card row breaks the run around it, as a bare emoji does`() { + fun `a card row joins the run around it, as a text bubble does`() { val link = "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" val text = "before $link after" val carded = bubble(2, secondsIn = 10, senderId = alice.userId, text = text).copy( @@ -150,13 +151,35 @@ class BubblePositionTest { assertEquals( listOf( - BubblePosition.Last, // newest, grouped under the trailing row - BubblePosition.First, // trailing, its run closed above by the card - BubblePosition.Solo, // card, in no run - BubblePosition.Last, // leading, its run closed below by the card - BubblePosition.First, // oldest, grouped over the leading row + BubblePosition.Last, // newest + BubblePosition.Middle, // trailing text + BubblePosition.Middle, // card + BubblePosition.Middle, // leading text + BubblePosition.First, // oldest ), positionsOf(items), ) } + + @Test + fun `an invite's card and the note after it read as one run`() { + // Two messages: the invite link on its own, which draws as a card and nothing else, then + // the note typed with it. + val link = "https://app.flipcash.com/chat/6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162" + val invite = bubble(1, secondsIn = 0, senderId = alice.userId, text = link).copy( + linkCard = LinkCard.GroupInvite( + url = link, + start = 0, + end = link.length, + chatId = ChatId(ByteArray(16)), + state = LinkCard.GroupInvite.State.Loading, + ), + ).splitAroundLinkCard().single() + val items = listOf(bubble(2, secondsIn = 1, senderId = alice.userId), invite) + + assertEquals( + listOf(BubblePosition.Last, BubblePosition.First), + positionsOf(items), + ) + } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkCardShapeTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkCardShapeTest.kt new file mode 100644 index 0000000000..0ecac6623f --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkCardShapeTest.kt @@ -0,0 +1,126 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.junit4.createComposeRule +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.splitAroundLinkCard +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * A link card is drawn in the outline a text bubble would have at the same place in its run, so + * a card squares off against its neighbours the way the bubbles around it do. + */ +@RunWith(RobolectricTestRunner::class) +class LinkCardShapeTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val cashLink = "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + private val inviteLink = "https://app.flipcash.com/chat/6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162" + + private fun cardRow(card: LinkCard, isFromSelf: Boolean) = ChatListItem.ContentBubble( + messageId = 1, + contentIndex = 0, + content = MessageContent.Text(card.url), + isFromSelf = isFromSelf, + timestamp = Instant.fromEpochSeconds(1_000), + linkCard = card, + ).splitAroundLinkCard().single() + + private val cash = LinkCard.Cash( + url = cashLink, + start = 0, + end = cashLink.length, + entropy = "KNi8pQr1n5hRU65vKJGge3", + state = LinkCard.Cash.State.Unresolved, + ) + + private val invite = LinkCard.GroupInvite( + url = inviteLink, + start = 0, + end = inviteLink.length, + chatId = ChatId(ByteArray(16)), + state = LinkCard.GroupInvite.State.Unavailable, + ) + + private val personLink = "https://flipcash.com/someone" + + private val person = LinkCard.User( + url = personLink, + start = 0, + end = personLink.length, + identity = LinkCard.User.Identity.ByUsername("someone"), + state = LinkCard.User.State.NotFound, + ) + + private val hasShape = SemanticsMatcher.keyIsDefined(LinkCardShapeKey) + + /** Every position, from both sides, for [card]: the shape drawn and the one a bubble takes. */ + private fun assertCardShapesMatchBubbles(card: LinkCard) { + val cases = BubblePosition.entries.flatMap { position -> + listOf(true, false).map { isFromSelf -> position to isFromSelf } + } + val expected = arrayOfNulls(cases.size) + + composeTestRule.setContent { + DesignSystem { + Column { + cases.forEachIndexed { index, (position, isFromSelf) -> + expected[index] = bubbleShape(position, isFromSelf) + ContentBubble(item = cardRow(card, isFromSelf), position = position) + } + } + } + } + + val drawn = composeTestRule.onAllNodes(hasShape, useUnmergedTree = true) + .fetchSemanticsNodes() + .map { it.config[LinkCardShapeKey] } + + assertEquals(cases.size, drawn.size) + cases.forEachIndexed { index, case -> + assertEquals(expected[index], drawn[index], "shape at $case") + } + } + + @Test + fun `a cash card takes the bubble's corners at every position`() { + assertCardShapesMatchBubbles(cash) + } + + @Test + fun `a group invite card takes the bubble's corners at every position`() { + assertCardShapesMatchBubbles(invite) + } + + @Test + fun `a person card takes the bubble's corners at every position`() { + assertCardShapesMatchBubbles(person) + } + + @Test + fun `positions in a run give a card different corners`() { + // Guards the comparison above: if every position resolved to the same outline, matching + // bubbleShape would prove nothing. + val shapes = mutableListOf() + composeTestRule.setContent { + DesignSystem { + BubblePosition.entries.forEach { shapes += bubbleShape(it, isFromSelf = true) } + } + } + composeTestRule.waitForIdle() + assertEquals(BubblePosition.entries.size, shapes.distinct().size) + } +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt index 7a086384a2..748c563cfe 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt @@ -23,7 +23,9 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -63,6 +65,22 @@ sealed interface ChatInputSubmit { /** Confirms an edit of a message already in the transcript. */ data class ConfirmEdit(override val perform: () -> Unit) : ChatInputSubmit + + /** + * A named action that does not need the field's text, such as sending a group invite with an + * optional message. Shown as [label] whether or not anything is typed, or as a spinner in the + * label's place while [busy], so the button keeps its width as the action runs. + */ + data class Action( + val label: String, + val busy: Boolean = false, + override val perform: () -> Unit, + ) : ChatInputSubmit +} + +object ChatInputDefaults { + /** The field's own fill. A caller that draws its own glass behind the field passes transparent. */ + val ContainerColor = Color(0xAD1E1E1E) } @Composable @@ -72,10 +90,11 @@ fun ChatInput( hint: String = "", state: TextFieldState = rememberTextFieldState(), focusRequester: FocusRequester = remember { FocusRequester() }, + containerColor: Color = ChatInputDefaults.ContainerColor, submit: ChatInputSubmit, ) { val shape = CodeTheme.shapes.medium - val sendVisible = state.text.isNotEmpty() + val sendVisible = state.text.isNotEmpty() || submit is ChatInputSubmit.Action val sendSpec = spring(dampingRatio = 0.66f, stiffness = 4000f) val sendAlpha by animateFloatAsState( targetValue = if (sendVisible) 1f else 0f, @@ -118,7 +137,7 @@ fun ChatInput( bottom = CodeTheme.dimens.staticGrid.x2, ), colors = inputColors( - backgroundColor = Color(0xAD1E1E1E), + backgroundColor = containerColor, borderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, ), @@ -139,7 +158,7 @@ fun ChatInput( .clip(CodeTheme.shapes.extraSmall) // Reads the current submit rather than the one the crossfade happens to // be showing, so a tap mid-transition does what the composer is now for. - .clickable(enabled = sendVisible) { submit.perform() } + .clickable(enabled = sendVisible && enabled) { submit.perform() } .padding(CodeTheme.dimens.staticGrid.x1), ) { // The button stays put and only its glyph changes, so entering and leaving edit @@ -156,7 +175,28 @@ fun ChatInput( }, label = "send glyph", ) { target -> - if (target is ChatInputSubmit.ConfirmEdit) { + if (target is ChatInputSubmit.Action) { + // Reads the live submit, not the crossfade's captured target: the busy + // flag changes without changing the kind, so no crossfade runs for it. + val busy = (submit as? ChatInputSubmit.Action)?.busy == true + Box(contentAlignment = Alignment.Center) { + Text( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.staticGrid.x2) + .graphicsLayer { alpha = if (busy) 0f else 1f }, + text = target.label, + style = CodeTheme.typography.textMedium, + color = Color.Black, + ) + if (busy) { + CircularProgressIndicator( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + color = Color.Black, + strokeWidth = 2.dp, + ) + } + } + } else if (target is ChatInputSubmit.ConfirmEdit) { Icon( modifier = Modifier .testTag("chat_confirm_edit_icon") @@ -209,6 +249,35 @@ private fun Preview_ChatInput_Typing() { } } +@Preview +@Composable +private fun Preview_ChatInput_Action() { + DesignSystem { + Box(modifier = Modifier.background(Color(0xFF19191A))) { + ChatInput( + modifier = Modifier.padding(15.dp), + hint = "Add a message", + submit = ChatInputSubmit.Action(label = "Invite") {}, + ) + } + } +} + +@Preview +@Composable +private fun Preview_ChatInput_ActionBusy() { + DesignSystem { + Box(modifier = Modifier.background(Color(0xFF19191A))) { + ChatInput( + modifier = Modifier.padding(15.dp), + hint = "Add a message", + enabled = false, + submit = ChatInputSubmit.Action(label = "Invite", busy = true) {}, + ) + } + } +} + @Preview @Composable private fun Preview_ChatInput_Editing() {