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]