diff --git a/apps/flipcash/core/src/androidHostTest/kotlin/com/flipcash/app/core/route/GroupCardRouteTest.kt b/apps/flipcash/core/src/androidHostTest/kotlin/com/flipcash/app/core/route/GroupCardRouteTest.kt
new file mode 100644
index 0000000000..46001fffbf
--- /dev/null
+++ b/apps/flipcash/core/src/androidHostTest/kotlin/com/flipcash/app/core/route/GroupCardRouteTest.kt
@@ -0,0 +1,38 @@
+package com.flipcash.app.core.route
+
+import com.flipcash.app.core.AppRoute
+import com.flipcash.services.models.chat.ChatId
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+
+/**
+ * The group card's route is saved with the back stack, and an invite link's fragment is the chat
+ * key's secret, so the link must reach the card some other way (spec group-invites.md surface 1).
+ */
+class GroupCardRouteTest {
+ private val route = AppRoute.Menu.GroupCard(
+ chatId = ChatId("aa"),
+ title = "Moony",
+ picture = null,
+ memberCount = 48,
+ )
+
+ @Test
+ fun `the saved route has no field a link could ride in`() {
+ val saved = Json.encodeToString(AppRoute.Menu.GroupCard.serializer(), route)
+
+ assertEquals(setOf("chatId", "title", "picture", "memberCount"), Json.parseToJsonElement(saved).jsonObject.keys)
+ }
+
+ @Test
+ fun `the saved route holds no link`() {
+ val saved = Json.encodeToString(AppRoute.serializer(), route)
+
+ assertFalse("http" in saved)
+ assertFalse("#s=" in saved)
+ assertFalse("flipcash.com" in saved)
+ }
+}
diff --git a/apps/flipcash/core/src/androidHostTest/kotlin/com/flipcash/app/core/share/groupcard/GroupCardQrTest.kt b/apps/flipcash/core/src/androidHostTest/kotlin/com/flipcash/app/core/share/groupcard/GroupCardQrTest.kt
index 408e46ecb1..e3d4679c7f 100644
--- a/apps/flipcash/core/src/androidHostTest/kotlin/com/flipcash/app/core/share/groupcard/GroupCardQrTest.kt
+++ b/apps/flipcash/core/src/androidHostTest/kotlin/com/flipcash/app/core/share/groupcard/GroupCardQrTest.kt
@@ -52,6 +52,17 @@ class GroupCardQrTest {
assertEquals(caseOneUrl, decode(bitmap))
}
+ // 32 random bytes as unpadded base64url: the longest link a card encodes (spec Open 5).
+ private val inviteUrl = "https://app.flipcash.com/chat/invite#s=kQ3vR8nYw2pLx7TzA0bC9dEfGhIjKlMnOpQrStUxT9a"
+
+ @Test
+ fun `3b - an invite link fits version 6 and the styled qr with its logo decodes to it`() {
+ assertEquals(82, inviteUrl.length)
+ val matrix = QrMatrix.encodeGroupCard(inviteUrl)
+ assertEquals(41, matrix.size)
+ assertEquals(inviteUrl, decode(StyledQrBitmap.render(matrix)))
+ }
+
@Test
fun `4 - the qr is error correction Q, version 6, 41 modules`() {
// encodeGroupCard checks level and version itself; this proves it ran and the size is 41.
diff --git a/apps/flipcash/core/src/androidMain/kotlin/com/flipcash/app/core/share/groupcard/QrMatrix.kt b/apps/flipcash/core/src/androidMain/kotlin/com/flipcash/app/core/share/groupcard/QrMatrix.kt
index 85abb9cc02..f0c860240b 100644
--- a/apps/flipcash/core/src/androidMain/kotlin/com/flipcash/app/core/share/groupcard/QrMatrix.kt
+++ b/apps/flipcash/core/src/androidMain/kotlin/com/flipcash/app/core/share/groupcard/QrMatrix.kt
@@ -1,6 +1,7 @@
package com.flipcash.app.core.share.groupcard
import com.google.zxing.EncodeHintType
+import com.google.zxing.WriterException
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.google.zxing.qrcode.encoder.Encoder
@@ -15,22 +16,22 @@ class QrMatrix(val size: Int, private val bits: BooleanArray) {
x in 0 until size && y in 0 until size && bits[y * size + x]
companion object {
- /** Version 6 is 41 modules: the smallest that holds the 66-byte invite URL at Q. */
+ /** Version 6 is 41 modules: the smallest that holds the 66-byte plain group URL at Q. */
const val GroupCardVersion = 6
const val GroupCardModules = 41
/**
- * [text] as a byte-mode, error-correction-Q, version-6 symbol. Throws when it does not fit
- * version 6 (ZXing raises a WriterException), rather than silently moving to a version that
- * puts an alignment pattern under the logo.
+ * [text] as a byte-mode, version-6 symbol: at error correction Q when it fits, otherwise M.
+ * An invite link (`/chat/invite#s=…`, 82 bytes) does not fit version 6 at Q, and every
+ * larger version puts an alignment pattern under the logo, so it drops to M instead.
+ * Throws when [text] does not fit version 6 at M either (ZXing raises a WriterException).
*/
fun encodeGroupCard(text: String): QrMatrix {
- val code = Encoder.encode(
- text,
- ErrorCorrectionLevel.Q,
- mapOf(EncodeHintType.QR_VERSION to GroupCardVersion),
- )
- check(code.ecLevel == ErrorCorrectionLevel.Q) { "Expected error correction Q, got ${code.ecLevel}" }
+ val code = try {
+ encode(text, ErrorCorrectionLevel.Q)
+ } catch (_: WriterException) {
+ encode(text, ErrorCorrectionLevel.M)
+ }
check(code.version.versionNumber == GroupCardVersion) { "Expected version 6, got ${code.version.versionNumber}" }
val m = code.matrix
check(m.width == GroupCardModules && m.height == GroupCardModules) { "Expected 41 modules, got ${m.width}" }
@@ -38,5 +39,13 @@ class QrMatrix(val size: Int, private val bits: BooleanArray) {
for (y in 0 until m.height) for (x in 0 until m.width) bits[y * m.width + x] = m[x, y].toInt() == 1
return QrMatrix(m.width, bits)
}
+
+ private fun encode(text: String, level: ErrorCorrectionLevel) = Encoder.encode(
+ text,
+ level,
+ mapOf(EncodeHintType.QR_VERSION to GroupCardVersion),
+ ).also { code ->
+ check(code.ecLevel == level) { "Expected error correction $level, got ${code.ecLevel}" }
+ }
}
}
diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml
index 47559d7ee7..649602c320 100644
--- a/apps/flipcash/core/src/androidMain/res/values/strings.xml
+++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml
@@ -1583,12 +1583,16 @@
Invite to %1$s
Share Invite Link
- Anyone with this link joins right away
- Share Join Link
- People ask to join and you approve each one
Manage Invites
- See and revoke the invite links you\'ve made
Creating link…
+
+
+ %1$s · %2$s
+ People who open this link ask to join
+ Approve New Members
+ Anyone with the link joins right away
+ You approve each person
+ Make more links with an expiry or a use limit
Share Link
Anyone with this link can join and see who\'s in the group. Share it only with people you want in the group
@@ -1848,10 +1852,14 @@
Profile Card
Couldn\'t Export
We were unable to export your tip code. Please try again
- Show Group Card
+ Show QR Code
Group Chat
Join %1$s
Group Card
+ Scan to join
+ Link expires %1$s
+ Link never expires
+ Anyone who scans this card joins without approval until the link expires or you revoke it.
We were unable to export your group code. Please try again
diff --git a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt
index f6c73b24f3..14ad8923b9 100644
--- a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt
+++ b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt
@@ -370,7 +370,7 @@ sealed interface AppRoute : NavKey, Parcelable {
/**
* A group's scan card: its invite link as a QR, full screen, with Download. Opened from the
- * Show Group Card tile of the group invite sheet, which stays open beneath it. The group's
+ * Show QR Code tile of the group invite sheet, which stays open beneath it. The group's
* identity rides on the route because the host screen already holds it and the card makes
* no request of its own.
*/
diff --git a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/chat/ChatStep.kt
index edcdcf301c..fba3f17754 100644
--- a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/chat/ChatStep.kt
+++ b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/chat/ChatStep.kt
@@ -34,6 +34,10 @@ data class GroupInviteResult(val chatId: ChatId) : Parcelable
@Serializable
enum class InviteLinkAction : Parcelable { Share, Revoke }
+/** What the host's Invite sheet (frame 1m) hands back to the screen beneath it to open. */
+@Parcelize
+enum class InviteHostAction : Parcelable { ShareInvite, ShareJoin, ManageInvites }
+
/**
* What the host picked on New Invite Link (frame 2d): the preset names, not the presets, because
* this module cannot see `shared:chat`. Presets only; never a key or link.
@@ -117,6 +121,19 @@ sealed interface ChatStep : FlowStep, Parcelable {
Sheet,
com.getcode.navigation.FullscreenSheet
+ /**
+ * Frames 1m to 1m″: a host's link card, Approve new members and Manage Invites, from the
+ * profile's share circle and Join Requests' empty state. Carries nothing: the card's link is
+ * the host's own, rebuilt from their invites, and is never on a step.
+ */
+ @Parcelize
+ @Serializable
+ data object InviteHost :
+ ChatStep,
+ NavigationRetVal,
+ Sheet,
+ WrapContentSheet
+
/**
* Frame 2b: Share Link and Revoke Link for one invite on Manage Invites. Returns the pick and
* leaves the work to Manage Invites, which is still on screen beneath it and owns the revoke
diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/GroupCardScreen.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/GroupCardScreen.kt
index 4f4ca7d3d2..6d28b01e08 100644
--- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/GroupCardScreen.kt
+++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/GroupCardScreen.kt
@@ -55,14 +55,17 @@ fun GroupCardScreen(route: AppRoute.Menu.GroupCard) {
val textSize = with(LocalDensity.current) { (width * TextFraction).toSp() }
val lineHeight = textSize * LineHeightRatio
- Box(
+ val inviteLink = viewModel.inviteLink
+ Column(
modifier = cardModifier
.align(Alignment.Center)
// The avatar's upper half stands outside the card, so the card sits lower by half
// of it and the whole group stays centred.
.offset(y = avatarSize / 2)
.width(width),
+ horizontalAlignment = Alignment.CenterHorizontally,
) {
+ Box(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -94,6 +97,24 @@ fun GroupCardScreen(route: AppRoute.Menu.GroupCard) {
.testTag("group-card-qr"),
)
}
+ // Frame 1j: an invite-link card says it joins without asking, and until when, so a
+ // saved or printed card carries its own expiry.
+ if (inviteLink != null) {
+ Text(
+ modifier = Modifier.padding(top = width * QrTopFraction),
+ text = stringResource(CoreR.string.label_groupCardScanToJoin),
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textMain,
+ )
+ Text(
+ modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1),
+ text = inviteLink.expiresAt
+ ?.let { stringResource(CoreR.string.label_groupCardLinkExpires, cardDate(it)) }
+ ?: stringResource(CoreR.string.label_groupCardLinkNeverExpires),
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ )
+ }
}
// A ring in the screen colour keeps the picture from running into the card.
@@ -115,9 +136,27 @@ fun GroupCardScreen(route: AppRoute.Menu.GroupCard) {
)
}
}
+ if (inviteLink != null) {
+ Text(
+ modifier = Modifier
+ .padding(top = CodeTheme.dimens.grid.x4)
+ .testTag("group-card-invite-footnote"),
+ text = stringResource(CoreR.string.footnote_groupCardInviteLink),
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
}
}
+/** "Nov 3", as the invite sheet writes a link's dates. */
+private fun cardDate(at: kotlin.time.Instant): String =
+ java.time.format.DateTimeFormatter.ofPattern("MMM d", java.util.Locale.getDefault())
+ .withZone(java.time.ZoneId.systemDefault())
+ .format(java.time.Instant.ofEpochMilli(at.toEpochMilliseconds()))
+
private const val CardWidthFraction = 0.75f
// The profile card's corner and name proportions (TipCard.kt).
diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/GroupCardViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/GroupCardViewModel.kt
index 73a4683ca5..cf5c86f871 100644
--- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/GroupCardViewModel.kt
+++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/GroupCardViewModel.kt
@@ -14,6 +14,7 @@ import com.flipcash.app.core.util.Linkify
import com.flipcash.app.shareable.ShareSheetController
import com.flipcash.app.shareable.Shareable
import com.flipcash.features.menu.R
+import com.flipcash.shared.chat.GroupCardLinks
import com.getcode.manager.BottomBarManager
import com.getcode.util.resources.ResourceHelper
import dagger.assisted.Assisted
@@ -23,8 +24,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
/**
- * A group's scan card: the invite link as a QR, exportable as an image. The link is the one
- * [Linkify.groupChatInvite] builds, so what the card encodes is what Share and Copy hand out.
+ * A group's scan card: a link as a QR, exportable as an image. The link is the one the Invite
+ * People sheet beneath was opened for, handed over in memory through [GroupCardLinks] because the
+ * route is saved and an invite link is a key. With nothing handed over (a card restored after
+ * process death) it is the plain link [Linkify.groupChatInvite] builds.
*/
@HiltViewModel(assistedFactory = GroupCardViewModel.Factory::class)
internal class GroupCardViewModel @AssistedInject constructor(
@@ -33,10 +36,16 @@ internal class GroupCardViewModel @AssistedInject constructor(
private val shareable: ShareSheetController,
private val resources: ResourceHelper,
private val analytics: FlipcashAnalytics,
+ links: GroupCardLinks,
) : ViewModel() {
- /** Null only for an id with no UUID form, which no group has. */
- val inviteUrl: String? = Linkify.groupChatInvite(group.chatId)
+ private val link: GroupCardLinks.Link? = links.cardLink(group.chatId, Linkify.groupChatInvite(group.chatId))
+
+ /** The invite link this card encodes, for the lines under the QR; null for the plain link. */
+ val inviteLink: GroupCardLinks.Link? = link?.takeIf { it.isInvite }
+
+ /** Null only for a plain link from an id with no UUID form, which no group has. */
+ val inviteUrl: String? = link?.url
/** Null when [inviteUrl] is, or if the link cannot be encoded. */
val matrix: QrMatrix? = inviteUrl?.let { runCatching { QrMatrix.encodeGroupCard(it) }.getOrNull() }
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt
index b72d1fd13c..0b4d26809c 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt
@@ -42,6 +42,7 @@ import com.flipcash.app.messenger.internal.ChatSubject
import com.flipcash.services.models.chat.ChatType
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.GroupInviteViewModel
+import com.flipcash.app.core.chat.InviteHostAction
import com.flipcash.app.core.chat.InviteLinkAction
import com.flipcash.app.core.chat.NewInviteLinkChoice
import com.flipcash.app.messenger.internal.invites.InviteLinkActions
@@ -49,6 +50,8 @@ import com.flipcash.app.messenger.internal.invites.NewInviteLinkContent
import com.flipcash.app.messenger.internal.invites.toChoice
import com.flipcash.app.messenger.internal.invites.InviteLinkActionsViewModel
import com.flipcash.app.messenger.internal.invites.InviteLinkViewModel
+import com.flipcash.app.messenger.internal.invites.InviteHostSheetContent
+import com.flipcash.app.messenger.internal.invites.InviteHostSheetViewModel
import com.flipcash.app.messenger.internal.invites.ManageInvitesScreen
import com.flipcash.app.messenger.internal.screens.EmojiPickerViewModel
import com.flipcash.app.messenger.internal.screens.GroupInviteSheet
@@ -194,6 +197,9 @@ private fun chatEntryProvider(
annotatedEntry {
FlowNewInviteLinkSheet()
}
+ annotatedEntry {
+ FlowInviteHostSheet()
+ }
annotatedEntry { step ->
FlowInviteLinkActionsSheet(step.inviteIdHex)
}
@@ -551,21 +557,21 @@ private fun FlowGroupInviteSheet() {
// The card is drawn over this sheet, which stays open beneath it.
onShowCard = (state.subject as? ChatSubject.Group)?.let { group ->
{
- keyboard.hideIfVisible {
- navigator.push(
- AppRoute.Menu.GroupCard(
- chatId = group.chatId,
- title = group.groupTitle,
- picture = group.picture,
- memberCount = group.memberCount,
- )
- )
- }
+ state.shareableGroupInviteUrl?.let { inviteViewModel.handOverPlainCard(group.chatId, it) }
+ keyboard.hideIfVisible { navigator.push(group.cardRoute()) }
}
},
)
}
+/** The group's scan card. Its link goes through [com.flipcash.shared.chat.GroupCardLinks], never this route. */
+private fun ChatSubject.Group.cardRoute() = AppRoute.Menu.GroupCard(
+ chatId = chatId,
+ title = groupTitle,
+ picture = picture,
+ memberCount = memberCount,
+)
+
/**
* [ChatStep.ShareInviteLink]: the invite sheet's invite variant (frames 1b, 1f). The tap on Share Invite
* Link that opened it has the link made when the host had none; sending goes through the same view model as the
@@ -608,6 +614,14 @@ private fun FlowInviteLinkSheet(inviteIdHex: String?) {
onMessageChanged = inviteViewModel::onMessageChanged,
onInvite = linkViewModel::onSend,
onDismiss = dismissSheet,
+ // The card is drawn over this sheet and encodes its invite link, handed over in memory.
+ onShowCard = (state.subject as? ChatSubject.Group)?.let { group ->
+ {
+ if (linkViewModel.handOverCard()) keyboard.hideIfVisible {
+ navigator.push(group.cardRoute())
+ }
+ }
+ },
inviteEffects = linkViewModel.effects,
onInviteEffect = { effect ->
when (effect) {
@@ -629,6 +643,45 @@ private fun FlowInviteLinkSheet(inviteIdHex: String?) {
)
}
+/**
+ * [ChatStep.InviteHost]: frame 1m on the app's sheet scene. Share Link and Manage Invites return
+ * the pick and close it, and the screen beneath opens the next sheet, as 2b does.
+ */
+@Composable
+private fun FlowInviteHostSheet() {
+ val viewModel = flowSharedViewModel()
+ val state by viewModel.stateFlow.collectAsStateWithLifecycle()
+ val hostViewModel = hiltViewModel()
+ val hostState by hostViewModel.state.collectAsStateWithLifecycle()
+ val dismissSheet = LocalBottomSheetDismissDispatcher.current
+ val resultBack = resultBackNavigator(exit = dismissSheet)
+ val group = state.subject as? ChatSubject.Group
+
+ LaunchedEffect(hostViewModel, state.chatId) {
+ state.chatId?.let(hostViewModel::open)
+ }
+ LaunchedEffect(hostViewModel) {
+ hostViewModel.effects.collect { effect ->
+ when (effect) {
+ InviteHostSheetViewModel.Effect.ShareInvite -> resultBack.returnValue(InviteHostAction.ShareInvite)
+ InviteHostSheetViewModel.Effect.ShareJoin -> resultBack.returnValue(InviteHostAction.ShareJoin)
+ InviteHostSheetViewModel.Effect.OpenManageInvites ->
+ resultBack.returnValue(InviteHostAction.ManageInvites)
+ InviteHostSheetViewModel.Effect.Close -> dismissSheet()
+ }
+ }
+ }
+
+ InviteHostSheetContent(
+ groupName = group?.groupTitle.orEmpty(),
+ state = hostState,
+ onShare = hostViewModel::onShare,
+ onApproveChanged = hostViewModel::onApproveChanged,
+ onManageInvites = hostViewModel::onManageInvites,
+ onDismiss = dismissSheet,
+ )
+}
+
/**
* [ChatStep.InviteLinkActions]: frame 2b on the app's sheet scene, like the other choice sheets.
* It returns the pick and closes; Manage Invites, still beneath it, shares or confirms the revoke.
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt
index 659f59f1cf..4a65181b46 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModel.kt
@@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
+import com.flipcash.shared.chat.GroupCardLinks
/**
* The invite sheet's "Recent Chats" (nodes 10330:19387, 10330:19549, 10329:12104): which chats,
@@ -51,8 +52,17 @@ internal class GroupInviteViewModel @Inject constructor(
private val clipboardManager: ClipboardManager,
private val tipCodePreviewCache: TipCodePreviewCache,
private val e2eePolicy: E2eePolicy,
+ private val cardLinks: GroupCardLinks,
) : ViewModel() {
+ /**
+ * Hands the plain [url] to the group card about to open, so a link left by an earlier sheet
+ * that never opened its card can't be the one it encodes.
+ */
+ fun handOverPlainCard(chatId: ChatId, url: String) {
+ cardLinks.put(chatId, GroupCardLinks.Link(url, isInvite = false))
+ }
+
data class State(
/** Null until the feed first emits, so an empty list means there are no chats to offer. */
val recentChats: List? = null,
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
new file mode 100644
index 0000000000..cd7cd95d58
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheet.kt
@@ -0,0 +1,287 @@
+package com.flipcash.app.messenger.internal.invites
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+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.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.Icon
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Menu
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+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.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.messenger.internal.screens.LinkPillFrame
+import com.flipcash.features.messenger.R
+import com.getcode.theme.CodeTheme
+import com.getcode.theme.White
+import com.getcode.theme.White08
+import com.getcode.theme.White50
+import com.getcode.ui.components.AppBarDefaults
+import com.getcode.ui.components.AppBarWithTitle
+import com.getcode.ui.theme.ButtonState
+import com.getcode.ui.theme.CodeButton
+import com.getcode.ui.theme.CodeCircularProgressIndicator
+import com.getcode.ui.theme.CodeToggleSwitch
+
+internal const val InviteHostApproveTag = "invite-host-approve"
+internal const val InviteHostShareTag = "invite-host-share"
+
+/**
+ * Frame 1m (spec surface 1): the link card, Approve New Members and Manage Invites, with no
+ * navigation or view model so previews and tests can draw it. Copy Link and Show QR Code are on
+ * the Invite People sheet that Share Link opens; revoking is in Manage Invites.
+ */
+@Composable
+internal fun InviteHostSheetContent(
+ groupName: String,
+ state: InviteHostSheetViewModel.State,
+ onShare: () -> Unit,
+ onApproveChanged: (Boolean) -> Unit,
+ onManageInvites: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) {
+ AppBarWithTitle(
+ title = stringResource(R.string.title_inviteToGroup, groupName),
+ titleAlignment = Alignment.CenterHorizontally,
+ endContent = { AppBarDefaults.Close(onClick = onDismiss) },
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(top = HeaderToCardGap, bottom = SheetBottomPadding),
+ verticalArrangement = Arrangement.spacedBy(CardGap),
+ ) {
+ LinkCard(state = state, onShare = onShare)
+ SheetRow(
+ modifier = Modifier.testTag(InviteHostApproveTag),
+ title = stringResource(R.string.label_approveNewMembers),
+ subtitle = {
+ ApproveCrossfade(approve = state.approve) { on ->
+ Text(
+ text = stringResource(
+ if (on) R.string.subtitle_approveNewMembersOn else R.string.subtitle_approveNewMembersOff
+ ),
+ style = cardNoteStyle(),
+ color = CardNoteColor,
+ )
+ }
+ },
+ onClick = { onApproveChanged(!state.approve) },
+ ) {
+ CodeToggleSwitch(checked = state.approve, onCheckedChange = onApproveChanged)
+ }
+ SheetRow(
+ title = stringResource(R.string.action_manageInvites),
+ subtitle = { SubtitleText(stringResource(R.string.subtitle_manageInvitesRow)) },
+ onClick = onManageInvites,
+ ) {
+ Icon(imageVector = Icons.Outlined.Menu, contentDescription = null, tint = CodeTheme.colors.textMain)
+ }
+ }
+ }
+}
+
+// iOS #1175's host sheet: the cards 12 apart, 20 from the header to the link card (8 under the
+// header plus the 12 between items) and 20 under the last card.
+private val CardGap = 12.dp
+private val HeaderToCardGap = 20.dp
+private val SheetBottomPadding = 20.dp
+
+// Figma 1m's pill sits darker than the card around it; no theme token is white at 0.06.
+private val LinkPillColor = Color(0x0FFFFFFF)
+
+// iOS #1175's link card, which is the reference: content 20 in and 12 apart, and a 48-high capsule
+// pill with the link 16 in from its ends. Share Link stays the standard button.
+private val LinkCardPadding = 20.dp
+private val LinkCardSpacing = 12.dp
+private val LinkPillHeight = 48.dp
+private val LinkPillInset = 16.dp
+
+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
+// subtitles Medium 13 at white 0.68. The theme's Avenir draws Medium as Demi, so these start from
+// the styles on the true Medium face.
+@Composable
+private fun pillLinkStyle() = CodeTheme.typography.textMessage.copy(fontSize = 14.sp, lineHeight = 18.sp)
+
+@Composable
+private fun cardNoteStyle() = CodeTheme.typography.caption.copy(fontSize = 13.sp, lineHeight = 18.sp)
+
+private val CardNoteColor = White.copy(alpha = 0.68f)
+
+/**
+ * Crossfades what Approve New Members changes, and animates any change in height. [content] gets
+ * the switch state it draws, so the outgoing text keeps the old one while it fades.
+ */
+@Composable
+private fun ApproveCrossfade(
+ approve: Boolean,
+ modifier: Modifier = Modifier,
+ contentAlignment: Alignment = Alignment.TopStart,
+ content: @Composable (approve: Boolean) -> Unit,
+) {
+ AnimatedContent(
+ targetState = approve,
+ modifier = modifier,
+ contentAlignment = contentAlignment,
+ transitionSpec = {
+ fadeIn(tween(ApproveFadeMillis)) togetherWith fadeOut(tween(ApproveFadeMillis)) using
+ SizeTransform(clip = false) { _, _ -> tween(ApproveFadeMillis) }
+ },
+ label = "approve-new-members",
+ ) { content(it) }
+}
+
+/** The pill, Share Link, and the caption: how many joined and when it ends, or that people ask. */
+@Composable
+private fun LinkCard(
+ state: InviteHostSheetViewModel.State,
+ onShare: () -> Unit,
+) {
+ val invite = state.invite
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(CodeTheme.shapes.medium)
+ .background(White08)
+ .padding(LinkCardPadding),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ LinkPillFrame(
+ modifier = Modifier.height(LinkPillHeight),
+ color = LinkPillColor,
+ shape = CircleShape,
+ horizontalPadding = LinkPillInset,
+ verticalPadding = 0.dp,
+ ) {
+ ApproveCrossfade(
+ approve = state.approve,
+ modifier = Modifier.weight(1f),
+ contentAlignment = Alignment.CenterStart,
+ ) { approve ->
+ when {
+ 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
+ }
+ }
+ }
+ Spacer(Modifier.height(LinkCardSpacing))
+ CodeButton(
+ modifier = Modifier.fillMaxWidth().testTag(InviteHostShareTag),
+ text = stringResource(R.string.action_shareLink),
+ buttonState = ButtonState.Filled,
+ isLoading = state.creating,
+ enabled = !state.creating,
+ onClick = onShare,
+ )
+ // The spacing above the caption goes with it, so a card with no caption ends at the button.
+ ApproveCrossfade(approve = state.approve, modifier = Modifier.fillMaxWidth()) { approve ->
+ val caption = when {
+ approve -> stringResource(R.string.label_joinLinkAsksToJoin)
+ invite is InviteLinkState.Ready -> stringResource(
+ R.string.label_inviteJoinedThenEnds,
+ JoinedCount(invite.useCount, invite.maxUses).label(),
+ (invite.expiresAt?.let(LinkStatus::Expires) ?: LinkStatus.NeverExpires).label(),
+ )
+ else -> null
+ }
+ if (caption != null) {
+ Text(
+ modifier = Modifier.fillMaxWidth().padding(top = LinkCardSpacing),
+ text = caption,
+ style = cardNoteStyle(),
+ color = CardNoteColor,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PillText(text: String) {
+ Text(
+ text = text,
+ style = pillLinkStyle(),
+ color = CodeTheme.colors.textMain,
+ maxLines = 1,
+ // No wrap: a link breaks at its slashes and hyphens, and maxLines would clip what follows.
+ // Still too wide for the pill, it loses its middle rather than its end.
+ softWrap = false,
+ overflow = TextOverflow.MiddleEllipsis,
+ )
+}
+
+/** A card row: title and subtitle, and [trailing] at the end; the whole row is the target. */
+@Composable
+private fun SheetRow(
+ title: String,
+ subtitle: @Composable () -> Unit,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ trailing: @Composable () -> Unit,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(CodeTheme.shapes.medium)
+ .background(White08)
+ .clickable(onClick = onClick)
+ .padding(CodeTheme.dimens.grid.x3),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
+ ) {
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)) {
+ Text(text = title, style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain)
+ subtitle()
+ }
+ trailing()
+ }
+}
+
+@Composable
+private fun SubtitleText(text: String) {
+ Text(text = text, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary)
+}
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
new file mode 100644
index 0000000000..436feb38e7
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModel.kt
@@ -0,0 +1,193 @@
+package com.flipcash.app.messenger.internal.invites
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.flipcash.app.analytics.FlipcashAnalytics
+import com.flipcash.app.core.util.Linkify
+import com.flipcash.app.messenger.internal.trackInviteLinkCreated
+import com.flipcash.features.messenger.R
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.shared.chat.ApproveNewMembersStore
+import com.flipcash.shared.chat.HostInvite
+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 dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Frame 1m: the host's link card, Approve New Members and Manage Invites (spec surface 1).
+ *
+ * Off, the card is the current invite link, made on the first Share Link rather than on open. On,
+ * it is the plain `/chat/{uuid}` link. The switch only picks which one the card shows; it revokes
+ * nothing.
+ *
+ * What needs navigation is an [Effect] for the sheet, which has the navigators.
+ */
+@HiltViewModel
+internal class InviteHostSheetViewModel @Inject constructor(
+ private val hostInvites: HostInvites,
+ private val approveStore: ApproveNewMembersStore,
+ private val resources: ResourceHelper,
+ private val analytics: FlipcashAnalytics,
+) : ViewModel() {
+
+ data class State(
+ /** Approve new members. */
+ val approve: Boolean = false,
+ /**
+ * The invite link the card shows while [approve] is off. [InviteLinkState.None] is a card
+ * with no link yet, which the next tap fills.
+ */
+ val invite: InviteLinkState = InviteLinkState.Loading,
+ /** The plain link the card shows while [approve] is on. */
+ val plainUrl: String? = null,
+ ) {
+ val creating: Boolean get() = !approve && invite == InviteLinkState.Creating
+ }
+
+ sealed interface Effect {
+ /** The Invite People sheet on the current invite link. */
+ data object ShareInvite : Effect
+
+ /** The Invite People sheet on the plain link, as a member gets it. */
+ data object ShareJoin : Effect
+
+ data object OpenManageInvites : Effect
+
+ data object Close : Effect
+ }
+
+ private val _state = MutableStateFlow(State())
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _effects = Channel(Channel.BUFFERED)
+ val effects: Flow = _effects.receiveAsFlow()
+
+ private var chatId: ChatId? = null
+ private var pending: Job? = null
+
+ fun open(chatId: ChatId) {
+ if (this.chatId == chatId) return
+ this.chatId = chatId
+ _state.update {
+ State(
+ approve = it.approve,
+ invite = hostInvites.current(chatId)?.toState() ?: InviteLinkState.Loading,
+ plainUrl = Linkify.groupChatInvite(chatId),
+ )
+ }
+ viewModelScope.launch {
+ approveStore.observe(chatId).collect { on -> _state.update { it.copy(approve = on) } }
+ }
+ viewModelScope.launch { refresh(chatId) }
+ }
+
+ /** Shows the current link, or an empty card when there is none. Nothing is made on open. */
+ private suspend fun refresh(chatId: ChatId) {
+ val loaded = hostInvites.load(chatId)
+ if (loaded.exceptionOrNull()?.isLostInviteAccess() == true) {
+ hostInvites.revokeAccess(chatId)
+ _effects.send(Effect.Close)
+ return
+ }
+ // A pending tap owns the card until it lands.
+ if (pending?.isActive == true) return
+ _state.update { it.copy(invite = hostInvites.current(chatId)?.toState() ?: InviteLinkState.None) }
+ }
+
+ fun onApproveChanged(on: Boolean) {
+ val chatId = chatId ?: return
+ _state.update { it.copy(approve = on) }
+ viewModelScope.launch { approveStore.set(chatId, on) }
+ }
+
+ fun onManageInvites() {
+ _effects.trySend(Effect.OpenManageInvites)
+ }
+
+ fun onShare() {
+ val chatId = chatId ?: return
+ if (pending?.isActive == true) return
+ if (_state.value.approve) {
+ _effects.trySend(Effect.ShareJoin)
+ return
+ }
+ pending = viewModelScope.launch {
+ ensureInvite(chatId) ?: return@launch
+ _effects.send(Effect.ShareInvite)
+ }
+ }
+
+ /**
+ * 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 }
+ _state.update { it.copy(invite = InviteLinkState.Creating) }
+ // 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 ->
+ _state.update { it.copy(invite = InviteLinkState.None) }
+ if (error.isLostInviteAccess()) {
+ hostInvites.revokeAccess(chatId)
+ _effects.send(Effect.Close)
+ } else {
+ showInviteLinksNotLoaded(resources)
+ }
+ return null
+ }
+ hostInvites.current(chatId)?.let { return settle(it) }
+ return when (val result = hostInvites.currentOrCreate(chatId).also { analytics.trackInviteLinkCreated(it) }) {
+ is InviteCreation.Created -> settle(result.invite) ?: run { failed(); null }
+ InviteCreation.TooMany -> {
+ _state.update { it.copy(invite = InviteLinkState.None) }
+ showTooManyInviteLinks(
+ resources,
+ onManageInvites = { _effects.trySend(Effect.OpenManageInvites) },
+ )
+ null
+ }
+ InviteCreation.NotAvailable -> {
+ _state.update { it.copy(invite = InviteLinkState.None) }
+ // 1e: its button shares the plain link this once. Approve New Members stays as it
+ // was; only the host's tap on the switch turns it on.
+ showInviteLinksNotAvailable(
+ resources,
+ onShareJoinLink = { _effects.trySend(Effect.ShareJoin) },
+ )
+ null
+ }
+ is InviteCreation.Failed -> {
+ failed()
+ null
+ }
+ }
+ }
+
+ private fun settle(invite: HostInvite): String? {
+ val link = invite.toState()
+ _state.update { it.copy(invite = link) }
+ return (link as? InviteLinkState.Ready)?.url
+ }
+
+ private fun failed() {
+ _state.update { it.copy(invite = InviteLinkState.None) }
+ showInviteLinkNotCreated(resources)
+ }
+
+ private fun HostInvite.toState(): InviteLinkState =
+ url?.let { InviteLinkState.Ready(it, invite.expiresAt, invite.useCount, invite.maxUses) }
+ ?: InviteLinkState.None
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostViewModel.kt
index 4909d1fee1..5f5913fbf2 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostViewModel.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostViewModel.kt
@@ -4,23 +4,20 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flipcash.services.models.chat.ChatId
import com.flipcash.shared.chat.HostInvites
-import com.getcode.util.resources.ResourceHelper
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
- * The host's way into invite links, for the screens that hand the group out: whether the viewer
- * can invite (`can_invite`), and the three-row sheet that opens from the profile's share circle
- * and Join Requests' empty state (frame 1a).
+ * Whether the viewer can invite (`can_invite`), for the screens that hand the group out: a host
+ * gets the link card sheet ([com.flipcash.app.core.chat.ChatStep.InviteHost]), a member the plain link.
*
* Kept out of the conversation's view model: nothing here is about the transcript.
*/
@HiltViewModel
internal class InviteHostViewModel @Inject constructor(
private val hostInvites: HostInvites,
- private val resources: ResourceHelper,
) : ViewModel() {
/** Each checked chat's `can_invite`; a chat not checked yet reads as false. */
@@ -30,12 +27,4 @@ internal class InviteHostViewModel @Inject constructor(
fun refresh(chatId: ChatId) {
viewModelScope.launch { hostInvites.refreshAccess(chatId) }
}
-
- /** Frame 1a: see [showInviteHostSheet]. */
- fun presentHostSheet(
- groupName: String,
- onShareInviteLink: () -> Unit,
- onShareJoinLink: () -> Unit,
- onManageInvites: () -> Unit,
- ) = showInviteHostSheet(resources, groupName, onShareInviteLink, onShareJoinLink, onManageInvites)
}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt
index 532cb4e030..3eabcd8bfc 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt
@@ -82,6 +82,9 @@ internal fun HostInvite.detailLine(): String {
return "$joined · $second"
}
+/** A group's plain link as the pill shows it: scheme dropped, nothing cut, as it holds no secret. */
+internal fun plainLinkText(url: String): String = url.removePrefix("https://").removePrefix("http://")
+
/** The link as the pill shows it: scheme dropped and the middle of the secret elided (node 12933:113). */
internal fun elideInviteUrl(url: String, head: Int = 32, tail: Int = 4): String {
val bare = url.removePrefix("https://").removePrefix("http://")
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt
index 6e3014cbc6..b56526e378 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt
@@ -8,6 +8,7 @@ import com.flipcash.app.core.extensions.setSensitiveText
import com.flipcash.app.messenger.internal.trackInviteLinkCreated
import com.flipcash.features.messenger.R
import com.flipcash.services.models.chat.ChatId
+import com.flipcash.shared.chat.GroupCardLinks
import com.flipcash.shared.chat.HostInvite
import com.flipcash.shared.chat.HostInvites
import com.flipcash.shared.chat.HostInvites.Companion.isLostInviteAccess
@@ -66,6 +67,7 @@ internal class InviteLinkViewModel @Inject constructor(
private val resources: ResourceHelper,
private val clipboardManager: ClipboardManager,
private val analytics: FlipcashAnalytics,
+ private val cardLinks: GroupCardLinks,
) : ViewModel() {
enum class Action { Share, Copy, Send }
@@ -106,6 +108,17 @@ internal class InviteLinkViewModel @Inject constructor(
private var specific: String? = null
private var job: Job? = null
+ /**
+ * Hands the link showing to the group card about to open, in memory (the card's route is
+ * saved). False while there is no link yet, so the sheet opens no card.
+ */
+ fun handOverCard(): Boolean {
+ val chatId = chatId ?: return false
+ val link = _state.value.link as? InviteLinkState.Ready ?: return false
+ cardLinks.put(chatId, GroupCardLinks.Link(link.url, isInvite = true, expiresAt = link.expiresAt))
+ return true
+ }
+
/**
* Starts on [chatId]'s current link, or on the invite [inviteIdHex] when Manage Invites picked
* one. Opening on what is already showing does nothing; a different invite reloads.
@@ -183,6 +196,7 @@ internal class InviteLinkViewModel @Inject constructor(
_state.update { it.copy(link = InviteLinkState.None) }
showInviteLinksNotAvailable(
resources,
+ // 1e: share the plain link this once, leaving Approve New Members off.
onShareJoinLink = { _effects.trySend(Effect.OpenJoinLink) },
onCancel = { _effects.trySend(Effect.Close) },
)
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteMessages.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteMessages.kt
index d50a5262ba..bbe5cb2d46 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteMessages.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteMessages.kt
@@ -1,6 +1,5 @@
package com.flipcash.app.messenger.internal.invites
-import com.flipcash.app.funding.bottomBarCardAction
import com.flipcash.features.messenger.R
import com.getcode.manager.BottomBarAction
import com.getcode.manager.BottomBarManager
@@ -11,45 +10,6 @@ import com.getcode.util.resources.ResourceHelper
* previews draw the same ones.
*/
-/**
- * Frame 1a, drawn by the bottom bar's card sheet that Group Money and Add Money use, so it keeps
- * their spacing, colours and dismissal.
- */
-internal fun showInviteHostSheet(
- resources: ResourceHelper,
- groupName: String,
- onShareInviteLink: () -> Unit,
- onShareJoinLink: () -> Unit,
- onManageInvites: () -> Unit,
-) {
- BottomBarManager.showMessage(
- title = resources.getString(R.string.title_inviteToGroup, groupName),
- actions = listOf(
- bottomBarCardAction(
- title = resources.getString(R.string.action_shareInviteLinkLong),
- subtitle = resources.getString(R.string.subtitle_shareInviteLink),
- iconRes = R.drawable.ic_send_outlined,
- testTag = "invite_action_share_invite",
- onClick = onShareInviteLink,
- ),
- bottomBarCardAction(
- title = resources.getString(R.string.action_shareJoinLink),
- subtitle = resources.getString(R.string.subtitle_shareJoinLink),
- iconRes = R.drawable.ic_link_outlined,
- testTag = "invite_action_share_join",
- onClick = onShareJoinLink,
- ),
- bottomBarCardAction(
- title = resources.getString(R.string.action_manageInvites),
- subtitle = resources.getString(R.string.subtitle_manageInvites),
- iconRes = R.drawable.ic_checklist,
- testTag = "invite_action_manage",
- onClick = onManageInvites,
- ),
- ),
- )
-}
-
/**
* Frame 1c: the group holds the most active links the server allows. With [onManageInvites] the
* buttons are Manage Invites and Cancel (spec surface 1, step 4); without it, OK only, for use
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InvitePreviews.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InvitePreviews.kt
index 745454f8ff..eeda989527 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InvitePreviews.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InvitePreviews.kt
@@ -118,17 +118,45 @@ private fun InviteSheetFrame(subject: ShareToChatsSubject, selection: List Unit) {
+ navigateForResult(ChatStep.InviteHost) { result ->
+ if (result !is NavResultOrCanceled.ReturnValue) return@navigateForResult
+ when (result.value) {
+ InviteHostAction.ShareInvite -> openInviteLinkShare()
+ InviteHostAction.ShareJoin -> openGroupInvite()
+ InviteHostAction.ManageInvites -> onManageInvites()
+ }
+ }
+}
+
/** Opens the sheet on a message's link; the chat picked first opens once the sends are out. */
internal fun CodeNavigator.openMessageShare(messageId: Long) {
navigateForResult(ChatStep.ShareMessage(messageId)) { result ->
@@ -148,23 +167,24 @@ internal fun CodeNavigator.openMessageShare(messageId: Long) {
}
}
-/** Test id of the Show Group Card tile. */
+/** Test id of the Show QR Code tile. */
internal const val GroupCardTileTag = "group-card-tile"
-/** The group tile carries [GroupCardTileTag]; the profile tile is unchanged and has none. */
-internal fun cardTileTag(subject: ShareToChatsSubject): String? =
- if (subject is ShareToChatsSubject.Group) GroupCardTileTag else null
+/** A group's tile, for either link, carries [GroupCardTileTag]; the profile tile has none. */
+internal fun cardTileTag(subject: ShareToChatsSubject): String? = when (subject) {
+ is ShareToChatsSubject.Group, is ShareToChatsSubject.GroupInviteLink -> GroupCardTileTag
+ else -> null
+}
/**
* Whether the card tile is drawn: whenever the host supplies a card to show, so a group's tile is
- * there for every group, public or private, member or not, as Share and Copy are. Only a message
+ * there for every group and every link, plain or invite, as Share and Copy are. Only a message
* link has no card.
*/
internal fun showsCardTile(subject: ShareToChatsSubject, onShowCard: (() -> Unit)?): Boolean =
onShowCard != null &&
subject !is ShareToChatsSubject.Message &&
- subject !is ShareToChatsSubject.Currency &&
- subject !is ShareToChatsSubject.GroupInviteLink
+ subject !is ShareToChatsSubject.Currency
/**
* What [GroupInviteSheet] hands out: a group's invite link, a person's profile link, or a link to
@@ -327,15 +347,16 @@ internal fun GroupInviteSheet(
),
) {
if (inviteUrl != null || inviteLink != null) {
+ // Both of a group's links lead with the link itself; only the invite link has an
+ // expiry and a joined count to put under it.
+ val pillModifier = Modifier
+ .padding(horizontal = ListInset)
+ .padding(top = TileRowTop)
+ val showsPill = inviteLink != null || subject is ShareToChatsSubject.Group
if (inviteLink != null) {
- item(key = "pill") {
- InviteLinkPill(
- link = inviteLink.link,
- modifier = Modifier
- .padding(horizontal = ListInset)
- .padding(top = TileRowTop),
- )
- }
+ item(key = "pill") { InviteLinkPill(link = inviteLink.link, modifier = pillModifier) }
+ } else if (subject is ShareToChatsSubject.Group && inviteUrl != null) {
+ item(key = "pill") { PlainLinkPill(url = inviteUrl, modifier = pillModifier) }
}
item(key = "links") {
// Intrinsic height, so a tile whose label wraps stretches the others to
@@ -346,7 +367,7 @@ internal fun GroupInviteSheet(
.height(IntrinsicSize.Min)
.padding(horizontal = ListInset)
.padding(
- top = if (inviteLink != null) TileRowBottom else TileRowTop,
+ top = if (showsPill) TileRowBottom else TileRowTop,
bottom = TileRowBottom,
),
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
@@ -406,10 +427,12 @@ internal fun GroupInviteSheet(
if (showsCardTile(subject, onShowCard)) {
InviteLinkTile(
modifier = Modifier.weight(1f).fillMaxHeight(),
+ // The card encodes the link, so it waits for it as Share and Copy do.
+ enabled = !creating,
// The Scan tab's outline glyph, as the iOS tile draws it.
icon = R.drawable.ic_nav_tipcard,
label = stringResource(
- if (subject is ShareToChatsSubject.Group) {
+ if (group != null) {
R.string.action_showGroupCard
} else {
R.string.action_showProfileCard
@@ -579,15 +602,7 @@ private fun InviteLinkTile(
@Composable
internal fun InviteLinkPill(link: InviteLinkState, modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clip(CodeTheme.shapes.extraSmall)
- .background(White05)
- .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x3),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
- ) {
+ LinkPillFrame {
when (link) {
InviteLinkState.Creating -> {
CodeCircularProgressIndicator(modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6 / 2))
@@ -597,13 +612,7 @@ internal fun InviteLinkPill(link: InviteLinkState, modifier: Modifier = Modifier
color = White50,
)
}
- is InviteLinkState.Ready -> Text(
- text = elideInviteUrl(link.url),
- style = CodeTheme.typography.textMedium,
- color = CodeTheme.colors.textMain,
- maxLines = 1,
- overflow = TextOverflow.Clip,
- )
+ is InviteLinkState.Ready -> LinkPillText(elideInviteUrl(link.url))
// A picked invite being fetched, or nothing to show as the sheet closes.
InviteLinkState.None, InviteLinkState.Loading ->
CodeCircularProgressIndicator(modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6 / 2))
@@ -619,6 +628,49 @@ internal fun InviteLinkPill(link: InviteLinkState, modifier: Modifier = Modifier
}
}
+/** A group's plain `/chat` link in the invite link's pill, with no line under it: it has no expiry or count. */
+@Composable
+internal fun PlainLinkPill(url: String, modifier: Modifier = Modifier) {
+ LinkPillFrame(modifier = modifier.fillMaxWidth()) {
+ LinkPillText(plainLinkText(url))
+ }
+}
+
+@Composable
+internal fun LinkPillFrame(
+ modifier: Modifier = Modifier,
+ color: Color = White05,
+ shape: Shape = CodeTheme.shapes.extraSmall,
+ horizontalPadding: Dp = CodeTheme.dimens.grid.x3,
+ verticalPadding: Dp = CodeTheme.dimens.grid.x3,
+ content: @Composable RowScope.() -> Unit,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(shape)
+ .background(color)
+ .padding(horizontal = horizontalPadding, vertical = verticalPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ content = content,
+ )
+}
+
+@Composable
+private fun LinkPillText(text: String) {
+ Text(
+ text = text,
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textMain,
+ maxLines = 1,
+ // No wrap: a link breaks at its slashes and hyphens, and maxLines would clip what follows.
+ // Still too wide for the pill, it loses its middle rather than its end.
+ softWrap = false,
+ overflow = TextOverflow.MiddleEllipsis,
+ )
+}
+
/** Node 10330:19568 — the list's label, an ordinary row that scrolls with the chats. */
@Composable
private fun RecentChatsHeader() {
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt
index f9cf303a20..a59151e8eb 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt
@@ -82,7 +82,7 @@ import com.flipcash.app.messenger.internal.rememberMutedLabel
import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar
import com.flipcash.app.messenger.internal.invites.InviteHostViewModel
import com.flipcash.app.messenger.internal.screens.openGroupInvite
-import com.flipcash.app.messenger.internal.screens.openInviteLinkShare
+import com.flipcash.app.messenger.internal.screens.openInviteHost
import com.flipcash.features.messenger.R
import com.flipcash.services.models.chat.BlobAccessContext
import com.flipcash.services.models.chat.ChatRuleRequirement
@@ -260,15 +260,10 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) {
viewModel.dispatchEvent(
ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.PROFILE)
)
- // A host sees the three-way sheet (frame 1a); a member without
+ // A host sees the link card sheet (frame 1m); a member without
// can_invite keeps the single join link.
if (canInvite) {
- inviteHost.presentHostSheet(
- groupName = group?.groupTitle.orEmpty(),
- onShareInviteLink = { navigator.openInviteLinkShare() },
- onShareJoinLink = { navigator.openGroupInvite() },
- onManageInvites = { flowNavigator.navigateTo(ChatStep.ManageInvites) },
- )
+ 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/profile/JoinRequestsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt
index a0b409f445..73102e67b4 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt
@@ -37,7 +37,7 @@ import com.flipcash.app.messenger.internal.ChatSubject
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.invites.InviteHostViewModel
import com.flipcash.app.messenger.internal.screens.openGroupInvite
-import com.flipcash.app.messenger.internal.screens.openInviteLinkShare
+import com.flipcash.app.messenger.internal.screens.openInviteHost
import com.flipcash.features.messenger.R
import com.flipcash.services.models.chat.LobbyMember
import com.flipcash.shared.chat.RequestAge
@@ -102,12 +102,7 @@ internal fun JoinRequestsScreen(viewModel: ChatViewModel) {
onShare = {
viewModel.dispatchEvent(ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.PROFILE))
if (canInvite) {
- inviteHost.presentHostSheet(
- groupName = group?.groupTitle.orEmpty(),
- onShareInviteLink = { navigator.openInviteLinkShare() },
- onShareJoinLink = { navigator.openGroupInvite() },
- onManageInvites = { flowNavigator.navigateTo(ChatStep.ManageInvites) },
- )
+ navigator.openInviteHost { flowNavigator.navigateTo(ChatStep.ManageInvites) }
} else {
navigator.openGroupInvite()
}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt
index 5912a8d5ce..80e4b4ed11 100644
--- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupInviteViewModelTest.kt
@@ -73,6 +73,7 @@ class GroupInviteViewModelTest {
clipboardManager = mockk(relaxed = true),
tipCodePreviewCache = mockk(relaxed = true),
e2eePolicy = com.flipcash.services.chat.E2eePolicy(),
+ cardLinks = com.flipcash.shared.chat.GroupCardLinks(),
)
@Test
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteFramesScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteFramesScreenshotTest.kt
index a0325448f5..28d7c212af 100644
--- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteFramesScreenshotTest.kt
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteFramesScreenshotTest.kt
@@ -50,7 +50,8 @@ class InviteFramesScreenshotTest {
println("SCREENSHOT_WRITTEN: ${file.absolutePath}")
}
- @Test fun frame1a() = render("1a") { InviteFrame1aHostSheet() }
+ @Test fun frame1m() = render("1m") { InviteFrame1mHostSheet() }
+ @Test fun frame1mPrime() = render("1m-prime") { InviteFrame1mPrimeHostSheet() }
@Test fun frame1b() = render("1b") { InviteFrame1bCreating() }
@Test fun frame1c() = render("1c") { InviteFrame1cTooMany() }
@Test fun frame1d() = render("1d") { InviteFrame1dJoinRequestsEmpty() }
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetCrossfadeTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetCrossfadeTest.kt
new file mode 100644
index 0000000000..9fec4c280c
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetCrossfadeTest.kt
@@ -0,0 +1,66 @@
+package com.flipcash.app.messenger.internal.invites
+
+import androidx.activity.ComponentActivity
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import com.flipcash.app.theme.FlipcashPreview
+import com.flipcash.features.messenger.R
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Approve New Members swaps the link card's pill and caption and the row's subtitle through a
+ * crossfade; once it settles, only the new text is drawn.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [32])
+class InviteHostSheetCrossfadeTest {
+
+ @get:Rule
+ val composeRule = createAndroidComposeRule()
+
+ private fun string(id: Int) = composeRule.activity.getString(id)
+
+ @Test
+ fun turningApproveOnSettlesOnTheNewText() {
+ val approve = mutableStateOf(false)
+ composeRule.setContent {
+ FlipcashPreview {
+ InviteHostSheetContent(
+ groupName = "Bad Boys",
+ state = InviteHostSheetViewModel.State(
+ approve = approve.value,
+ invite = InviteLinkState.Ready(
+ url = "https://flipcash.com/i/7Kq2xYbPz9#s=preview",
+ expiresAt = null,
+ useCount = 0,
+ maxUses = 0,
+ ),
+ plainUrl = "https://flipcash.com/chat/6f1c0b2e-3a4d-4c5e-8f90-123456789abc",
+ ),
+ onShare = {},
+ onApproveChanged = {},
+ onManageInvites = {},
+ onDismiss = {},
+ )
+ }
+ }
+ val off = string(R.string.subtitle_approveNewMembersOff)
+ val on = string(R.string.subtitle_approveNewMembersOn)
+ val asks = string(R.string.label_joinLinkAsksToJoin)
+
+ composeRule.onNodeWithText(off).assertExists()
+ composeRule.onNodeWithText(asks).assertDoesNotExist()
+
+ approve.value = true
+ composeRule.waitForIdle()
+
+ composeRule.onNodeWithText(off).assertDoesNotExist()
+ composeRule.onNodeWithText(on).assertExists()
+ composeRule.onNodeWithText(asks).assertExists()
+ }
+}
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
new file mode 100644
index 0000000000..a36b96e92a
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModelTest.kt
@@ -0,0 +1,96 @@
+package com.flipcash.app.messenger.internal.invites
+
+import com.flipcash.app.analytics.RecordingAnalytics
+import com.flipcash.services.models.chat.ChatId
+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 io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/** The host sheet's Approve New Members switch: only the host's tap turns it on. */
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class InviteHostSheetViewModelTest {
+
+ private val chatId = ChatId(ByteArray(32) { 5 })
+ private val hostInvites = mockk(relaxed = true)
+ private val resources = mockk(relaxed = true) {
+ every { getString(any()) } returns "text"
+ }
+
+ /** Every value written, in order, behind a switch that starts off. */
+ private val writes = mutableListOf()
+ private val stored = MutableStateFlow(false)
+ private val approveStore = object : ApproveNewMembersStore {
+ override fun observe(chatId: ChatId): Flow = stored
+ override suspend fun set(chatId: ChatId, on: Boolean) {
+ writes += on
+ stored.value = on
+ }
+ }
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ BottomBarManager.clear()
+ every { hostInvites.state } returns MutableStateFlow