diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml
index 73799d7a81..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
@@ -1578,6 +1579,10 @@
Join %1$s on Flipcash and let\'s chat
Invite People
+
+ Flipcash
+ 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/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..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
@@ -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.PaymentSuccessHoldDuration
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(PaymentSuccessHoldDuration)
+ }
_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/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..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
@@ -184,6 +184,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
if (privateOutsider) flowNavigator.replaceStack(listOf(ChatStep.GroupProfile))
}
+ // 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.
LaunchedEffect(viewModel) {
@@ -790,6 +794,21 @@ 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)
+ )
+ // Both branches of the group profile's Share button; see GroupProfileScreen.
+ // 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/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..1cf240ca94
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupReadyCard.kt
@@ -0,0 +1,97 @@
+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.tween
+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 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; see [groupAwaitsInvites]. */
+internal fun showsGroupReadyCard(
+ isGroup: Boolean,
+ rosterResolved: Boolean,
+ memberCount: Long,
+ canInvite: Boolean,
+ isPrivate: Boolean,
+): Boolean = groupAwaitsInvites(isGroup, rosterResolved, memberCount, canInvite, isPrivate)
+
+/** 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
+
+/** 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. 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 animates it; losing the right to invite removes it at once.
+ */
+@Stable
+internal class GroupReadyCardPresence(initiallyPresent: Boolean) {
+ var present by mutableStateOf(initiallyPresent)
+ private set
+ /** 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) {
+ heightFraction.snapTo(1f)
+ alpha.snapTo(1f)
+ present = true
+ } else if (present) {
+ if (joined) {
+ coroutineScope {
+ launch {
+ heightFraction.animateTo(
+ 0f,
+ tween(GROUP_READY_EXIT_MILLIS, easing = FastOutSlowInEasing),
+ )
+ }
+ launch {
+ alpha.animateTo(0f, tween(GROUP_READY_FADE_MILLIS, easing = LinearEasing))
+ }
+ }
+ }
+ present = false
+ }
+ }
+}
+
+/** 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) }
+ 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..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
@@ -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,20 @@ 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 fades 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,
+ canInvite = viewerCanInvite,
+ isPrivate = groupSubject?.isPrivate == true,
+ ),
+ 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 +773,22 @@ 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
+ .collapseHeight { groupReadyPresence.heightFraction.value }
+ .padding(bottom = CodeTheme.dimens.grid.x2)
+ .graphicsLayer { 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..ae3c40f2bb
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupReadyCardTest.kt
@@ -0,0 +1,89 @@
+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
+
+/** 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,
+ canInvite: Boolean = true,
+ isPrivate: Boolean = true,
+ ) = showsGroupReadyCard(isGroup, rosterResolved, memberCount, canInvite, isPrivate)
+
+ @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 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() {
+ 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)
+ }
+
+ @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/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..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
@@ -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.PaymentSuccessHoldDuration
+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(PaymentSuccessHoldDuration - 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()) }
+ }
}
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/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))
)
}
}
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/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..e4f56ee3f5
--- /dev/null
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupReadyCardBubble.kt
@@ -0,0 +1,93 @@
+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
+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
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+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
+
+/**
+ * "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 Flipcash's logo and name as its sender, and has no time or reactions.
+ */
+@Composable
+fun GroupReadyCardBubble(
+ groupName: String,
+ onInvite: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ // 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),
+ ) {
+ // 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),
+ subtitle = stringResource(R.string.subtitle_groupReady, groupName),
+ buttonLabel = stringResource(R.string.title_invitePeople),
+ onClick = onInvite,
+ isFromSelf = false,
+ position = BubblePosition.Solo,
+ maxWidth = maxWidth,
+ messageTypography = true,
+ modifier = Modifier.align(Alignment.CenterStart),
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_GroupReadyCardBubble() {
+ GroupReadyCardBubble(groupName = "Brothers", onInvite = {}, modifier = Modifier.padding(16.dp))
+}
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..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
@@ -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,64 @@ 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,
+ /** 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)
+ val textAlignment = if (avatar != null) Alignment.CenterHorizontally else Alignment.Start
Bubble(
isFromSelf = isFromSelf,
position = position,
@@ -191,38 +247,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,
- style = CodeTheme.typography.textLarge,
+ text = title,
+ style = if (messageTypography) CodeTheme.typography.textMedium else 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",
- style = CodeTheme.typography.caption,
- color = CodeTheme.colors.textMain.copy(alpha = 0.5f),
- textAlign = TextAlign.Center,
- maxLines = 1,
+ text = subtitle,
+ 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,
)
}
@@ -231,8 +283,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 +292,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/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"
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 {
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]