From 273fa99fbca00f626a61d5dc4493d6ba898d499c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 19:40:30 -0400 Subject: [PATCH 01/13] feat(groups): open a new private group on its conversation --- .../com/flipcash/app/tipping/NewGroupFlowScreen.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt index a648159ec6..dadfcff187 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt @@ -85,13 +85,10 @@ fun NewGroupFlowScreen( navigator.popUntil { it !is AppRoute.Messaging.NewGroup && it !is AppRoute.Messaging.NewChat } - // A private group opens on its profile (surface 6): nothing can happen in the - // chat until the host approves someone, and the profile is where requests are. + // Every new group opens on the conversation, private or not. A group with only + // its creator in it shows the "Your Group Is Ready" invite card there. navigator.push( - AppRoute.Messaging.Chat( - identifier = ChatIdentifier.ByChatId(chatId), - openOnProfile = route.isPrivate, - ) + AppRoute.Messaging.Chat(identifier = ChatIdentifier.ByChatId(chatId)) ) } } From 4016fa5a43a2379811e7269eafe68a74376e5a12 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 19:40:30 -0400 Subject: [PATCH 02/13] feat(chat): invite card at the top of a group only its creator is in --- .../src/androidMain/res/values/strings.xml | 3 + .../internal/screens/MessengerScreen.kt | 16 +++ .../screens/components/GroupReadyCard.kt | 88 +++++++++++++++ .../screens/components/MessageList.kt | 35 ++++++ .../messenger/internal/GroupReadyCardTest.kt | 62 +++++++++++ .../shared/chat/ui/GroupReadyCardBubble.kt | 100 ++++++++++++++++++ libs/analytics-events/EVENTS.md | 4 +- libs/analytics-events/events.toml | 4 +- 8 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupReadyCard.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupReadyCardTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index 73799d7a81..87e751cf40 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1578,6 +1578,9 @@ Join %1$s on Flipcash and let\'s chat Invite People + + Your Group Is Ready + Invite people to join %1$s Share Recent Chats 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 fdf3f57cd4..007ba2ff55 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 @@ -66,6 +66,7 @@ import com.getcode.opencode.model.core.ID import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.messenger.internal.invites.InviteHostViewModel import com.flipcash.app.core.chat.PinnedMessageJump import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator @@ -184,6 +185,12 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { if (privateOutsider) flowNavigator.replaceStack(listOf(ChatStep.GroupProfile)) } + // The same standing the group profile's Share button reads, so the transcript's invite card + // opens the sheet that button does. + val inviteHost = hiltViewModel() + val canInviteByChat by inviteHost.canInvite.collectAsStateWithLifecycle() + val viewerCanInvite = state.chatId?.let { canInviteByChat[it] } == true + // Here rather than in the action handler because the lookup comes first: the tap only knows a // handle, and where it leads is the view model's to decide once the handle has an answer. LaunchedEffect(viewModel) { @@ -790,6 +797,15 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { onOriginsReached = { viewModel.dispatchEvent(ChatViewModel.Event.ReturnOriginsReached(it)) }, onReachedNewest = { viewModel.dispatchEvent(ChatViewModel.Event.ReturnStackCleared) }, hazeState = hazeState, + viewerCanInvite = viewerCanInvite, + onInvitePeople = { + viewModel.dispatchEvent( + ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.CHAT) + ) + // The card only shows to a viewer who can invite, so this is the host + // branch of the group profile's Share button; see GroupProfileScreen. + navigator.openInviteHost { flowNavigator.navigateTo(ChatStep.ManageInvites) } + }, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupReadyCard.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupReadyCard.kt new file mode 100644 index 0000000000..7ded73ff8c --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupReadyCard.kt @@ -0,0 +1,88 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.keyframes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * Whether the "Your Group Is Ready" card is in the transcript. The spec iOS follows says the same: + * a group, its roster loaded, the viewer the only member, and the viewer allowed to invite. + * + * [rosterResolved] is separate from [memberCount] because an unloaded group reads as a count of + * zero or a stale one, and a card that flashed up before the roster arrived would leave again. + */ +internal fun showsGroupReadyCard( + isGroup: Boolean, + rosterResolved: Boolean, + memberCount: Long, + viewerCanInvite: Boolean, +): Boolean = isGroup && rosterResolved && memberCount == 1L && viewerCanInvite + +/** How long the card takes to leave once someone has joined. */ +internal const val GROUP_READY_POP_MILLIS = 280 + +private const val POP_PEAK_MILLIS = 90 +private const val POP_PEAK_SCALE = 1.08f + +/** + * Keeps the card composed while it leaves. The list has no item animations, so when [show] turns + * false the card is held for [GROUP_READY_POP_MILLIS], scaling 1 -> 1.08 -> 0 and fading out, then + * [present] goes false and the list drops the item. + * + * A card that was never shown does not animate: opening on a group that already has members just + * leaves [present] false. And only a join pops it; losing the right to invite removes it at once. + */ +@Stable +internal class GroupReadyCardPresence(initiallyPresent: Boolean) { + var present by mutableStateOf(initiallyPresent) + private set + val scale = Animatable(1f) + val alpha = Animatable(1f) + + suspend fun update(show: Boolean, joined: Boolean) { + if (show) { + scale.snapTo(1f) + alpha.snapTo(1f) + present = true + } else if (present) { + if (joined) { + coroutineScope { + launch { + scale.animateTo(0f, keyframes { + durationMillis = GROUP_READY_POP_MILLIS + 1f at 0 + POP_PEAK_SCALE at POP_PEAK_MILLIS using FastOutSlowInEasing + 0f at GROUP_READY_POP_MILLIS using FastOutSlowInEasing + }) + } + launch { + alpha.animateTo(0f, keyframes { + durationMillis = GROUP_READY_POP_MILLIS + 1f at 0 + 1f at POP_PEAK_MILLIS using LinearEasing + 0f at GROUP_READY_POP_MILLIS using LinearEasing + }) + } + } + } + present = false + } + } +} + +@Composable +internal fun rememberGroupReadyCardPresence(show: Boolean, joined: Boolean): GroupReadyCardPresence { + val presence = remember { GroupReadyCardPresence(initiallyPresent = show) } + LaunchedEffect(show) { presence.update(show, joined) } + return presence +} 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 58c7dea4f3..565bbea59b 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 @@ -84,6 +84,7 @@ 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.GroupReadyCardBubble import com.flipcash.shared.chat.ui.GroupInviteLinkCard import com.flipcash.shared.chat.ui.QuickReactionStripPlacement import com.flipcash.shared.chat.ui.transcriptBubbleMaxWidth @@ -121,6 +122,8 @@ internal fun MessageList( onOriginsReached: (Set) -> Unit = {}, onReachedNewest: () -> Unit = {}, hazeState: HazeState? = null, + viewerCanInvite: Boolean = false, + onInvitePeople: () -> Unit = {}, ) { val keyboard = rememberKeyboardController() val listState = rememberLazyListState() @@ -128,6 +131,19 @@ internal fun MessageList( // Whether messages are attributed at all, which is a property of the chat and not of any one // row: a DM needs no avatar column, a group reserves one on every incoming row. val isGroup = state.chatType == ChatType.GROUP + // Not a message, so it is not in `messages`: the transcript's own item, derived from state and + // held in place while it pops out. See GroupReadyCardPresence. + val groupSubject = state.subject as? ChatSubject.Group + val memberCount = groupSubject?.memberCount ?: 0L + val groupReadyPresence = rememberGroupReadyCardPresence( + show = showsGroupReadyCard( + isGroup = isGroup, + rosterResolved = groupSubject != null && state.resolveState is ChatViewModel.ResolveState.Resolved, + memberCount = memberCount, + viewerCanInvite = viewerCanInvite, + ), + joined = memberCount > 1L, + ) // Both provided here rather than passed down: a link card is drawn from several places inside // the bubble, and threading either through every one of them would be a parameter on a lot of @@ -756,6 +772,25 @@ internal fun MessageList( } } + // Oldest of everything, so last here: the topmost item of the reversed list. The + // list is arranged Top, so a transcript shorter than the screen packs against + // the bar rather than the composer, and this sits right under the bar. + if (groupReadyPresence.present) { + item(key = "group-ready-card") { + GroupReadyCardBubble( + groupName = groupSubject?.title.orEmpty(), + onInvite = onInvitePeople, + modifier = Modifier + .padding(bottom = CodeTheme.dimens.grid.x2) + .graphicsLayer { + scaleX = groupReadyPresence.scale.value + scaleY = groupReadyPresence.scale.value + alpha = groupReadyPresence.alpha.value + }, + ) + } + } + // Room at the start of history for the quick reaction strip, made only while a // message near the start needs it: the strip sits above the message it acts on. item(key = "history-start") { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupReadyCardTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupReadyCardTest.kt new file mode 100644 index 0000000000..d2802bb46a --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupReadyCardTest.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.messenger.internal.screens.components.GroupReadyCardPresence +import com.flipcash.app.messenger.internal.screens.components.showsGroupReadyCard +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** When the "Your Group Is Ready" card is in the transcript. */ +class GroupReadyCardTest { + + private fun shows( + isGroup: Boolean = true, + rosterResolved: Boolean = true, + memberCount: Long = 1, + viewerCanInvite: Boolean = true, + ) = showsGroupReadyCard(isGroup, rosterResolved, memberCount, viewerCanInvite) + + @Test + fun aloneWithAResolvedRosterAndTheRightToInviteShowsIt() = assertTrue(shows()) + + @Test + fun anUnknownRosterShowsNothing() { + // The count of an unloaded group is a default, which could be 1. + assertFalse(shows(rosterResolved = false, memberCount = 1)) + assertFalse(shows(rosterResolved = false, memberCount = 0)) + } + + @Test + fun aViewerWhoCannotInviteSeesNothing() = assertFalse(shows(viewerCanInvite = false)) + + @Test + fun anyoneElseInTheGroupRemovesIt() { + assertFalse(shows(memberCount = 2)) + assertFalse(shows(memberCount = 0)) + } + + @Test + fun aDirectChatNeverShowsIt() = assertFalse(shows(isGroup = false)) + + @Test + fun aScreenOpenedOnAnOccupiedGroupNeverHoldsTheCard() = runTest { + val presence = GroupReadyCardPresence(initiallyPresent = false) + presence.update(show = false, joined = true) + assertFalse(presence.present) + } + + @Test + fun theCardAppearsWhenTheRosterResolvesAlone() = runTest { + val presence = GroupReadyCardPresence(initiallyPresent = false) + presence.update(show = true, joined = false) + assertTrue(presence.present) + } + + @Test + fun losingTheRightToInviteDropsItWithoutAPop() = runTest { + val presence = GroupReadyCardPresence(initiallyPresent = true) + presence.update(show = false, joined = false) + assertFalse(presence.present) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt new file mode 100644 index 0000000000..de58682bb4 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt @@ -0,0 +1,100 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.core.R +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton + +/** + * "Your Group Is Ready": the card at the top of a group's transcript while its viewer is the only + * member. Not a message. It is drawn as an incoming bubble in the share-profile widget's container, + * with the same filled button, and has no sender, time or reactions. + */ +@Composable +fun GroupReadyCardBubble( + groupName: String, + onInvite: () -> Unit, + modifier: Modifier = Modifier, +) { + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val width = minOf(maxWidth, ShareProfileWidgetDefaults.WIDTH) + val shape = bubbleShape(BubblePosition.Solo, isFromSelf = false) + Bubble( + isFromSelf = false, + position = BubblePosition.Solo, + minWidth = width, + maxWidth = width, + bare = true, + shape = shape, + horizontalPadding = CodeTheme.dimens.staticGrid.x3, + verticalPadding = CodeTheme.dimens.staticGrid.x3, + modifier = Modifier + .align(Alignment.CenterStart) + .background(White05, shape) + .border(CodeTheme.dimens.border, ShareProfileWidgetDefaults.STROKE, shape), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(ShareProfileWidgetDefaults.SECTION_GAP), + ) { + Column(verticalArrangement = Arrangement.spacedBy(ShareProfileWidgetDefaults.LINE_GAP)) { + Text( + text = stringResource(R.string.title_groupReady), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Text( + text = stringResource(R.string.subtitle_groupReady, groupName), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + CodeButton( + modifier = Modifier + .fillMaxWidth() + .height(CodeTheme.dimens.staticGrid.x11), + onClick = onInvite, + buttonState = ButtonState.Filled, + shape = RoundedCornerShape(ShareProfileWidgetDefaults.BUTTON_RADIUS), + overrideContentPadding = true, + contentPadding = PaddingValues(0.dp), + ) { + Text( + text = stringResource(R.string.title_invitePeople), + style = CodeTheme.typography.textMedium, + ) + } + } + } + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_GroupReadyCardBubble() { + GroupReadyCardBubble(groupName = "Brothers", onInvite = {}, modifier = Modifier.padding(16.dp)) +} diff --git a/libs/analytics-events/EVENTS.md b/libs/analytics-events/EVENTS.md index 392b0f7a2f..2eab241348 100644 --- a/libs/analytics-events/EVENTS.md +++ b/libs/analytics-events/EVENTS.md @@ -905,11 +905,11 @@ The `Field` property on Group: Edited: what the edit changed. ### GroupInviteSheetSource -The `Source` property on Group: Invite Sheet Opened. Both apps send Profile, from the group profile and the join requests screen. +The `Source` property on Group: Invite Sheet Opened. Both apps send Profile, from the group profile and the join requests screen, and Chat, from the Your Group Is Ready card in a transcript. | Value sent | Name in code | Drift | |---|---|---| -| Chat | `CHAT` | Neither app sends this today. It was Android's transcript CTA and iOS's card at the head of chat history; Android dropped its CTA in #1707 and iOS removed its card in cb036f487. | +| Chat | `CHAT` | | | Profile | `PROFILE` | | ### GroupInviteMethod diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 1d27e1b304..356cb6a737 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -162,9 +162,9 @@ NAME = "Name" PICTURE = "Picture" [lists.GroupInviteSheetSource] -description = "The `Source` property on Group: Invite Sheet Opened. Both apps send Profile, from the group profile and the join requests screen." +description = "The `Source` property on Group: Invite Sheet Opened. Both apps send Profile, from the group profile and the join requests screen, and Chat, from the Your Group Is Ready card in a transcript." [lists.GroupInviteSheetSource.values] -CHAT = { value = "Chat", drift = "Neither app sends this today. It was Android's transcript CTA and iOS's card at the head of chat history; Android dropped its CTA in #1707 and iOS removed its card in cb036f487." } +CHAT = "Chat" PROFILE = "Profile" [lists.GroupInviteMethod] From bccdd693e451be452da5a30920b41be7ef21f288 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 19:44:58 -0400 Subject: [PATCH 03/13] style(chat): group ready card title at the button's text size --- .../kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt index de58682bb4..aa9312b78d 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt @@ -61,7 +61,7 @@ fun GroupReadyCardBubble( Column(verticalArrangement = Arrangement.spacedBy(ShareProfileWidgetDefaults.LINE_GAP)) { Text( text = stringResource(R.string.title_groupReady), - style = CodeTheme.typography.textLarge, + style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain, ) Text( From a49e29f807c1f897905199ebff2e904ed3850f8c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 20:03:33 -0400 Subject: [PATCH 04/13] fix(chat): ask the server for can_invite from the transcript so the group ready card shows --- .../app/messenger/internal/screens/MessengerScreen.kt | 9 +++++++++ 1 file changed, 9 insertions(+) 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 007ba2ff55..263addc73a 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 @@ -190,6 +190,15 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val inviteHost = hiltViewModel() val canInviteByChat by inviteHost.canInvite.collectAsStateWithLifecycle() val viewerCanInvite = state.chatId?.let { canInviteByChat[it] } == true + val inviteGroup = state.subject as? ChatSubject.Group + LaunchedEffect(state.chatId, inviteGroup?.isPrivate, inviteGroup?.isMember) { + // The group profile asks the server before it offers Share; the cached chat does not carry + // can_invite. A group opened straight on its transcript never visits the profile, so ask + // here too, on the same terms, or the card would never see the answer. + if (inviteGroup?.isPrivate == true && inviteGroup.isMember == true) { + state.chatId?.let(inviteHost::refresh) + } + } // Here rather than in the action handler because the lookup comes first: the tap only knows a // handle, and where it leads is the view model's to decide once the handle has an answer. From 0c773347b331e8b58c3ada2dc9603623e9399bfd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 20:19:20 -0400 Subject: [PATCH 05/13] fix(chat): read can_invite from the chat store for the group ready card --- .../internal/screens/MessengerScreen.kt | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) 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 263addc73a..69ebde846d 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 @@ -66,7 +66,6 @@ import com.getcode.opencode.model.core.ID import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.chat.ChatStep -import com.flipcash.app.messenger.internal.invites.InviteHostViewModel import com.flipcash.app.core.chat.PinnedMessageJump import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator @@ -185,20 +184,9 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { if (privateOutsider) flowNavigator.replaceStack(listOf(ChatStep.GroupProfile)) } - // The same standing the group profile's Share button reads, so the transcript's invite card - // opens the sheet that button does. - val inviteHost = hiltViewModel() - val canInviteByChat by inviteHost.canInvite.collectAsStateWithLifecycle() - val viewerCanInvite = state.chatId?.let { canInviteByChat[it] } == true - val inviteGroup = state.subject as? ChatSubject.Group - LaunchedEffect(state.chatId, inviteGroup?.isPrivate, inviteGroup?.isMember) { - // The group profile asks the server before it offers Share; the cached chat does not carry - // can_invite. A group opened straight on its transcript never visits the profile, so ask - // here too, on the same terms, or the card would never see the answer. - if (inviteGroup?.isPrivate == true && inviteGroup.isMember == true) { - state.chatId?.let(inviteHost::refresh) - } - } + // The server's can_invite as the chat store carries it, the same field the group profile's + // Share button is built on, so no extra call when a chat opens. + val viewerCanInvite = state.viewerState?.permissions?.canInvite == true // Here rather than in the action handler because the lookup comes first: the tap only knows a // handle, and where it leads is the view model's to decide once the handle has an answer. From b8febb869f590c5a08881137e57e2376366e6be9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 20:34:33 -0400 Subject: [PATCH 06/13] refactor(chat): draw the group ready card with the share profile widget's bubble --- .../internal/screens/MessengerScreen.kt | 9 +- .../screens/components/GroupReadyCard.kt | 10 +- .../screens/components/MessageList.kt | 3 +- .../messenger/internal/GroupReadyCardTest.kt | 13 ++- .../shared/chat/ui/GroupReadyCardBubble.kt | 74 ++---------- .../chat/ui/ShareProfileWidgetBubble.kt | 108 +++++++++++++----- .../ui/ShareProfileWidgetScreenshotTest.kt | 5 + 7 files changed, 122 insertions(+), 100 deletions(-) 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 69ebde846d..0a979c0df5 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 @@ -799,9 +799,12 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent( ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.CHAT) ) - // The card only shows to a viewer who can invite, so this is the host - // branch of the group profile's Share button; see GroupProfileScreen. - navigator.openInviteHost { flowNavigator.navigateTo(ChatStep.ManageInvites) } + // Both branches of the group profile's Share button; see GroupProfileScreen. + if (viewerCanInvite) { + navigator.openInviteHost { flowNavigator.navigateTo(ChatStep.ManageInvites) } + } else { + navigator.openGroupInvite() + } }, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupReadyCard.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupReadyCard.kt index 7ded73ff8c..0f4cd530e9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupReadyCard.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupReadyCard.kt @@ -16,7 +16,10 @@ import kotlinx.coroutines.launch /** * Whether the "Your Group Is Ready" card is in the transcript. The spec iOS follows says the same: - * a group, its roster loaded, the viewer the only member, and the viewer allowed to invite. + * a group, its roster loaded, the viewer the only member, and the viewer able to invite. + * + * Able to invite is `canInvite || !isPrivate`: the server withholds `can_invite` from a public + * group's creator, yet group info's Share still opens the plain share sheet for them. * * [rosterResolved] is separate from [memberCount] because an unloaded group reads as a count of * zero or a stale one, and a card that flashed up before the roster arrived would leave again. @@ -25,8 +28,9 @@ internal fun showsGroupReadyCard( isGroup: Boolean, rosterResolved: Boolean, memberCount: Long, - viewerCanInvite: Boolean, -): Boolean = isGroup && rosterResolved && memberCount == 1L && viewerCanInvite + canInvite: Boolean, + isPrivate: Boolean, +): Boolean = isGroup && rosterResolved && memberCount == 1L && (canInvite || !isPrivate) /** How long the card takes to leave once someone has joined. */ internal const val GROUP_READY_POP_MILLIS = 280 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 565bbea59b..49758c54cb 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 @@ -140,7 +140,8 @@ internal fun MessageList( isGroup = isGroup, rosterResolved = groupSubject != null && state.resolveState is ChatViewModel.ResolveState.Resolved, memberCount = memberCount, - viewerCanInvite = viewerCanInvite, + canInvite = viewerCanInvite, + isPrivate = groupSubject?.isPrivate == true, ), joined = memberCount > 1L, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupReadyCardTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupReadyCardTest.kt index d2802bb46a..4cd48e02a0 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupReadyCardTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupReadyCardTest.kt @@ -14,8 +14,9 @@ class GroupReadyCardTest { isGroup: Boolean = true, rosterResolved: Boolean = true, memberCount: Long = 1, - viewerCanInvite: Boolean = true, - ) = showsGroupReadyCard(isGroup, rosterResolved, memberCount, viewerCanInvite) + canInvite: Boolean = true, + isPrivate: Boolean = true, + ) = showsGroupReadyCard(isGroup, rosterResolved, memberCount, canInvite, isPrivate) @Test fun aloneWithAResolvedRosterAndTheRightToInviteShowsIt() = assertTrue(shows()) @@ -28,7 +29,13 @@ class GroupReadyCardTest { } @Test - fun aViewerWhoCannotInviteSeesNothing() = assertFalse(shows(viewerCanInvite = false)) + fun aPrivateGroupWithoutCanInviteShowsNothing() = assertFalse(shows(canInvite = false, isPrivate = true)) + + @Test + fun aPublicGroupHostWithoutCanInviteStillSeesIt() = assertTrue(shows(canInvite = false, isPrivate = false)) + + @Test + fun aPublicGroupWithCanInviteSeesIt() = assertTrue(shows(canInvite = true, isPrivate = false)) @Test fun anyoneElseInTheGroupRemovesIt() { diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt index aa9312b78d..8d27afe355 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt @@ -1,35 +1,22 @@ package com.flipcash.shared.chat.ui -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.dp import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.core.R -import com.getcode.theme.CodeTheme -import com.getcode.theme.White05 -import com.getcode.ui.theme.ButtonState -import com.getcode.ui.theme.CodeButton /** * "Your Group Is Ready": the card at the top of a group's transcript while its viewer is the only - * member. Not a message. It is drawn as an incoming bubble in the share-profile widget's container, - * with the same filled button, and has no sender, time or reactions. + * member. Not a message. It is the share-profile widget's bubble ([WidgetCardBubble]) with its own + * words and no avatar, and has no sender, time or reactions. */ @Composable fun GroupReadyCardBubble( @@ -38,57 +25,16 @@ fun GroupReadyCardBubble( modifier: Modifier = Modifier, ) { BoxWithConstraints(modifier = modifier.fillMaxWidth()) { - val width = minOf(maxWidth, ShareProfileWidgetDefaults.WIDTH) - val shape = bubbleShape(BubblePosition.Solo, isFromSelf = false) - Bubble( + WidgetCardBubble( + title = stringResource(R.string.title_groupReady), + subtitle = stringResource(R.string.subtitle_groupReady, groupName), + buttonLabel = stringResource(R.string.title_invitePeople), + onClick = onInvite, isFromSelf = false, position = BubblePosition.Solo, - minWidth = width, - maxWidth = width, - bare = true, - shape = shape, - horizontalPadding = CodeTheme.dimens.staticGrid.x3, - verticalPadding = CodeTheme.dimens.staticGrid.x3, - modifier = Modifier - .align(Alignment.CenterStart) - .background(White05, shape) - .border(CodeTheme.dimens.border, ShareProfileWidgetDefaults.STROKE, shape), - ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(ShareProfileWidgetDefaults.SECTION_GAP), - ) { - Column(verticalArrangement = Arrangement.spacedBy(ShareProfileWidgetDefaults.LINE_GAP)) { - Text( - text = stringResource(R.string.title_groupReady), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) - Text( - text = stringResource(R.string.subtitle_groupReady, groupName), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textMain, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - CodeButton( - modifier = Modifier - .fillMaxWidth() - .height(CodeTheme.dimens.staticGrid.x11), - onClick = onInvite, - buttonState = ButtonState.Filled, - shape = RoundedCornerShape(ShareProfileWidgetDefaults.BUTTON_RADIUS), - overrideContentPadding = true, - contentPadding = PaddingValues(0.dp), - ) { - Text( - text = stringResource(R.string.title_invitePeople), - style = CodeTheme.typography.textMedium, - ) - } - } - } + maxWidth = maxWidth, + modifier = Modifier.align(Alignment.CenterStart), + ) } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt index b7d31ce219..8bad40eb14 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -166,9 +167,62 @@ internal fun ShareProfileWidgetBubble( onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, attention: () -> Float = { 0f }, +) { + WidgetCardBubble( + title = displayName, + subtitle = "@$username", + buttonLabel = stringResource(R.string.action_shareProfile), + buttonIcon = painterResource(R.drawable.ic_share_os), + onClick = onShare, + isFromSelf = isFromSelf, + position = position, + maxWidth = maxWidth, + loading = loading, + modifier = modifier, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + attention = attention, + avatar = { + ContactAvatar( + image = profilePicture, + displayName = displayName, + access = BlobAccessContext.profile(userId), + modifier = Modifier + .size(ShareProfileWidgetDefaults.AVATAR) + .border(CodeTheme.dimens.border, GroupInviteCardDefaults.STROKE, CircleShape) + .clip(CircleShape), + ) + }, + ) +} + +/** + * The container the share-profile widget is drawn in: an incoming bubble with the widget's fill and + * outline, an optional [avatar], a [title] and [subtitle], and one filled button. The share-profile + * widget and the "Your Group Is Ready" card are both this; each supplies its own words and click. + * + * With no [avatar] the text is left-aligned, as in a card that has no face to centre it under. + */ +@Composable +internal fun WidgetCardBubble( + title: String, + subtitle: String, + buttonLabel: String, + onClick: (() -> Unit)?, + isFromSelf: Boolean, + position: BubblePosition, + maxWidth: Dp, + modifier: Modifier = Modifier, + buttonIcon: Painter? = null, + loading: Boolean = false, + onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, + attention: () -> Float = { 0f }, + avatar: (@Composable () -> Unit)? = null, ) { val shape = bubbleShape(position, isFromSelf) val width = minOf(maxWidth, ShareProfileWidgetDefaults.WIDTH) + val textAlignment = if (avatar != null) Alignment.CenterHorizontally else Alignment.Start Bubble( isFromSelf = isFromSelf, position = position, @@ -191,38 +245,34 @@ internal fun ShareProfileWidgetBubble( verticalArrangement = Arrangement.spacedBy(ShareProfileWidgetDefaults.SECTION_GAP), ) { Column( - modifier = Modifier.widthIn(max = ShareProfileWidgetDefaults.IDENTITY_WIDTH), - horizontalAlignment = Alignment.CenterHorizontally, + modifier = if (avatar != null) { + Modifier.widthIn(max = ShareProfileWidgetDefaults.IDENTITY_WIDTH) + } else { + Modifier.fillMaxWidth() + }, + horizontalAlignment = textAlignment, verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3), ) { - ContactAvatar( - image = profilePicture, - displayName = displayName, - access = BlobAccessContext.profile(userId), - modifier = Modifier - .size(ShareProfileWidgetDefaults.AVATAR) - .border(CodeTheme.dimens.border, GroupInviteCardDefaults.STROKE, CircleShape) - .clip(CircleShape), - ) + avatar?.invoke() Column( - horizontalAlignment = Alignment.CenterHorizontally, + horizontalAlignment = textAlignment, verticalArrangement = Arrangement.spacedBy(ShareProfileWidgetDefaults.LINE_GAP), ) { Text( - text = displayName, + text = title, style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, - textAlign = TextAlign.Center, + textAlign = if (avatar != null) TextAlign.Center else TextAlign.Start, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = if (loading) Modifier.shimmer(RoundedCornerShape(4.dp)) else Modifier, ) Text( - text = "@$username", + text = subtitle, style = CodeTheme.typography.caption, color = CodeTheme.colors.textMain.copy(alpha = 0.5f), - textAlign = TextAlign.Center, - maxLines = 1, + textAlign = if (avatar != null) TextAlign.Center else TextAlign.Start, + maxLines = if (avatar != null) 1 else 2, overflow = TextOverflow.Ellipsis, ) } @@ -231,8 +281,8 @@ internal fun ShareProfileWidgetBubble( modifier = Modifier .fillMaxWidth() .height(CodeTheme.dimens.staticGrid.x11), - onClick = { onShare?.invoke() }, - enabled = onShare != null, + onClick = { onClick?.invoke() }, + enabled = onClick != null, buttonState = ButtonState.Filled, shape = RoundedCornerShape(ShareProfileWidgetDefaults.BUTTON_RADIUS), overrideContentPadding = true, @@ -240,14 +290,20 @@ internal fun ShareProfileWidgetBubble( ) { // CodeButton stacks its content, so the pair needs a row of its own. Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(R.drawable.ic_share_os), - contentDescription = null, - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), - ) + if (buttonIcon != null) { + Icon( + painter = buttonIcon, + contentDescription = null, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), + ) + } Text( - modifier = Modifier.padding(start = CodeTheme.dimens.staticGrid.x1), - text = stringResource(R.string.action_shareProfile), + modifier = if (buttonIcon != null) { + Modifier.padding(start = CodeTheme.dimens.staticGrid.x1) + } else { + Modifier + }, + text = buttonLabel, style = CodeTheme.typography.textMedium, ) } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetScreenshotTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetScreenshotTest.kt index 5138f08cd4..b3a75b6764 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetScreenshotTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetScreenshotTest.kt @@ -64,6 +64,11 @@ class ShareProfileWidgetScreenshotTest { PreviewShareProfileWidget(state = LinkCard.User.State.NotFound, username = "satoshi") } + @Test + fun rendersGroupReadyCard() = render("group_ready_card.png") { + GroupReadyCardBubble(groupName = "Brothers", onInvite = {}) + } + private fun render(name: String, content: @Composable () -> Unit) { composeRule.mainClock.autoAdvance = false composeRule.setContent { From 737d8cdb28f8c7f57c9c9c87083e933f378399e6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 20:48:12 -0400 Subject: [PATCH 07/13] feat(chat): show the group ready card as a Flipcash message --- .../src/androidMain/res/values/strings.xml | 1 + .../shared/chat/ui/GroupReadyCardBubble.kt | 55 +++++++++++++++---- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index 87e751cf40..f2d0059866 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1579,6 +1579,7 @@ Invite People + Flipcash Your Group Is Ready Invite people to join %1$s Share diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt index 8d27afe355..06da88854b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt @@ -1,6 +1,16 @@ package com.flipcash.shared.chat.ui +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import com.getcode.theme.CodeTheme import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -16,7 +26,7 @@ import com.flipcash.core.R /** * "Your Group Is Ready": the card at the top of a group's transcript while its viewer is the only * member. Not a message. It is the share-profile widget's bubble ([WidgetCardBubble]) with its own - * words and no avatar, and has no sender, time or reactions. + * words and Flipcash's logo and name as its sender, and has no time or reactions. */ @Composable fun GroupReadyCardBubble( @@ -24,17 +34,40 @@ fun GroupReadyCardBubble( onInvite: () -> Unit, modifier: Modifier = Modifier, ) { - BoxWithConstraints(modifier = modifier.fillMaxWidth()) { - WidgetCardBubble( - title = stringResource(R.string.title_groupReady), - subtitle = stringResource(R.string.subtitle_groupReady, groupName), - buttonLabel = stringResource(R.string.title_invitePeople), - onClick = onInvite, - isFromSelf = false, - position = BubblePosition.Solo, - maxWidth = maxWidth, - modifier = Modifier.align(Alignment.CenterStart), + // Attributed to Flipcash like an incoming group message: the name over the run, the logo in the + // gutter the member avatars use. The words are the app's, so there is no member behind it. + val gutter = CodeTheme.dimens.staticGrid.x5 + val gap = CodeTheme.dimens.grid.x1 + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.title_flipcashSender), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + modifier = Modifier.padding(start = gutter + gap + gap, bottom = gap), ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(gap), + ) { + Image( + painter = painterResource(R.drawable.ic_logo_round_white), + contentDescription = null, + modifier = Modifier.size(gutter).clip(CircleShape), + ) + BoxWithConstraints(modifier = Modifier.weight(1f)) { + WidgetCardBubble( + title = stringResource(R.string.title_groupReady), + subtitle = stringResource(R.string.subtitle_groupReady, groupName), + buttonLabel = stringResource(R.string.title_invitePeople), + onClick = onInvite, + isFromSelf = false, + position = BubblePosition.Solo, + maxWidth = maxWidth, + modifier = Modifier.align(Alignment.CenterStart), + ) + } + } } } From 83e98916f81251be06e3e0fd902fe48904f4af69 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 20:54:09 -0400 Subject: [PATCH 08/13] style(chat): draw the group ready sender as the Flipcash mark on the avatar disc --- .../shared/chat/ui/GroupReadyCardBubble.kt | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt index 06da88854b..ef7d07a9db 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt @@ -1,7 +1,11 @@ package com.flipcash.shared.chat.ui import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.graphics.Brush import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -50,11 +54,20 @@ fun GroupReadyCardBubble( verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(gap), ) { - Image( - painter = painterResource(R.drawable.ic_logo_round_white), - contentDescription = null, - modifier = Modifier.size(gutter).clip(CircleShape), - ) + // The mark on the standard avatar disc, as a member without a picture gets. + Box( + modifier = Modifier + .size(gutter) + .clip(CircleShape) + .background(Brush.linearGradient(CodeTheme.colors.contactAvatar.colors)) + .padding(gap), + ) { + Image( + painter = painterResource(R.drawable.ic_flipcash_logo), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + } BoxWithConstraints(modifier = Modifier.weight(1f)) { WidgetCardBubble( title = stringResource(R.string.title_groupReady), From 8456934330596ce9869034175e487c7ddac1f077 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 21:37:57 -0400 Subject: [PATCH 09/13] style(chat): group ready card subtitle in the message bubble text style --- .../com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt | 1 + .../com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt index ef7d07a9db..9c3fafa78b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt @@ -77,6 +77,7 @@ fun GroupReadyCardBubble( isFromSelf = false, position = BubblePosition.Solo, maxWidth = maxWidth, + subtitleAsMessage = true, modifier = Modifier.align(Alignment.CenterStart), ) } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt index 8bad40eb14..5005d74b9e 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt @@ -219,6 +219,8 @@ internal fun WidgetCardBubble( onDoubleClick: (() -> Unit)? = null, attention: () -> Float = { 0f }, avatar: (@Composable () -> Unit)? = null, + /** Sets the subtitle in the message bubble's body text instead of the caption a handle gets. */ + subtitleAsMessage: Boolean = false, ) { val shape = bubbleShape(position, isFromSelf) val width = minOf(maxWidth, ShareProfileWidgetDefaults.WIDTH) @@ -269,8 +271,8 @@ internal fun WidgetCardBubble( ) Text( text = subtitle, - style = CodeTheme.typography.caption, - color = CodeTheme.colors.textMain.copy(alpha = 0.5f), + style = if (subtitleAsMessage) CodeTheme.typography.textMessage else CodeTheme.typography.caption, + color = if (subtitleAsMessage) CodeTheme.colors.textMain else CodeTheme.colors.textMain.copy(alpha = 0.5f), textAlign = if (avatar != null) TextAlign.Center else TextAlign.Start, maxLines = if (avatar != null) 1 else 2, overflow = TextOverflow.Ellipsis, From 8265d684c3ee3d7d3b9ded1f0d07457a157c1754 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 22:36:57 -0400 Subject: [PATCH 10/13] style(chat): group ready card title in the medium message type --- .../flipcash/shared/chat/ui/GroupReadyCardBubble.kt | 2 +- .../shared/chat/ui/ShareProfileWidgetBubble.kt | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt index 9c3fafa78b..e4f56ee3f5 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt @@ -77,7 +77,7 @@ fun GroupReadyCardBubble( isFromSelf = false, position = BubblePosition.Solo, maxWidth = maxWidth, - subtitleAsMessage = true, + messageTypography = true, modifier = Modifier.align(Alignment.CenterStart), ) } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt index 5005d74b9e..0841d30434 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ShareProfileWidgetBubble.kt @@ -219,8 +219,8 @@ internal fun WidgetCardBubble( onDoubleClick: (() -> Unit)? = null, attention: () -> Float = { 0f }, avatar: (@Composable () -> Unit)? = null, - /** Sets the subtitle in the message bubble's body text instead of the caption a handle gets. */ - subtitleAsMessage: Boolean = false, + /** Sets the title and subtitle in the message bubble's type (medium title, body subtitle), not the profile card's large name and caption handle. */ + messageTypography: Boolean = false, ) { val shape = bubbleShape(position, isFromSelf) val width = minOf(maxWidth, ShareProfileWidgetDefaults.WIDTH) @@ -262,7 +262,7 @@ internal fun WidgetCardBubble( ) { Text( text = title, - style = CodeTheme.typography.textLarge, + style = if (messageTypography) CodeTheme.typography.textMedium else CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, textAlign = if (avatar != null) TextAlign.Center else TextAlign.Start, maxLines = 2, @@ -271,8 +271,8 @@ internal fun WidgetCardBubble( ) Text( text = subtitle, - style = if (subtitleAsMessage) CodeTheme.typography.textMessage else CodeTheme.typography.caption, - color = if (subtitleAsMessage) CodeTheme.colors.textMain else CodeTheme.colors.textMain.copy(alpha = 0.5f), + style = if (messageTypography) CodeTheme.typography.textMessage else CodeTheme.typography.caption, + color = if (messageTypography) CodeTheme.colors.textMain else CodeTheme.colors.textMain.copy(alpha = 0.5f), textAlign = if (avatar != null) TextAlign.Center else TextAlign.Start, maxLines = if (avatar != null) 1 else 2, overflow = TextOverflow.Ellipsis, From 2124745f26a1fa2bad4d60857ebec3deae3d8e2b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 23:13:34 -0400 Subject: [PATCH 11/13] feat(invite): link placeholder, button states, success hold and haptics on the host sheet --- .../internal/invites/InviteHostSheet.kt | 43 +++++++---- .../invites/InviteHostSheetViewModel.kt | 27 +++++-- .../invites/InviteHostSheetViewModelTest.kt | 71 ++++++++++++++++++- 3 files changed, 121 insertions(+), 20 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheet.kt index cd7cd95d58..209d2694b2 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheet.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.togetherWith 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.Row import androidx.compose.foundation.layout.Spacer @@ -28,11 +29,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.flipcash.app.core.ui.shimmer import com.flipcash.app.messenger.internal.screens.LinkPillFrame import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme @@ -122,6 +125,12 @@ private val LinkCardSpacing = 12.dp private val LinkPillHeight = 48.dp private val LinkPillInset = 16.dp +private const val LinkPlaceholderFraction = 0.7f +private val LinkPlaceholderHeight = 12.dp + +// The same resting bar the message link card's skeleton draws. +private val LinkPlaceholderColor = Color.White.copy(alpha = 0.12f) + private const val ApproveFadeMillis = 220 // iOS's host card, which is the reference: the link is Medium 14, and the caption and the switch's @@ -189,21 +198,11 @@ private fun LinkCard( approve -> state.plainUrl?.let { PillText(plainLinkText(it)) } // The whole link, left to PillText to cut in the middle so the token's end shows. invite is InviteLinkState.Ready -> PillText(plainLinkText(invite.url)) - invite == InviteLinkState.Creating -> Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { - CodeCircularProgressIndicator(modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6 / 2)) - Text( - text = stringResource(R.string.label_creatingInviteLink), - style = CodeTheme.typography.textMedium, - color = White50, - ) - } invite == InviteLinkState.Loading -> CodeCircularProgressIndicator(modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6 / 2)) - // No link yet, or the last one revoked: the next tap makes one. - else -> Unit + // No link yet, or the last one revoked: a placeholder the next tap fills. It + // shimmers while that tap makes the link. + else -> LinkPlaceholder(shimmering = invite == InviteLinkState.Creating) } } } @@ -213,7 +212,8 @@ private fun LinkCard( text = stringResource(R.string.action_shareLink), buttonState = ButtonState.Filled, isLoading = state.creating, - enabled = !state.creating, + isSuccess = state.success, + enabled = !state.creating && !state.success, onClick = onShare, ) // The spacing above the caption goes with it, so a card with no caption ends at the button. @@ -240,6 +240,21 @@ private fun LinkCard( } } +/** A link-shaped bar for a card with no link. [shimmering] while the link is being made. */ +@Composable +private fun LinkPlaceholder(shimmering: Boolean) { + Box( + modifier = Modifier + .fillMaxWidth(LinkPlaceholderFraction) + .height(LinkPlaceholderHeight) + .clearAndSetSemantics {} + .let { + if (shimmering) it.shimmer(CircleShape) + else it.background(LinkPlaceholderColor, CircleShape) + }, + ) +} + @Composable private fun PillText(text: String) { Text( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModel.kt index 436feb38e7..cb08d858b1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModel.kt @@ -13,9 +13,12 @@ import com.flipcash.shared.chat.HostInvites import com.flipcash.shared.chat.HostInvites.Companion.isLostInviteAccess import com.flipcash.shared.chat.InviteCreation import com.getcode.util.resources.ResourceHelper +import com.getcode.util.vibration.Vibrator +import com.getcode.view.SuccessHoldDuration import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -40,6 +43,7 @@ internal class InviteHostSheetViewModel @Inject constructor( private val approveStore: ApproveNewMembersStore, private val resources: ResourceHelper, private val analytics: FlipcashAnalytics, + private val vibrator: Vibrator, ) : ViewModel() { data class State( @@ -52,6 +56,8 @@ internal class InviteHostSheetViewModel @Inject constructor( val invite: InviteLinkState = InviteLinkState.Loading, /** The plain link the card shows while [approve] is on. */ val plainUrl: String? = null, + /** This tap made the link; the button shows its checkmark while the sheet holds the new URL. */ + val success: Boolean = false, ) { val creating: Boolean get() = !approve && invite == InviteLinkState.Creating } @@ -124,7 +130,14 @@ internal class InviteHostSheetViewModel @Inject constructor( return } pending = viewModelScope.launch { - ensureInvite(chatId) ?: return@launch + val ensured = ensureInvite(chatId) ?: return@launch + if (ensured.created) { + // Show the new URL and the checkmark for the standard success hold before the full + // sheet takes over. An existing link opens it at once. + _state.update { it.copy(success = true) } + vibrator.vibrate() + delay(SuccessHoldDuration) + } _effects.send(Effect.ShareInvite) } } @@ -133,9 +146,10 @@ internal class InviteHostSheetViewModel @Inject constructor( * The current invite link's URL, made with the default settings when there is none (Open 9). * Null when no link came of it; the error has been shown. */ - private suspend fun ensureInvite(chatId: ChatId): String? { - (_state.value.invite as? InviteLinkState.Ready)?.let { return it.url } + private suspend fun ensureInvite(chatId: ChatId): Ensured? { + (_state.value.invite as? InviteLinkState.Ready)?.let { return Ensured(it.url, created = false) } _state.update { it.copy(invite = InviteLinkState.Creating) } + vibrator.tick() // A load that failed could be hiding a link that exists; make none, or this would add a second. val loaded = hostInvites.load(chatId) loaded.exceptionOrNull()?.let { error -> @@ -148,9 +162,9 @@ internal class InviteHostSheetViewModel @Inject constructor( } return null } - hostInvites.current(chatId)?.let { return settle(it) } + hostInvites.current(chatId)?.let { return settle(it)?.let { url -> Ensured(url, created = false) } } return when (val result = hostInvites.currentOrCreate(chatId).also { analytics.trackInviteLinkCreated(it) }) { - is InviteCreation.Created -> settle(result.invite) ?: run { failed(); null } + is InviteCreation.Created -> settle(result.invite)?.let { Ensured(it, created = true) } ?: run { failed(); null } InviteCreation.TooMany -> { _state.update { it.copy(invite = InviteLinkState.None) } showTooManyInviteLinks( @@ -176,6 +190,9 @@ internal class InviteHostSheetViewModel @Inject constructor( } } + /** [created] is true when this tap made the link, rather than finding one. */ + private data class Ensured(val url: String, val created: Boolean) + private fun settle(invite: HostInvite): String? { val link = invite.toState() _state.update { it.copy(invite = link) } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModelTest.kt index a36b96e92a..d19097f4cf 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModelTest.kt @@ -2,12 +2,20 @@ package com.flipcash.app.messenger.internal.invites import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.chat.InviteId import com.flipcash.shared.chat.ApproveNewMembersStore import com.flipcash.shared.chat.HostInvite import com.flipcash.shared.chat.HostInvites import com.flipcash.shared.chat.InviteCreation import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper +import com.getcode.util.vibration.Vibrator +import com.getcode.view.SuccessHoldDuration +import io.mockk.verify +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -20,6 +28,8 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -40,6 +50,8 @@ class InviteHostSheetViewModelTest { every { getString(any()) } returns "text" } + private val vibrator = mockk(relaxed = true) + /** Every value written, in order, behind a switch that starts off. */ private val writes = mutableListOf() private val stored = MutableStateFlow(false) @@ -67,7 +79,7 @@ class InviteHostSheetViewModelTest { } private fun viewModel() = - InviteHostSheetViewModel(hostInvites, approveStore, resources, RecordingAnalytics()) + InviteHostSheetViewModel(hostInvites, approveStore, resources, RecordingAnalytics(), vibrator) @Test fun `1e shares the plain link and leaves Approve New Members off`() = runTest { @@ -93,4 +105,61 @@ class InviteHostSheetViewModelTest { assertTrue(model.state.value.approve) assertEquals(listOf(true), writes) } + + private fun readyInvite(url: String): HostInvite = + HostInvite( + mockk(relaxed = true) { + every { id } returns InviteId(ByteArray(32) { 9 }) + every { expiresAt } returns null + every { useCount } returns 0 + every { maxUses } returns 0 + }, + url, + ) + + @Test + fun `a tap that creates the link shows loading then success, holds, then opens the sheet`() = runTest { + val created = readyInvite("https://x/chat/invite#s=new") + coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.Created(created) + val model = viewModel() + model.open(chatId) + val effects = Channel(Channel.UNLIMITED) + backgroundScope.launch { model.effects.collect { effects.send(it) } } + + model.onShare() + runCurrent() + + assertTrue(model.state.value.success) + assertFalse(model.state.value.creating) + assertTrue(model.state.value.invite is InviteLinkState.Ready) + verify(exactly = 1) { vibrator.tick() } + verify(exactly = 1) { vibrator.vibrate(any()) } + assertTrue("held: nothing opened yet", effects.tryReceive().isFailure) + + advanceTimeBy(SuccessHoldDuration - 1.milliseconds) + runCurrent() + assertTrue("still held", effects.tryReceive().isFailure) + + advanceTimeBy(2.milliseconds) + runCurrent() + assertEquals(InviteHostSheetViewModel.Effect.ShareInvite, effects.tryReceive().getOrNull()) + } + + @Test + fun `an existing link opens the sheet at once with no success state or haptic`() = runTest { + val existing = readyInvite("https://x/chat/invite#s=old") + every { hostInvites.current(chatId) } returns existing + val model = viewModel() + model.open(chatId) + val effects = Channel(Channel.UNLIMITED) + backgroundScope.launch { model.effects.collect { effects.send(it) } } + + model.onShare() + runCurrent() + + assertEquals(InviteHostSheetViewModel.Effect.ShareInvite, effects.tryReceive().getOrNull()) + assertFalse(model.state.value.success) + verify(exactly = 0) { vibrator.tick() } + verify(exactly = 0) { vibrator.vibrate(any()) } + } } From e73c5a915a20a0c480360293108f3dc9516fdd4b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 31 Oct 2026 08:41:00 -0400 Subject: [PATCH 12/13] feat(invite): hold the new link for the payment success hold and close the keyboard first --- .../internal/invites/InviteHostSheetViewModel.kt | 4 ++-- .../app/messenger/internal/screens/MessengerScreen.kt | 11 +++++++---- .../internal/invites/InviteHostSheetViewModelTest.kt | 4 ++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModel.kt index cb08d858b1..280d4b0db3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModel.kt @@ -14,7 +14,7 @@ import com.flipcash.shared.chat.HostInvites.Companion.isLostInviteAccess import com.flipcash.shared.chat.InviteCreation import com.getcode.util.resources.ResourceHelper import com.getcode.util.vibration.Vibrator -import com.getcode.view.SuccessHoldDuration +import com.getcode.view.PaymentSuccessHoldDuration import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel @@ -136,7 +136,7 @@ internal class InviteHostSheetViewModel @Inject constructor( // sheet takes over. An existing link opens it at once. _state.update { it.copy(success = true) } vibrator.vibrate() - delay(SuccessHoldDuration) + delay(PaymentSuccessHoldDuration) } _effects.send(Effect.ShareInvite) } 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 0a979c0df5..ae9a41c382 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 @@ -800,10 +800,13 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.CHAT) ) // Both branches of the group profile's Share button; see GroupProfileScreen. - if (viewerCanInvite) { - navigator.openInviteHost { flowNavigator.navigateTo(ChatStep.ManageInvites) } - } else { - navigator.openGroupInvite() + // The composer may have the keyboard up; it closes before the sheet opens. + keyboard.hideIfVisible { + if (viewerCanInvite) { + navigator.openInviteHost { flowNavigator.navigateTo(ChatStep.ManageInvites) } + } else { + navigator.openGroupInvite() + } } }, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModelTest.kt index d19097f4cf..75de5a4c45 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModelTest.kt @@ -11,7 +11,7 @@ import com.flipcash.shared.chat.InviteCreation import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import com.getcode.util.vibration.Vibrator -import com.getcode.view.SuccessHoldDuration +import com.getcode.view.PaymentSuccessHoldDuration import io.mockk.verify import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.test.advanceTimeBy @@ -136,7 +136,7 @@ class InviteHostSheetViewModelTest { verify(exactly = 1) { vibrator.vibrate(any()) } assertTrue("held: nothing opened yet", effects.tryReceive().isFailure) - advanceTimeBy(SuccessHoldDuration - 1.milliseconds) + advanceTimeBy(PaymentSuccessHoldDuration - 1.milliseconds) runCurrent() assertTrue("still held", effects.tryReceive().isFailure) From ffc16979b22c93d5379ab9e1a54d529ed4085d9f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 31 Oct 2026 12:25:03 -0400 Subject: [PATCH 13/13] feat(invite): fade the group-ready card out as the chat slides over it; preview empty groups as invite prompts --- .../src/androidMain/res/values/strings.xml | 1 + .../screens/components/GroupReadyCard.kt | 73 ++++++++++--------- .../screens/components/MessageList.kt | 9 +-- .../messenger/internal/GroupReadyCardTest.kt | 20 +++++ .../app/search/internal/SearchContent.kt | 1 + .../tipping/internal/components/TipChatRow.kt | 1 + .../flipcash/shared/chat/ui/ChatRowContent.kt | 8 +- .../shared/chat/ui/ChatSummaryMapping.kt | 9 +++ .../shared/chat/ui/ConversationReference.kt | 5 ++ .../shared/chat/ui/GroupAwaitsInvites.kt | 20 +++++ .../shared/chat/ui/ChatSummaryPreviewTest.kt | 44 +++++++++++ 11 files changed, 150 insertions(+), 41 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupAwaitsInvites.kt diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index f2d0059866..f391c7b7f1 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1219,6 +1219,7 @@ Request Closed This request is no longer accepting payments. Nothing yet + Invite people to get started Is Typing… Edited diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupReadyCard.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupReadyCard.kt index 0f4cd530e9..1cf240ca94 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupReadyCard.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupReadyCard.kt @@ -3,7 +3,7 @@ package com.flipcash.app.messenger.internal.screens.components import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable @@ -11,71 +11,66 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.layout +import kotlin.math.roundToInt +import com.flipcash.shared.chat.ui.groupAwaitsInvites import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch -/** - * Whether the "Your Group Is Ready" card is in the transcript. The spec iOS follows says the same: - * a group, its roster loaded, the viewer the only member, and the viewer able to invite. - * - * Able to invite is `canInvite || !isPrivate`: the server withholds `can_invite` from a public - * group's creator, yet group info's Share still opens the plain share sheet for them. - * - * [rosterResolved] is separate from [memberCount] because an unloaded group reads as a count of - * zero or a stale one, and a card that flashed up before the roster arrived would leave again. - */ +/** Whether the "Your Group Is Ready" card is in the transcript; see [groupAwaitsInvites]. */ internal fun showsGroupReadyCard( isGroup: Boolean, rosterResolved: Boolean, memberCount: Long, canInvite: Boolean, isPrivate: Boolean, -): Boolean = isGroup && rosterResolved && memberCount == 1L && (canInvite || !isPrivate) +): Boolean = groupAwaitsInvites(isGroup, rosterResolved, memberCount, canInvite, isPrivate) -/** How long the card takes to leave once someone has joined. */ -internal const val GROUP_READY_POP_MILLIS = 280 +/** How long the card takes to leave once someone has joined: the row is held this long. */ +internal const val GROUP_READY_EXIT_MILLIS = 320 -private const val POP_PEAK_MILLIS = 90 -private const val POP_PEAK_SCALE = 1.08f +/** The card fades over the first part of that; the rest of the transcript slides up as it shrinks. */ +internal const val GROUP_READY_FADE_MILLIS = 200 /** - * Keeps the card composed while it leaves. The list has no item animations, so when [show] turns - * false the card is held for [GROUP_READY_POP_MILLIS], scaling 1 -> 1.08 -> 0 and fading out, then - * [present] goes false and the list drops the item. + * Keeps the card composed while it leaves. When [show] turns false the card is held for + * [GROUP_READY_EXIT_MILLIS]: it fades out over [GROUP_READY_FADE_MILLIS] while its row collapses + * from full height to none, so whatever sits below it slides into the freed space instead of + * jumping. Then [present] goes false and the list drops the item. + * + * The collapse is the row's own height rather than a placement animation on every other row: the + * list has no item animations, and turning them on for the whole transcript would change how new + * messages arrive too. * * A card that was never shown does not animate: opening on a group that already has members just - * leaves [present] false. And only a join pops it; losing the right to invite removes it at once. + * leaves [present] false. And only a join animates it; losing the right to invite removes it at once. */ @Stable internal class GroupReadyCardPresence(initiallyPresent: Boolean) { var present by mutableStateOf(initiallyPresent) private set - val scale = Animatable(1f) + /** 1 while the card is up, falling to 0 as its row collapses. */ + val heightFraction = Animatable(1f) val alpha = Animatable(1f) suspend fun update(show: Boolean, joined: Boolean) { if (show) { - scale.snapTo(1f) + heightFraction.snapTo(1f) alpha.snapTo(1f) present = true } else if (present) { if (joined) { coroutineScope { launch { - scale.animateTo(0f, keyframes { - durationMillis = GROUP_READY_POP_MILLIS - 1f at 0 - POP_PEAK_SCALE at POP_PEAK_MILLIS using FastOutSlowInEasing - 0f at GROUP_READY_POP_MILLIS using FastOutSlowInEasing - }) + heightFraction.animateTo( + 0f, + tween(GROUP_READY_EXIT_MILLIS, easing = FastOutSlowInEasing), + ) } launch { - alpha.animateTo(0f, keyframes { - durationMillis = GROUP_READY_POP_MILLIS - 1f at 0 - 1f at POP_PEAK_MILLIS using LinearEasing - 0f at GROUP_READY_POP_MILLIS using LinearEasing - }) + alpha.animateTo(0f, tween(GROUP_READY_FADE_MILLIS, easing = LinearEasing)) } } } @@ -84,6 +79,16 @@ internal class GroupReadyCardPresence(initiallyPresent: Boolean) { } } +/** Reports [fraction] of the content's height and clips what hangs outside it, so a list sees the row shrink. */ +internal fun Modifier.collapseHeight(fraction: () -> Float): Modifier = + this + .clipToBounds() + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val height = (placeable.height * fraction().coerceIn(0f, 1f)).roundToInt() + layout(placeable.width, height) { placeable.place(0, 0) } + } + @Composable internal fun rememberGroupReadyCardPresence(show: Boolean, joined: Boolean): GroupReadyCardPresence { val presence = remember { GroupReadyCardPresence(initiallyPresent = show) } 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 49758c54cb..665cc6a4bf 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 @@ -132,7 +132,7 @@ internal fun MessageList( // row: a DM needs no avatar column, a group reserves one on every incoming row. val isGroup = state.chatType == ChatType.GROUP // Not a message, so it is not in `messages`: the transcript's own item, derived from state and - // held in place while it pops out. See GroupReadyCardPresence. + // held in place while it fades out. See GroupReadyCardPresence. val groupSubject = state.subject as? ChatSubject.Group val memberCount = groupSubject?.memberCount ?: 0L val groupReadyPresence = rememberGroupReadyCardPresence( @@ -782,12 +782,9 @@ internal fun MessageList( groupName = groupSubject?.title.orEmpty(), onInvite = onInvitePeople, modifier = Modifier + .collapseHeight { groupReadyPresence.heightFraction.value } .padding(bottom = CodeTheme.dimens.grid.x2) - .graphicsLayer { - scaleX = groupReadyPresence.scale.value - scaleY = groupReadyPresence.scale.value - alpha = groupReadyPresence.alpha.value - }, + .graphicsLayer { alpha = groupReadyPresence.alpha.value }, ) } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupReadyCardTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupReadyCardTest.kt index 4cd48e02a0..ae3c40f2bb 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupReadyCardTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupReadyCardTest.kt @@ -2,8 +2,11 @@ package com.flipcash.app.messenger.internal import com.flipcash.app.messenger.internal.screens.components.GroupReadyCardPresence import com.flipcash.app.messenger.internal.screens.components.showsGroupReadyCard +import androidx.compose.runtime.MonotonicFrameClock import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext import org.junit.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -66,4 +69,21 @@ class GroupReadyCardTest { presence.update(show = false, joined = false) assertFalse(presence.present) } + + @Test + fun aJoinFadesAndCollapsesTheRowBeforeDroppingIt() = runTest { + val presence = GroupReadyCardPresence(initiallyPresent = true) + // A frame every 16 ms of fake time; an Animatable needs a clock to run against. + var nanos = 0L + val clock = object : MonotonicFrameClock { + override suspend fun withFrameNanos(onFrame: (Long) -> R): R { + nanos += 16_000_000L + return onFrame(nanos) + } + } + withContext(clock) { presence.update(show = false, joined = true) } + assertFalse(presence.present) + assertEquals(0f, presence.alpha.value) + assertEquals(0f, presence.heightFraction.value) + } } diff --git a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchContent.kt b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchContent.kt index 32931c40b7..53db73475b 100644 --- a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchContent.kt +++ b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchContent.kt @@ -492,6 +492,7 @@ private fun LazyListScope.chatRows( isTyping = false, preview = chat.lastMessagePreview, hasMessages = chat.hasMessages, + awaitsInvites = chat.awaitsInvites, fallback = { SubtitleText("") }, ) }, diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index dd6a70f6c9..af89468733 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -96,6 +96,7 @@ internal fun TipChatRow( isTyping = chat.isTyping, preview = chat.lastMessagePreview, hasMessages = chat.hasMessages, + awaitsInvites = chat.awaitsInvites, fallback = { // Blank rather than absent: a chat with a message the row cannot preview // still occupies both lines, so it doesn't jump in height against its diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt index ed64b5d73f..9c009fe8c8 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt @@ -71,19 +71,25 @@ fun RowScope.ChatRowTrailing( * not the same thing: a chat whose newest message is media or a system event previews as nothing * but is not empty, and telling the viewer it is would be a lie. Rows that cannot tell the * difference leave [hasMessages] alone and keep their own fallback. + * + * An empty group that [awaitsInvites] says so instead of "Nothing yet", to encourage the first invites. */ @Composable fun ChatRowSubtitle( isTyping: Boolean, preview: String?, hasMessages: Boolean = true, + awaitsInvites: Boolean = false, fallback: @Composable () -> Unit = {}, ) { when { isTyping -> SubtitleText(stringResource(R.string.label_isTyping)) !preview.isNullOrEmpty() -> SubtitleText(preview) !hasMessages -> SubtitleText( - text = stringResource(R.string.label_chat_preview_noMessages), + text = stringResource( + if (awaitsInvites) R.string.label_chat_preview_inviteToStart + else R.string.label_chat_preview_noMessages, + ), // Italic marks it as the row talking about itself rather than quoting a message — // the same distinction the tombstone bubble draws. fontStyle = FontStyle.Italic, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index 637bcdb566..a674d7c5b7 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -67,6 +67,15 @@ fun ChatSummary.toConversationReference( chatType = metadata.type, lastMessagePreview = formatPreview(selfId, tokensByMint, resources, senderProfiles, exchange, paymentRequests), hasMessages = metadata.lastMessage != null, + // The feed carries the roster size, privacy and the viewer's permissions, so the row needs no + // fetch of its own. memberCount defaults to 0 for metadata never fetched, which is not 1. + awaitsInvites = groupAwaitsInvites( + isGroup = isGroup, + rosterResolved = true, + memberCount = metadata.rosterSummary.memberCount, + canInvite = metadata.viewerState?.permissions?.canInvite == true, + isPrivate = metadata.isPrivate, + ), lastActivity = metadata.lastActivity, unreadCount = unreadCount, viewerState = metadata.viewerState, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt index 5dcf7581b1..2f2af7347e 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt @@ -43,6 +43,11 @@ data class ConversationReference( * spoken in, which is the one case a row can call empty. */ val hasMessages: Boolean = true, + /** + * Whether this is a group still waiting for its first guests (see [groupAwaitsInvites]). Only + * changes what an empty row says: a chat with messages has something better to show. + */ + val awaitsInvites: Boolean = false, /** The chat's last-activity timestamp; drives recency sorting and the row's trailing timestamp. */ val lastActivity: Instant? = null, /** Messages past the viewer's READ pointer: 0 when read, null when unread by an unknown count. */ diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupAwaitsInvites.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupAwaitsInvites.kt new file mode 100644 index 0000000000..cb18d3d5b8 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupAwaitsInvites.kt @@ -0,0 +1,20 @@ +package com.flipcash.shared.chat.ui + +/** + * Whether a group is waiting for its first guests: a group, its roster loaded, the viewer the only + * member, and the viewer able to invite. It decides both the "Your Group Is Ready" card in the + * transcript and the "Invite people to get started" preview on the group's row in the chat list. + * + * Able to invite is `canInvite || !isPrivate`: the server withholds `can_invite` from a public + * group's creator, yet group info's Share still opens the plain share sheet for them. + * + * [rosterResolved] is separate from [memberCount] because an unloaded group reads as a count of + * zero or a stale one, and a prompt that flashed up before the roster arrived would leave again. + */ +fun groupAwaitsInvites( + isGroup: Boolean, + rosterResolved: Boolean, + memberCount: Long, + canInvite: Boolean, + isPrivate: Boolean, +): Boolean = isGroup && rosterResolved && memberCount == 1L && (canInvite || !isPrivate) diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt index 1c206865d8..7729a6518f 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt @@ -9,6 +9,8 @@ import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.WidgetContent import com.flipcash.services.models.paymentrequest.PaymentRequest import com.flipcash.services.models.paymentrequest.PaymentRequestStatus @@ -374,6 +376,48 @@ class ChatSummaryPreviewTest { assertNull(reference.lastMessagePreview) } + private fun awaitsInvites( + type: ChatType = ChatType.GROUP, + memberCount: Long = 1, + canInvite: Boolean = true, + isPrivate: Boolean = true, + lastMessage: ChatMessage? = null, + ) = ChatSummary( + metadata = ChatMetadata( + chatId = ChatId(byteArrayOf(9)), + type = type, + members = listOf(ChatMember(userId = self, userProfile = profile("Me"), pointers = emptyList())), + lastMessage = lastMessage, + lastActivity = sentAt, + title = "Bad Boys", + rosterSummary = RosterSummary(memberCount = memberCount, version = 1), + viewerState = ViewerState(permissions = ViewerState.Permissions(canInvite = canInvite)), + isPrivate = isPrivate, + ), + unreadCount = 0, + ).toConversationReference(selfId = self, tokensByMint = emptyMap(), resources = resources).awaitsInvites + + @Test + fun `a lone host of a private group awaits invites`() = assertTrue(awaitsInvites()) + + @Test + fun `a public group's creator awaits invites without can_invite`() = + assertTrue(awaitsInvites(canInvite = false, isPrivate = false)) + + @Test + fun `a private group the viewer cannot invite to does not await invites`() = + assertFalse(awaitsInvites(canInvite = false, isPrivate = true)) + + @Test + fun `a group with anyone else in it does not await invites`() { + assertFalse(awaitsInvites(memberCount = 2)) + // Never fetched: the default count is 0, which is not "just me". + assertFalse(awaitsInvites(memberCount = 0)) + } + + @Test + fun `a direct chat never awaits invites`() = assertFalse(awaitsInvites(type = ChatType.CONTACT_DM)) + @Test fun `a photo previews as its caption behind the camera`() { every { resources.getString(R.string.label_chat_media_photo) } returns "Photo"