Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/flipcash/core/src/androidMain/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1219,6 +1219,7 @@
<string name="error_title_requestClosed">Request Closed</string>
<string name="error_description_requestClosed">This request is no longer accepting payments.</string>
<string name="label_chat_preview_noMessages">Nothing yet</string>
<string name="label_chat_preview_inviteToStart">Invite people to get started</string>
<string name="label_isTyping">Is Typing…</string>

<string name="label_edited">Edited</string>
Expand Down Expand Up @@ -1578,6 +1579,10 @@
<string name="message_groupInvite">Join %1$s on Flipcash and let\'s chat</string>
<!-- The invite sheet (nodes 10329:12104, 10330:19549). -->
<string name="title_invitePeople">Invite People</string>
<!-- The card at the top of a group's transcript while its creator is the only member. -->
<string name="title_flipcashSender" translatable="false">Flipcash</string>
<string name="title_groupReady">Your Group Is Ready</string>
<string name="subtitle_groupReady">Invite people to join %1$s</string>
<string name="action_shareInviteLink">Share</string>
<string name="label_recentChats">Recent Chats</string>
<!-- Invite links for a private group's host (spec group-invites.md, nodes 12844:60 to 12844:395). -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Expand All @@ -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.
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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 ->
Expand All @@ -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(
Expand All @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
}
}
},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -121,13 +122,29 @@ internal fun MessageList(
onOriginsReached: (Set<Long>) -> Unit = {},
onReachedNewest: () -> Unit = {},
hazeState: HazeState? = null,
viewerCanInvite: Boolean = false,
onInvitePeople: () -> Unit = {},
) {
val keyboard = rememberKeyboardController()
val listState = rememberLazyListState()
val vibrator = LocalVibrator.current
// 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
Expand Down Expand Up @@ -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") {
Expand Down
Loading
Loading