From 7a5134b88a646e6a9ccd657ddb09e4c50aac7251 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 15:03:22 -0400 Subject: [PATCH 01/12] feat(groups): Show Group Card on every invite link, and the host sheet's Approve new members switch Show Group Card now sits next to Share and Copy for both the plain link and an invite link, and the invite sheet's pill shows the plain link the same way. The card encodes the link it was opened from; the link reaches it in memory (GroupCardLinks), never on the saved AppRoute.Menu.GroupCard route, since an invite link's fragment is the chat key's secret. The host's Invite sheet (spec frame 1m) replaces the bottom-bar message: one link card, an Approve new members switch kept per group and account, and Manage Invites. Spec: code-payments/flipcash-client-orchestrator#82, docs/specs/group-invites.md surface 1. --- .../app/core/route/GroupCardRouteTest.kt | 38 +++ .../core/share/groupcard/GroupCardQrTest.kt | 11 + .../app/core/share/groupcard/QrMatrix.kt | 29 +- .../src/androidMain/res/values/strings.xml | 18 +- .../com/flipcash/app/core/chat/ChatStep.kt | 12 + .../com/flipcash/app/menu/GroupCardScreen.kt | 41 ++- .../app/menu/internal/GroupCardViewModel.kt | 17 +- .../flipcash/app/messenger/ChatFlowScreen.kt | 89 +++++- .../internal/GroupInviteViewModel.kt | 10 + .../internal/invites/InviteHostSheet.kt | 284 ++++++++++++++++++ .../invites/InviteHostSheetViewModel.kt | 280 +++++++++++++++++ .../internal/invites/InviteHostViewModel.kt | 15 +- .../internal/invites/InviteLabels.kt | 3 + .../internal/invites/InviteLinkViewModel.kt | 22 +- .../internal/invites/InviteMessages.kt | 40 --- .../internal/invites/InvitePreviews.kt | 41 ++- .../internal/screens/GroupInviteSheet.kt | 92 ++++-- .../screens/profile/GroupProfileScreen.kt | 10 +- .../screens/profile/JoinRequestsScreen.kt | 8 +- .../internal/GroupInviteViewModelTest.kt | 1 + .../invites/InviteFramesScreenshotTest.kt | 3 +- .../invites/InviteLinkViewModelTest.kt | 27 +- .../internal/screens/GroupCardTileTest.kt | 12 +- .../shared/chat/ApproveNewMembersStore.kt | 16 + .../flipcash/shared/chat/GroupCardLinks.kt | 43 +++ .../flipcash/shared/chat/inject/ChatModule.kt | 7 + .../DataStoreApproveNewMembersStore.kt | 63 ++++ .../shared/chat/GroupCardLinksTest.kt | 54 ++++ .../DataStoreApproveNewMembersStoreTest.kt | 76 +++++ 29 files changed, 1223 insertions(+), 139 deletions(-) create mode 100644 apps/flipcash/core/src/androidHostTest/kotlin/com/flipcash/app/core/route/GroupCardRouteTest.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheet.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModel.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ApproveNewMembersStore.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupCardLinks.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStore.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupCardLinksTest.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStoreTest.kt 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..b83b74d9ac 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1583,12 +1583,18 @@ 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 + Off: anyone with the link joins right away + On: you approve each person in Join Requests + Make more links with an expiry or a use limit + Revoke and Make New Link + More link options 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 @@ -1852,6 +1858,10 @@ 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/chat/ChatStep.kt b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/chat/ChatStep.kt index edcdcf301c..90e0484ec5 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 @@ -117,6 +117,18 @@ 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, + 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..74afa40624 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 @@ -49,6 +49,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 @@ -58,6 +60,7 @@ import com.flipcash.app.messenger.internal.screens.ReactorsSheet import com.flipcash.app.messenger.internal.screens.ReactorsViewModel import com.flipcash.app.messenger.internal.screens.ShareToChatsSubject import com.flipcash.app.messenger.internal.screens.openGroupInvite +import com.flipcash.app.messenger.internal.screens.openInviteLinkShare import com.flipcash.app.messenger.internal.request.ChatRequestsViewModel import com.flipcash.app.messenger.internal.request.RequestDetailsScreen import com.flipcash.app.messenger.internal.request.RequestMoneyScreen @@ -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,61 @@ private fun FlowInviteLinkSheet(inviteIdHex: String?) { ) } +/** + * [ChatStep.InviteHost]: frames 1m to 1m″ on the app's sheet scene. Share Link closes it for the + * Invite People sheet on the card's link; Show Group Card draws the card over it, as the Invite + * People sheet 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 navigator = LocalCodeNavigator.current + val flowNavigator = rememberFlowNavigator() + 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 -> { + dismissSheet() + navigator.openInviteLinkShare() + } + InviteHostSheetViewModel.Effect.ShareJoin -> { + dismissSheet() + navigator.openGroupInvite() + } + InviteHostSheetViewModel.Effect.ShowCard -> group?.let { navigator.push(it.cardRoute()) } + InviteHostSheetViewModel.Effect.OpenManageInvites -> { + dismissSheet() + flowNavigator.navigateTo(ChatStep.ManageInvites) + } + InviteHostSheetViewModel.Effect.CopyJoin -> + viewModel.dispatchEvent(ChatViewModel.Event.CopyInviteLink) + InviteHostSheetViewModel.Effect.Close -> dismissSheet() + } + } + } + + InviteHostSheetContent( + groupName = group?.groupTitle.orEmpty(), + state = hostState, + onShare = hostViewModel::onShare, + onCopy = hostViewModel::onCopy, + onShowCard = hostViewModel::onShowCard, + onRevoke = hostViewModel::onRevoke, + 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..9cc1f3866d --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheet.kt @@ -0,0 +1,284 @@ +package com.flipcash.app.messenger.internal.invites + +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.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.material.icons.outlined.MoreHoriz +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 com.flipcash.app.messenger.internal.screens.LinkPillFrame +import com.flipcash.features.messenger.R +import com.getcode.theme.CodeTheme +import com.getcode.theme.extraLarge +import com.getcode.theme.White05 +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 InviteHostMoreTag = "invite-host-more" +internal const val InviteHostApproveTag = "invite-host-approve" +internal const val InviteHostShareTag = "invite-host-share" + +/** + * Frames 1m to 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. + */ +@Composable +internal fun InviteHostSheetContent( + groupName: String, + state: InviteHostSheetViewModel.State, + onShare: () -> Unit, + onCopy: () -> Unit, + onShowCard: () -> Unit, + onRevoke: () -> Unit, + onApproveChanged: (Boolean) -> Unit, + onManageInvites: () -> Unit, + onDismiss: () -> Unit, + initiallyExpanded: Boolean = false, +) { + 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(bottom = CodeTheme.dimens.grid.x4), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + LinkCard( + state = state, + onShare = onShare, + onCopy = onCopy, + onShowCard = onShowCard, + onRevoke = onRevoke, + initiallyExpanded = initiallyExpanded, + ) + SheetRow( + modifier = Modifier.testTag(InviteHostApproveTag), + title = stringResource(R.string.label_approveNewMembers), + subtitle = stringResource( + if (state.approve) R.string.subtitle_approveNewMembersOn else R.string.subtitle_approveNewMembersOff + ), + onClick = { onApproveChanged(!state.approve) }, + ) { + CodeToggleSwitch(checked = state.approve, onCheckedChange = onApproveChanged) + } + SheetRow( + title = stringResource(R.string.action_manageInvites), + subtitle = stringResource(R.string.subtitle_manageInvitesRow), + onClick = onManageInvites, + ) { + Icon(imageVector = Icons.Outlined.Menu, contentDescription = null, tint = CodeTheme.colors.textMain) + } + } + } +} + +/** The pill with ⋯, 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, + onCopy: () -> Unit, + onShowCard: () -> Unit, + onRevoke: () -> Unit, + initiallyExpanded: Boolean, +) { + val invite = state.invite + Column( + modifier = Modifier + .fillMaxWidth() + .clip(CodeTheme.shapes.medium) + .background(White05) + .padding(CodeTheme.dimens.grid.x3), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + LinkPillFrame { + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + when { + state.approve -> state.plainUrl?.let { PillText(plainLinkText(it)) } + invite is InviteLinkState.Ready -> PillText(elideInviteUrl(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 + } + } + MoreMenu( + canRevoke = state.canRevoke, + enabled = !state.creating, + onCopy = onCopy, + onShowCard = onShowCard, + onRevoke = onRevoke, + initiallyExpanded = initiallyExpanded, + ) + } + CodeButton( + modifier = Modifier.fillMaxWidth().testTag(InviteHostShareTag), + text = stringResource(R.string.action_shareLink), + buttonState = ButtonState.Filled, + isLoading = state.creating, + enabled = !state.creating, + onClick = onShare, + ) + val caption = when { + state.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(), + text = caption, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } +} + +/** Frame 1m″: the platform's own menu off the pill's ⋯. */ +@Composable +private fun MoreMenu( + canRevoke: Boolean, + enabled: Boolean, + onCopy: () -> Unit, + onShowCard: () -> Unit, + onRevoke: () -> Unit, + initiallyExpanded: Boolean, +) { + var expanded by remember { mutableStateOf(initiallyExpanded) } + Box { + IconButton( + modifier = Modifier.testTag(InviteHostMoreTag), + enabled = enabled, + onClick = { expanded = true }, + ) { + Icon( + imageVector = Icons.Outlined.MoreHoriz, + contentDescription = stringResource(R.string.action_moreLinkOptions), + tint = CodeTheme.colors.textMain, + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + // The profile's ⋯ menu surface. + containerColor = CodeTheme.colors.brandLight, + shape = CodeTheme.shapes.extraLarge, + ) { + MenuItem(stringResource(R.string.action_copyLink)) { expanded = false; onCopy() } + MenuItem(stringResource(R.string.action_showGroupCard)) { expanded = false; onShowCard() } + if (canRevoke) { + HorizontalDivider(color = CodeTheme.colors.divider) + MenuItem( + text = stringResource(R.string.action_revokeAndMakeNewLink), + color = CodeTheme.colors.errorText, + ) { expanded = false; onRevoke() } + } + } + } +} + +@Composable +private fun MenuItem( + text: String, + color: androidx.compose.ui.graphics.Color = CodeTheme.colors.textMain, + onClick: () -> Unit, +) { + DropdownMenuItem( + text = { Text(text = text, style = CodeTheme.typography.textSmall, color = color) }, + onClick = onClick, + ) +} + +@Composable +private fun PillText(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, + ) +} + +/** A card row: title and subtitle, and [trailing] at the end; the whole row is the target. */ +@Composable +private fun SheetRow( + title: String, + subtitle: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + trailing: @Composable () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(CodeTheme.shapes.medium) + .background(White05) + .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) + Text(text = subtitle, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary) + } + trailing() + } +} 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..f429b668c8 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModel.kt @@ -0,0 +1,280 @@ +package com.flipcash.app.messenger.internal.invites + +import android.content.ClipboardManager +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.core.extensions.setSensitiveText +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.GroupCardLinks +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.manager.BottomBarManager +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 + +/** + * Frames 1m to 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, Copy Link or Show Group + * Card 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 cardLinks: GroupCardLinks, + private val resources: ResourceHelper, + private val clipboardManager: ClipboardManager, + 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 + + /** Revoke and Make New Link is there only for an invite link that exists. */ + val canRevoke: Boolean get() = !approve && invite is InviteLinkState.Ready + } + + 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 + + /** The group card, whose link [GroupCardLinks] now holds. */ + data object ShowCard : Effect + + data object OpenManageInvites : Effect + + /** The plain link is on the clipboard; the conversation copies it, with its toast. */ + data object CopyJoin : Effect + + data object Close : Effect + } + + private enum class Action { Share, Copy, Card } + + 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 current: HostInvite? = null + private var pending: Job? = null + + fun open(chatId: ChatId) { + if (this.chatId == chatId) return + this.chatId = chatId + current = hostInvites.current(chatId) + _state.update { + State( + approve = it.approve, + invite = current?.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 + current = hostInvites.current(chatId) + _state.update { it.copy(invite = current?.toState() ?: InviteLinkState.None) } + } + + fun onApproveChanged(on: Boolean) { + val chatId = chatId ?: return + _state.update { it.copy(approve = on) } + viewModelScope.launch { approveStore.set(chatId, on) } + } + + fun onShare() = act(Action.Share) + + fun onCopy() = act(Action.Copy) + + fun onShowCard() = act(Action.Card) + + fun onManageInvites() { + _effects.trySend(Effect.OpenManageInvites) + } + + private fun act(action: Action) { + val chatId = chatId ?: return + if (pending?.isActive == true) return + if (_state.value.approve) { + plain(chatId, action) + return + } + pending = viewModelScope.launch { + val url = ensureInvite(chatId) ?: return@launch + when (action) { + Action.Share -> _effects.send(Effect.ShareInvite) + Action.Copy -> clipboardManager.setSensitiveText( + text = url, + label = resources.getString(R.string.title_clipboardLabelGroupInviteLink), + ) + Action.Card -> { + val ready = _state.value.invite as? InviteLinkState.Ready ?: return@launch + cardLinks.put(chatId, GroupCardLinks.Link(url, isInvite = true, expiresAt = ready.expiresAt)) + _effects.send(Effect.ShowCard) + } + } + } + } + + private fun plain(chatId: ChatId, action: Action) { + val url = _state.value.plainUrl ?: return + when (action) { + Action.Share -> _effects.trySend(Effect.ShareJoin) + Action.Copy -> _effects.trySend(Effect.CopyJoin) + Action.Card -> { + cardLinks.put(chatId, GroupCardLinks.Link(url, isInvite = false)) + _effects.trySend(Effect.ShowCard) + } + } + } + + /** + * 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 { found -> + current = found + return settle(found) + } + return when (val result = hostInvites.currentOrCreate(chatId).also { analytics.trackInviteLinkCreated(it) }) { + is InviteCreation.Created -> { + current = result.invite + 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 (Open 10): its button turns Approve new members on, so the host isn't sent + // back here on the next open, and shares the plain link it now shows. + showInviteLinksNotAvailable( + resources, + onShareJoinLink = { + onApproveChanged(true) + _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) + } + + /** + * Revoke and Make New Link: surface 2's revoke confirmation, then an empty card until the next + * tap makes a new current link. + */ + fun onRevoke() { + val chatId = chatId ?: return + val invite = current ?: return + showRevokeInviteLink(resources) { + viewModelScope.launch { + hostInvites.revoke(chatId, invite.invite.id) + .onSuccess { + current = null + _state.update { it.copy(invite = InviteLinkState.None) } + } + .onFailure { error -> + if (error.isLostInviteAccess()) { + hostInvites.revokeAccess(chatId) + _effects.send(Effect.Close) + } else { + BottomBarManager.showError( + title = resources.getString(R.string.title_inviteLinkNotRevoked), + message = resources.getString(R.string.description_inviteLinkNotCreated), + ) + } + } + } + } + } + + 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..27a15e963e 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,8 @@ 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.ApproveNewMembersStore +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 +68,8 @@ internal class InviteLinkViewModel @Inject constructor( private val resources: ResourceHelper, private val clipboardManager: ClipboardManager, private val analytics: FlipcashAnalytics, + private val cardLinks: GroupCardLinks, + private val approveStore: ApproveNewMembersStore, ) : ViewModel() { enum class Action { Share, Copy, Send } @@ -106,6 +110,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,7 +198,12 @@ internal class InviteLinkViewModel @Inject constructor( _state.update { it.copy(link = InviteLinkState.None) } showInviteLinksNotAvailable( resources, - onShareJoinLink = { _effects.trySend(Effect.OpenJoinLink) }, + onShareJoinLink = { + // Open 10: the host who hits this turns Approve new members on, so the + // host sheet shows the plain link next time rather than sending them back here. + viewModelScope.launch { approveStore.set(chatId, true) } + _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..8dc7d6d0a5 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,48 @@ private fun InviteSheetFrame(subject: ShareToChatsSubject, selection: List 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 +330,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 +350,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 +410,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 +585,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 +595,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 +611,42 @@ 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, content: @Composable RowScope.() -> Unit) { + 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), + 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..229f6babdc 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,6 @@ 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.features.messenger.R import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatRuleRequirement @@ -260,15 +259,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.push(ChatStep.InviteHost) } 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..58841c6923 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,6 @@ 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.features.messenger.R import com.flipcash.services.models.chat.LobbyMember import com.flipcash.shared.chat.RequestAge @@ -102,12 +101,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.push(ChatStep.InviteHost) } 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/InviteLinkViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt index 17cb7f5623..adb3966693 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt @@ -11,6 +11,8 @@ import com.flipcash.services.models.GetChatInvitesError 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.GroupCardLinks import com.flipcash.shared.chat.HostInvite import com.flipcash.shared.chat.HostInvites import com.flipcash.shared.chat.InviteCreation @@ -26,7 +28,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -76,7 +80,16 @@ class InviteLinkViewModelTest { private val analytics = RecordingAnalytics() - private fun viewModel() = InviteLinkViewModel(hostInvites, resources, clipboard, analytics) + private val approved = mutableSetOf() + private val approveStore = object : ApproveNewMembersStore { + override fun observe(chatId: ChatId): Flow = flowOf(chatId in approved) + override suspend fun set(chatId: ChatId, on: Boolean) { + if (on) approved += chatId else approved -= chatId + } + } + + private fun viewModel() = + InviteLinkViewModel(hostInvites, resources, clipboard, analytics, GroupCardLinks(), approveStore) private var seed = 1 private fun hostInvite(url: String?, useCount: Long = 0, maxUses: Long = 0): HostInvite { @@ -198,6 +211,18 @@ class InviteLinkViewModelTest { assertEquals(InviteLinkState.None, model.state.value.link) } + @Test + fun `not available turns Approve new members on and opens the plain link`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.NotAvailable + val model = viewModel() + model.open(chatId, null) + + BottomBarManager.messages.value.single().actions.first().onClick() + + assertEquals(InviteLinkViewModel.Effect.OpenJoinLink, model.effects.first()) + assertTrue(chatId in approved) + } + @Test fun `any other failure shows the generic error and closes behind it`() = runTest { coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.Failed(IllegalStateException()) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/GroupCardTileTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/GroupCardTileTest.kt index c7f4264093..76eb10c826 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/GroupCardTileTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/GroupCardTileTest.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.screens +import com.flipcash.app.messenger.internal.invites.InviteLinkState import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -29,7 +30,14 @@ class GroupCardTileTest { } @Test - fun `an invite link sheet has no card tile even when the host supplies a card`() { - assertFalse(showsCardTile(ShareToChatsSubject.GroupInviteLink(group = null, link = com.flipcash.app.messenger.internal.invites.InviteLinkState.Creating)) {}) + fun `an invite link sheet shows the card tile when the host supplies a card`() { + val subject = ShareToChatsSubject.GroupInviteLink(group = null, link = InviteLinkState.Creating) + assertTrue(showsCardTile(subject) {}) + assertEquals(GroupCardTileTag, cardTileTag(subject)) + } + + @Test + fun `message and currency links have no card`() { + assertFalse(showsCardTile(ShareToChatsSubject.Message) {}) } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ApproveNewMembersStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ApproveNewMembersStore.kt new file mode 100644 index 0000000000..adf3bec485 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ApproveNewMembersStore.kt @@ -0,0 +1,16 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import kotlinx.coroutines.flow.Flow + +/** + * The host sheet's Approve new members switch, per group, on this device only (spec surface 1). + * On, the host's link card shows the plain `/chat/{uuid}` link; off, the current invite link. + * Turning it on revokes nothing. + */ +interface ApproveNewMembersStore { + /** Whether the switch is on for [chatId] for the signed-in account; off until set. */ + fun observe(chatId: ChatId): Flow + + suspend fun set(chatId: ChatId, on: Boolean) +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupCardLinks.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupCardLinks.kt new file mode 100644 index 0000000000..f2dab04bb9 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupCardLinks.kt @@ -0,0 +1,43 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Instant + +/** + * Hands a group card the link it encodes, in memory. The card's route is saved with the back + * stack, and an invite link's fragment is the chat key's secret, so the link can't ride on it the + * way the chat id does (spec surface 1, "Show Group Card on every link"). + * + * The sheet [put]s the link just before it opens the card; the card [take]s it once, which + * clears it. A card restored with nothing here (after process death) encodes the plain link. + */ +@Singleton +class GroupCardLinks @Inject constructor() { + + /** + * What one card encodes. [expiresAt] and [isInvite] decide the lines under the QR. + * [toString] leaves the link out, so this never prints it. + */ + class Link(val url: String, val isInvite: Boolean, val expiresAt: Instant? = null) { + override fun toString(): String = "Link(isInvite=$isInvite, expiresAt=$expiresAt)" + } + + private val links = ConcurrentHashMap() + + fun put(chatId: ChatId, link: Link) { + links[chatId] = link + } + + /** The link handed over for [chatId], removed as it is read. */ + fun take(chatId: ChatId): Link? = links.remove(chatId) + + /** + * What the card for [chatId] encodes: the link handed over, taken so it is used once, or else + * the group's [plainUrl]. + */ + fun cardLink(chatId: ChatId, plainUrl: String?): Link? = + take(chatId) ?: plainUrl?.let { Link(it, isInvite = false) } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index 9b7982e77d..e6ccea5864 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -11,6 +11,8 @@ import com.flipcash.shared.chat.LobbyUpdateSink import com.flipcash.shared.chat.GroupRosters import com.flipcash.shared.chat.RosterUpdateSink import com.flipcash.shared.chat.RosterSearchSource +import com.flipcash.shared.chat.ApproveNewMembersStore +import com.flipcash.shared.chat.internal.DataStoreApproveNewMembersStore import com.flipcash.shared.chat.internal.DataStoreLobbyRequestStore import com.flipcash.shared.chat.internal.DmOutgoingEncryption import com.flipcash.shared.chat.alerts.ReactionAlertController @@ -65,6 +67,11 @@ abstract class ChatModule { impl: DataStoreLobbyRequestStore ): LobbyRequestStore + @Binds + internal abstract fun bindApproveNewMembersStore( + impl: DataStoreApproveNewMembersStore + ): ApproveNewMembersStore + @Binds abstract fun bindLobbyUpdateSink( impl: HostLobbies diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStore.kt new file mode 100644 index 0000000000..055ddc3b7a --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStore.kt @@ -0,0 +1,63 @@ +package com.flipcash.shared.chat.internal + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ApproveNewMembersStore +import com.getcode.utils.hexEncodedString +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.approveNewMembersDataStore: DataStore by preferencesDataStore(name = "approve_new_members") + +/** + * One string set per account of the chat ids (hex) whose switch is on. Per account, as + * [DataStoreLobbyRequestStore] is, because one device serves several. + */ +@Singleton +internal class DataStoreApproveNewMembersStore( + private val dataStore: DataStore, + /** The signed-in account as hex, null when signed out. */ + private val owners: Flow, + private val currentOwner: () -> String?, +) : ApproveNewMembersStore { + + @Inject + constructor(@ApplicationContext context: Context, userManager: UserManager) : this( + dataStore = context.approveNewMembersDataStore, + owners = userManager.state.map { it.accountId?.hexEncodedString() }, + currentOwner = { userManager.accountId?.hexEncodedString() }, + ) + + @OptIn(ExperimentalStdlibApi::class) + override fun observe(chatId: ChatId): Flow = + combine( + owners.distinctUntilChanged(), + dataStore.data, + ) { owner, prefs -> + owner != null && chatId.bytes.toHexString() in prefs[key(owner)].orEmpty() + }.distinctUntilChanged() + + @OptIn(ExperimentalStdlibApi::class) + override suspend fun set(chatId: ChatId, on: Boolean) { + val owner = currentOwner() ?: return + val hex = chatId.bytes.toHexString() + dataStore.edit { prefs -> + val key = key(owner) + val current = prefs[key].orEmpty() + prefs[key] = if (on) current + hex else current - hex + } + } + + private fun key(owner: String) = stringSetPreferencesKey("on-$owner") +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupCardLinksTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupCardLinksTest.kt new file mode 100644 index 0000000000..511e3a631d --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupCardLinksTest.kt @@ -0,0 +1,54 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class GroupCardLinksTest { + private val group = ChatId("aa") + private val plain = "https://flipcash.com/chat/plain" + private val invite = "https://flipcash.com/i/abc#s=secret" + + @Test + fun `a card opened from an invite link encodes that link`() { + val links = GroupCardLinks() + links.put(group, GroupCardLinks.Link(invite, isInvite = true)) + + val link = links.cardLink(group, plain)!! + + assertEquals(invite, link.url) + assertTrue(link.isInvite) + } + + @Test + fun `a card with nothing handed over encodes the plain link`() { + val link = GroupCardLinks().cardLink(group, plain)!! + + assertEquals(plain, link.url) + assertFalse(link.isInvite) + } + + @Test + fun `the handed link is used once, so a card restored later falls back to the plain link`() { + val links = GroupCardLinks() + links.put(group, GroupCardLinks.Link(invite, isInvite = true)) + links.cardLink(group, plain) + + assertEquals(plain, links.cardLink(group, plain)!!.url) + } + + @Test + fun `a link handed for one group is not another group's`() { + val links = GroupCardLinks() + links.put(group, GroupCardLinks.Link(invite, isInvite = true)) + + assertEquals(plain, links.cardLink(ChatId("bb"), plain)!!.url) + } + + @Test + fun `a link never prints its URL`() { + assertFalse(invite in GroupCardLinks.Link(invite, isInvite = true).toString()) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStoreTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStoreTest.kt new file mode 100644 index 0000000000..145b35440e --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStoreTest.kt @@ -0,0 +1,76 @@ +package com.flipcash.shared.chat.internal + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.flipcash.services.models.chat.ChatId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DataStoreApproveNewMembersStoreTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val dir: File = createTempDirectory("approve").toFile() + private val owner = MutableStateFlow("account-a") + private val store = DataStoreApproveNewMembersStore( + dataStore = PreferenceDataStoreFactory.create(scope = scope) { File(dir, "approve.preferences_pb") }, + owners = owner, + currentOwner = { owner.value }, + ) + private val moony = ChatId("aa") + private val other = ChatId("bb") + + @AfterTest + fun tearDown() { + scope.cancel() + dir.deleteRecursively() + } + + @Test + fun `the switch is off until set`() = runBlocking { + assertFalse(store.observe(moony).first()) + } + + @Test + fun `the switch is kept per group`() = runBlocking { + store.set(moony, true) + + assertTrue(store.observe(moony).first()) + assertFalse(store.observe(other).first()) + } + + @Test + fun `turning it off again clears it for that group only`() = runBlocking { + store.set(moony, true) + store.set(other, true) + store.set(moony, false) + + assertFalse(store.observe(moony).first()) + assertTrue(store.observe(other).first()) + } + + @Test + fun `another account on the device has its own switch`() = runBlocking { + store.set(moony, true) + owner.value = "account-b" + + assertFalse(store.observe(moony).first()) + } + + @Test + fun `signed out, nothing is on and nothing is kept`() = runBlocking { + owner.value = null + store.set(moony, true) + owner.value = "account-a" + + assertFalse(store.observe(moony).first()) + } +} From e2af42625b204b0b4e7736369ee0c8b34ffeae7c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 15:20:13 -0400 Subject: [PATCH 02/12] fix(groups): open the host sheet's next sheet after it closes, and use Figma's darker surfaces on it Share Link and Manage Invites return their pick through the nav result, as 2b does, so the next sheet opens once the host sheet is gone. The link card and both rows use white at 0.08 and the pill white at 0.06; the Invite People sheet keeps its pill color. --- .../com/flipcash/app/core/chat/ChatStep.kt | 5 ++++ .../flipcash/app/messenger/ChatFlowScreen.kt | 26 +++++++------------ .../internal/invites/InviteHostSheet.kt | 14 ++++++---- .../internal/screens/GroupInviteSheet.kt | 24 +++++++++++++++-- .../screens/profile/GroupProfileScreen.kt | 3 ++- .../screens/profile/JoinRequestsScreen.kt | 3 ++- 6 files changed, 49 insertions(+), 26 deletions(-) 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 90e0484ec5..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. @@ -126,6 +130,7 @@ sealed interface ChatStep : FlowStep, Parcelable { @Serializable data object InviteHost : ChatStep, + NavigationRetVal, Sheet, WrapContentSheet 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 74afa40624..0b33647b11 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 @@ -60,7 +61,6 @@ import com.flipcash.app.messenger.internal.screens.ReactorsSheet import com.flipcash.app.messenger.internal.screens.ReactorsViewModel import com.flipcash.app.messenger.internal.screens.ShareToChatsSubject import com.flipcash.app.messenger.internal.screens.openGroupInvite -import com.flipcash.app.messenger.internal.screens.openInviteLinkShare import com.flipcash.app.messenger.internal.request.ChatRequestsViewModel import com.flipcash.app.messenger.internal.request.RequestDetailsScreen import com.flipcash.app.messenger.internal.request.RequestMoneyScreen @@ -644,9 +644,9 @@ private fun FlowInviteLinkSheet(inviteIdHex: String?) { } /** - * [ChatStep.InviteHost]: frames 1m to 1m″ on the app's sheet scene. Share Link closes it for the - * Invite People sheet on the card's link; Show Group Card draws the card over it, as the Invite - * People sheet does. + * [ChatStep.InviteHost]: frames 1m to 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; Show + * Group Card draws the card over it, as the Invite People sheet does. */ @Composable private fun FlowInviteHostSheet() { @@ -655,8 +655,8 @@ private fun FlowInviteHostSheet() { val hostViewModel = hiltViewModel() val hostState by hostViewModel.state.collectAsStateWithLifecycle() val dismissSheet = LocalBottomSheetDismissDispatcher.current + val resultBack = resultBackNavigator(exit = dismissSheet) val navigator = LocalCodeNavigator.current - val flowNavigator = rememberFlowNavigator() val group = state.subject as? ChatSubject.Group LaunchedEffect(hostViewModel, state.chatId) { @@ -665,19 +665,11 @@ private fun FlowInviteHostSheet() { LaunchedEffect(hostViewModel) { hostViewModel.effects.collect { effect -> when (effect) { - InviteHostSheetViewModel.Effect.ShareInvite -> { - dismissSheet() - navigator.openInviteLinkShare() - } - InviteHostSheetViewModel.Effect.ShareJoin -> { - dismissSheet() - navigator.openGroupInvite() - } + InviteHostSheetViewModel.Effect.ShareInvite -> resultBack.returnValue(InviteHostAction.ShareInvite) + InviteHostSheetViewModel.Effect.ShareJoin -> resultBack.returnValue(InviteHostAction.ShareJoin) InviteHostSheetViewModel.Effect.ShowCard -> group?.let { navigator.push(it.cardRoute()) } - InviteHostSheetViewModel.Effect.OpenManageInvites -> { - dismissSheet() - flowNavigator.navigateTo(ChatStep.ManageInvites) - } + InviteHostSheetViewModel.Effect.OpenManageInvites -> + resultBack.returnValue(InviteHostAction.ManageInvites) InviteHostSheetViewModel.Effect.CopyJoin -> viewModel.dispatchEvent(ChatViewModel.Event.CopyInviteLink) InviteHostSheetViewModel.Effect.Close -> dismissSheet() 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 9cc1f3866d..89ed32c569 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 @@ -26,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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 @@ -35,7 +36,7 @@ import com.flipcash.app.messenger.internal.screens.LinkPillFrame import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme import com.getcode.theme.extraLarge -import com.getcode.theme.White05 +import com.getcode.theme.White08 import com.getcode.theme.White50 import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle @@ -107,6 +108,9 @@ internal fun InviteHostSheetContent( } } +// Figma 1m's pill sits darker than the card around it; no theme token is white at 0.06. +private val LinkPillColor = Color(0x0FFFFFFF) + /** The pill with ⋯, Share Link, and the caption: how many joined and when it ends, or that people ask. */ @Composable private fun LinkCard( @@ -122,12 +126,12 @@ private fun LinkCard( modifier = Modifier .fillMaxWidth() .clip(CodeTheme.shapes.medium) - .background(White05) + .background(White08) .padding(CodeTheme.dimens.grid.x3), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), horizontalAlignment = Alignment.CenterHorizontally, ) { - LinkPillFrame { + LinkPillFrame(color = LinkPillColor) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { when { state.approve -> state.plainUrl?.let { PillText(plainLinkText(it)) } @@ -233,7 +237,7 @@ private fun MoreMenu( @Composable private fun MenuItem( text: String, - color: androidx.compose.ui.graphics.Color = CodeTheme.colors.textMain, + color: Color = CodeTheme.colors.textMain, onClick: () -> Unit, ) { DropdownMenuItem( @@ -269,7 +273,7 @@ private fun SheetRow( modifier = modifier .fillMaxWidth() .clip(CodeTheme.shapes.medium) - .background(White05) + .background(White08) .clickable(onClick = onClick) .padding(CodeTheme.dimens.grid.x3), verticalAlignment = Alignment.CenterVertically, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index f36068943e..31ef7eb57b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -72,6 +72,7 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.chat.GroupInviteResult +import com.flipcash.app.core.chat.InviteHostAction import com.flipcash.app.core.media.rememberMediaUrl import com.flipcash.app.core.share.SharePreviewImage import com.flipcash.app.core.share.TipCodePreview @@ -141,6 +142,21 @@ internal fun CodeNavigator.openInviteLinkShare(inviteIdHex: String? = null) { } } +/** + * Opens the host's Invite sheet (frame 1m). It returns what to open next and closes, and the next + * sheet opens from here, once it is gone; [onManageInvites] opens Manage Invites in the chat flow. + */ +internal fun CodeNavigator.openInviteHost(onManageInvites: () -> 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 -> @@ -620,12 +636,16 @@ internal fun PlainLinkPill(url: String, modifier: Modifier = Modifier) { } @Composable -internal fun LinkPillFrame(modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) { +internal fun LinkPillFrame( + modifier: Modifier = Modifier, + color: Color = White05, + content: @Composable RowScope.() -> Unit, +) { Row( modifier = modifier .fillMaxWidth() .clip(CodeTheme.shapes.extraSmall) - .background(White05) + .background(color) .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x3), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), 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 229f6babdc..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,6 +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.openInviteHost import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatRuleRequirement @@ -262,7 +263,7 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // A host sees the link card sheet (frame 1m); a member without // can_invite keeps the single join link. if (canInvite) { - navigator.push(ChatStep.InviteHost) + 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 58841c6923..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,6 +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.openInviteHost import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.LobbyMember import com.flipcash.shared.chat.RequestAge @@ -101,7 +102,7 @@ internal fun JoinRequestsScreen(viewModel: ChatViewModel) { onShare = { viewModel.dispatchEvent(ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.PROFILE)) if (canInvite) { - navigator.push(ChatStep.InviteHost) + navigator.openInviteHost { flowNavigator.navigateTo(ChatStep.ManageInvites) } } else { navigator.openGroupInvite() } From 3f68182886731b0f7fff1fb25af2b796e013c94c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 15:33:05 -0400 Subject: [PATCH 03/12] fix(groups): drop the host link card's menu, and space and size its header and pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The link card's ⋯ menu is gone: Copy Link and Show Group Card are on the Invite People sheet that Share Link opens, and revoking is in Manage Invites. The host sheet's view model keeps only Share Link. The card now starts 16 below the header instead of touching its close button, and the pill is one 30-high line, 14 inside the card, as in Figma 1m. The row reads "Approve New Members", and its subtitles drop the "Off:" and "On:" prefixes. --- .../src/androidMain/res/values/strings.xml | 10 +- .../flipcash/app/messenger/ChatFlowScreen.kt | 12 +- .../internal/invites/InviteHostSheet.kt | 115 +++--------------- .../invites/InviteHostSheetViewModel.kt | 108 ++-------------- .../internal/invites/InvitePreviews.kt | 3 - .../internal/screens/GroupInviteSheet.kt | 3 +- 6 files changed, 39 insertions(+), 212 deletions(-) diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index b83b74d9ac..074f40aba8 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1585,16 +1585,14 @@ Share Invite Link Manage Invites Creating link… - + %1$s · %2$s People who open this link ask to join - Approve new members - Off: anyone with the link joins right away - On: you approve each person in Join Requests + Approve New Members + Anyone with the link joins right away + You approve each person in Join Requests Make more links with an expiry or a use limit - Revoke and Make New Link - More link options 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 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 0b33647b11..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 @@ -644,9 +644,8 @@ private fun FlowInviteLinkSheet(inviteIdHex: String?) { } /** - * [ChatStep.InviteHost]: frames 1m to 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; Show - * Group Card draws the card over it, as the Invite People sheet does. + * [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() { @@ -656,7 +655,6 @@ private fun FlowInviteHostSheet() { val hostState by hostViewModel.state.collectAsStateWithLifecycle() val dismissSheet = LocalBottomSheetDismissDispatcher.current val resultBack = resultBackNavigator(exit = dismissSheet) - val navigator = LocalCodeNavigator.current val group = state.subject as? ChatSubject.Group LaunchedEffect(hostViewModel, state.chatId) { @@ -667,11 +665,8 @@ private fun FlowInviteHostSheet() { when (effect) { InviteHostSheetViewModel.Effect.ShareInvite -> resultBack.returnValue(InviteHostAction.ShareInvite) InviteHostSheetViewModel.Effect.ShareJoin -> resultBack.returnValue(InviteHostAction.ShareJoin) - InviteHostSheetViewModel.Effect.ShowCard -> group?.let { navigator.push(it.cardRoute()) } InviteHostSheetViewModel.Effect.OpenManageInvites -> resultBack.returnValue(InviteHostAction.ManageInvites) - InviteHostSheetViewModel.Effect.CopyJoin -> - viewModel.dispatchEvent(ChatViewModel.Event.CopyInviteLink) InviteHostSheetViewModel.Effect.Close -> dismissSheet() } } @@ -681,9 +676,6 @@ private fun FlowInviteHostSheet() { groupName = group?.groupTitle.orEmpty(), state = hostState, onShare = hostViewModel::onShare, - onCopy = hostViewModel::onCopy, - onShowCard = hostViewModel::onShowCard, - onRevoke = hostViewModel::onRevoke, onApproveChanged = hostViewModel::onApproveChanged, onManageInvites = hostViewModel::onManageInvites, onDismiss = dismissSheet, 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 89ed32c569..59af68937f 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 @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -14,16 +15,7 @@ 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.material.icons.outlined.MoreHoriz -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -32,10 +24,10 @@ 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 com.flipcash.app.messenger.internal.screens.LinkPillFrame import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme -import com.getcode.theme.extraLarge import com.getcode.theme.White08 import com.getcode.theme.White50 import com.getcode.ui.components.AppBarDefaults @@ -45,26 +37,22 @@ import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.theme.CodeToggleSwitch -internal const val InviteHostMoreTag = "invite-host-more" internal const val InviteHostApproveTag = "invite-host-approve" internal const val InviteHostShareTag = "invite-host-share" /** - * Frames 1m to 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. + * 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 Group Card 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, - onCopy: () -> Unit, - onShowCard: () -> Unit, - onRevoke: () -> Unit, onApproveChanged: (Boolean) -> Unit, onManageInvites: () -> Unit, onDismiss: () -> Unit, - initiallyExpanded: Boolean = false, ) { Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) { AppBarWithTitle( @@ -76,17 +64,11 @@ internal fun InviteHostSheetContent( modifier = Modifier .fillMaxWidth() .padding(horizontal = CodeTheme.dimens.inset) - .padding(bottom = CodeTheme.dimens.grid.x4), + // The top keeps the link card clear of the header and its close button. + .padding(vertical = CodeTheme.dimens.grid.x4), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { - LinkCard( - state = state, - onShare = onShare, - onCopy = onCopy, - onShowCard = onShowCard, - onRevoke = onRevoke, - initiallyExpanded = initiallyExpanded, - ) + LinkCard(state = state, onShare = onShare) SheetRow( modifier = Modifier.testTag(InviteHostApproveTag), title = stringResource(R.string.label_approveNewMembers), @@ -111,15 +93,15 @@ internal fun InviteHostSheetContent( // Figma 1m's pill sits darker than the card around it; no theme token is white at 0.06. private val LinkPillColor = Color(0x0FFFFFFF) -/** The pill with ⋯, Share Link, and the caption: how many joined and when it ends, or that people ask. */ +// Figma 1m: the card's content sits 14 in, and its pill is one 30-high line. +private val LinkCardPadding = 14.dp +private val LinkPillHeight = 30.dp + +/** 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, - onCopy: () -> Unit, - onShowCard: () -> Unit, - onRevoke: () -> Unit, - initiallyExpanded: Boolean, ) { val invite = state.invite Column( @@ -127,11 +109,15 @@ private fun LinkCard( .fillMaxWidth() .clip(CodeTheme.shapes.medium) .background(White08) - .padding(CodeTheme.dimens.grid.x3), + .padding(LinkCardPadding), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), horizontalAlignment = Alignment.CenterHorizontally, ) { - LinkPillFrame(color = LinkPillColor) { + LinkPillFrame( + modifier = Modifier.heightIn(min = LinkPillHeight), + color = LinkPillColor, + verticalPadding = CodeTheme.dimens.grid.x1, + ) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { when { state.approve -> state.plainUrl?.let { PillText(plainLinkText(it)) } @@ -153,14 +139,6 @@ private fun LinkCard( else -> Unit } } - MoreMenu( - canRevoke = state.canRevoke, - enabled = !state.creating, - onCopy = onCopy, - onShowCard = onShowCard, - onRevoke = onRevoke, - initiallyExpanded = initiallyExpanded, - ) } CodeButton( modifier = Modifier.fillMaxWidth().testTag(InviteHostShareTag), @@ -191,61 +169,6 @@ private fun LinkCard( } } -/** Frame 1m″: the platform's own menu off the pill's ⋯. */ -@Composable -private fun MoreMenu( - canRevoke: Boolean, - enabled: Boolean, - onCopy: () -> Unit, - onShowCard: () -> Unit, - onRevoke: () -> Unit, - initiallyExpanded: Boolean, -) { - var expanded by remember { mutableStateOf(initiallyExpanded) } - Box { - IconButton( - modifier = Modifier.testTag(InviteHostMoreTag), - enabled = enabled, - onClick = { expanded = true }, - ) { - Icon( - imageVector = Icons.Outlined.MoreHoriz, - contentDescription = stringResource(R.string.action_moreLinkOptions), - tint = CodeTheme.colors.textMain, - ) - } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - // The profile's ⋯ menu surface. - containerColor = CodeTheme.colors.brandLight, - shape = CodeTheme.shapes.extraLarge, - ) { - MenuItem(stringResource(R.string.action_copyLink)) { expanded = false; onCopy() } - MenuItem(stringResource(R.string.action_showGroupCard)) { expanded = false; onShowCard() } - if (canRevoke) { - HorizontalDivider(color = CodeTheme.colors.divider) - MenuItem( - text = stringResource(R.string.action_revokeAndMakeNewLink), - color = CodeTheme.colors.errorText, - ) { expanded = false; onRevoke() } - } - } - } -} - -@Composable -private fun MenuItem( - text: String, - color: Color = CodeTheme.colors.textMain, - onClick: () -> Unit, -) { - DropdownMenuItem( - text = { Text(text = text, style = CodeTheme.typography.textSmall, color = color) }, - onClick = onClick, - ) -} - @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 f429b668c8..581b015696 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 @@ -1,21 +1,17 @@ package com.flipcash.app.messenger.internal.invites -import android.content.ClipboardManager import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flipcash.app.analytics.FlipcashAnalytics -import com.flipcash.app.core.extensions.setSensitiveText 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.GroupCardLinks 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.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -30,11 +26,11 @@ import kotlinx.coroutines.launch import javax.inject.Inject /** - * Frames 1m to 1m″: the host's link card, Approve new members and Manage Invites (spec surface 1). + * 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, Copy Link or Show Group - * Card rather than on open. On, it is the plain `/chat/{uuid}` link. The switch only picks which - * one the card shows; it revokes nothing. + * 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. */ @@ -42,9 +38,7 @@ import javax.inject.Inject internal class InviteHostSheetViewModel @Inject constructor( private val hostInvites: HostInvites, private val approveStore: ApproveNewMembersStore, - private val cardLinks: GroupCardLinks, private val resources: ResourceHelper, - private val clipboardManager: ClipboardManager, private val analytics: FlipcashAnalytics, ) : ViewModel() { @@ -60,9 +54,6 @@ internal class InviteHostSheetViewModel @Inject constructor( val plainUrl: String? = null, ) { val creating: Boolean get() = !approve && invite == InviteLinkState.Creating - - /** Revoke and Make New Link is there only for an invite link that exists. */ - val canRevoke: Boolean get() = !approve && invite is InviteLinkState.Ready } sealed interface Effect { @@ -72,19 +63,11 @@ internal class InviteHostSheetViewModel @Inject constructor( /** The Invite People sheet on the plain link, as a member gets it. */ data object ShareJoin : Effect - /** The group card, whose link [GroupCardLinks] now holds. */ - data object ShowCard : Effect - data object OpenManageInvites : Effect - /** The plain link is on the clipboard; the conversation copies it, with its toast. */ - data object CopyJoin : Effect - data object Close : Effect } - private enum class Action { Share, Copy, Card } - private val _state = MutableStateFlow(State()) val state: StateFlow = _state.asStateFlow() @@ -92,17 +75,15 @@ internal class InviteHostSheetViewModel @Inject constructor( val effects: Flow = _effects.receiveAsFlow() private var chatId: ChatId? = null - private var current: HostInvite? = null private var pending: Job? = null fun open(chatId: ChatId) { if (this.chatId == chatId) return this.chatId = chatId - current = hostInvites.current(chatId) _state.update { State( approve = it.approve, - invite = current?.toState() ?: InviteLinkState.Loading, + invite = hostInvites.current(chatId)?.toState() ?: InviteLinkState.Loading, plainUrl = Linkify.groupChatInvite(chatId), ) } @@ -122,8 +103,7 @@ internal class InviteHostSheetViewModel @Inject constructor( } // A pending tap owns the card until it lands. if (pending?.isActive == true) return - current = hostInvites.current(chatId) - _state.update { it.copy(invite = current?.toState() ?: InviteLinkState.None) } + _state.update { it.copy(invite = hostInvites.current(chatId)?.toState() ?: InviteLinkState.None) } } fun onApproveChanged(on: Boolean) { @@ -132,49 +112,20 @@ internal class InviteHostSheetViewModel @Inject constructor( viewModelScope.launch { approveStore.set(chatId, on) } } - fun onShare() = act(Action.Share) - - fun onCopy() = act(Action.Copy) - - fun onShowCard() = act(Action.Card) - fun onManageInvites() { _effects.trySend(Effect.OpenManageInvites) } - private fun act(action: Action) { + fun onShare() { val chatId = chatId ?: return if (pending?.isActive == true) return if (_state.value.approve) { - plain(chatId, action) + _effects.trySend(Effect.ShareJoin) return } pending = viewModelScope.launch { - val url = ensureInvite(chatId) ?: return@launch - when (action) { - Action.Share -> _effects.send(Effect.ShareInvite) - Action.Copy -> clipboardManager.setSensitiveText( - text = url, - label = resources.getString(R.string.title_clipboardLabelGroupInviteLink), - ) - Action.Card -> { - val ready = _state.value.invite as? InviteLinkState.Ready ?: return@launch - cardLinks.put(chatId, GroupCardLinks.Link(url, isInvite = true, expiresAt = ready.expiresAt)) - _effects.send(Effect.ShowCard) - } - } - } - } - - private fun plain(chatId: ChatId, action: Action) { - val url = _state.value.plainUrl ?: return - when (action) { - Action.Share -> _effects.trySend(Effect.ShareJoin) - Action.Copy -> _effects.trySend(Effect.CopyJoin) - Action.Card -> { - cardLinks.put(chatId, GroupCardLinks.Link(url, isInvite = false)) - _effects.trySend(Effect.ShowCard) - } + ensureInvite(chatId) ?: return@launch + _effects.send(Effect.ShareInvite) } } @@ -197,15 +148,9 @@ internal class InviteHostSheetViewModel @Inject constructor( } return null } - hostInvites.current(chatId)?.let { found -> - current = found - return settle(found) - } + hostInvites.current(chatId)?.let { return settle(it) } return when (val result = hostInvites.currentOrCreate(chatId).also { analytics.trackInviteLinkCreated(it) }) { - is InviteCreation.Created -> { - current = result.invite - settle(result.invite) ?: run { failed(); null } - } + is InviteCreation.Created -> settle(result.invite) ?: run { failed(); null } InviteCreation.TooMany -> { _state.update { it.copy(invite = InviteLinkState.None) } showTooManyInviteLinks( @@ -245,35 +190,6 @@ internal class InviteHostSheetViewModel @Inject constructor( showInviteLinkNotCreated(resources) } - /** - * Revoke and Make New Link: surface 2's revoke confirmation, then an empty card until the next - * tap makes a new current link. - */ - fun onRevoke() { - val chatId = chatId ?: return - val invite = current ?: return - showRevokeInviteLink(resources) { - viewModelScope.launch { - hostInvites.revoke(chatId, invite.invite.id) - .onSuccess { - current = null - _state.update { it.copy(invite = InviteLinkState.None) } - } - .onFailure { error -> - if (error.isLostInviteAccess()) { - hostInvites.revokeAccess(chatId) - _effects.send(Effect.Close) - } else { - BottomBarManager.showError( - title = resources.getString(R.string.title_inviteLinkNotRevoked), - message = resources.getString(R.string.description_inviteLinkNotCreated), - ) - } - } - } - } - } - 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/InvitePreviews.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InvitePreviews.kt index 8dc7d6d0a5..045c32f03c 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 @@ -153,9 +153,6 @@ private fun HostSheetPreview(state: InviteHostSheetViewModel.State) = InviteHost groupName = "Bad Boys", state = state, onShare = {}, - onCopy = {}, - onShowCard = {}, - onRevoke = {}, onApproveChanged = {}, onManageInvites = {}, onDismiss = {}, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index 31ef7eb57b..2ad31a3ecc 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -639,6 +639,7 @@ internal fun PlainLinkPill(url: String, modifier: Modifier = Modifier) { internal fun LinkPillFrame( modifier: Modifier = Modifier, color: Color = White05, + verticalPadding: Dp = CodeTheme.dimens.grid.x3, content: @Composable RowScope.() -> Unit, ) { Row( @@ -646,7 +647,7 @@ internal fun LinkPillFrame( .fillMaxWidth() .clip(CodeTheme.shapes.extraSmall) .background(color) - .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x3), + .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = verticalPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), content = content, From ab662c79256b41a86b1c569db39e243704e5cd85 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 15:39:13 -0400 Subject: [PATCH 04/12] fix(groups): shorten the Approve New Members on subtitle to "You approve each person" --- apps/flipcash/core/src/androidMain/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index 074f40aba8..f65e4f2c97 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1591,7 +1591,7 @@ People who open this link ask to join Approve New Members Anyone with the link joins right away - You approve each person in Join Requests + 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 From fcc0026682f8fe01779b8cd842b0e892b030a002 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 15:57:42 -0400 Subject: [PATCH 05/12] feat(groups): crossfade the host link card's text when Approve New Members toggles The pill's link, the caption under Share Link and the row's subtitle now fade between the off and on text over 220ms, and the card's height animates with them instead of jumping. The fades are plain tweens, so the system animation scale applies to them. The caption's top spacing moves into the caption, so a card with no caption still ends at the button. --- .../internal/invites/InviteHostSheet.kt | 104 +++++++++++++----- .../invites/InviteHostSheetCrossfadeTest.kt | 66 +++++++++++ 2 files changed, 143 insertions(+), 27 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetCrossfadeTest.kt 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 59af68937f..0dc571a33d 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 @@ -1,12 +1,19 @@ 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.Box 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.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding @@ -72,16 +79,22 @@ internal fun InviteHostSheetContent( SheetRow( modifier = Modifier.testTag(InviteHostApproveTag), title = stringResource(R.string.label_approveNewMembers), - subtitle = stringResource( - if (state.approve) R.string.subtitle_approveNewMembersOn else R.string.subtitle_approveNewMembersOff - ), + subtitle = { + ApproveCrossfade(approve = state.approve) { on -> + SubtitleText( + stringResource( + if (on) R.string.subtitle_approveNewMembersOn else R.string.subtitle_approveNewMembersOff + ) + ) + } + }, onClick = { onApproveChanged(!state.approve) }, ) { CodeToggleSwitch(checked = state.approve, onCheckedChange = onApproveChanged) } SheetRow( title = stringResource(R.string.action_manageInvites), - subtitle = stringResource(R.string.subtitle_manageInvitesRow), + subtitle = { SubtitleText(stringResource(R.string.subtitle_manageInvitesRow)) }, onClick = onManageInvites, ) { Icon(imageVector = Icons.Outlined.Menu, contentDescription = null, tint = CodeTheme.colors.textMain) @@ -97,6 +110,31 @@ private val LinkPillColor = Color(0x0FFFFFFF) private val LinkCardPadding = 14.dp private val LinkPillHeight = 30.dp +private const val ApproveFadeMillis = 220 + +/** + * 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( @@ -110,7 +148,6 @@ private fun LinkCard( .clip(CodeTheme.shapes.medium) .background(White08) .padding(LinkCardPadding), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), horizontalAlignment = Alignment.CenterHorizontally, ) { LinkPillFrame( @@ -118,9 +155,13 @@ private fun LinkCard( color = LinkPillColor, verticalPadding = CodeTheme.dimens.grid.x1, ) { - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + ApproveCrossfade( + approve = state.approve, + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterStart, + ) { approve -> when { - state.approve -> state.plainUrl?.let { PillText(plainLinkText(it)) } + approve -> state.plainUrl?.let { PillText(plainLinkText(it)) } invite is InviteLinkState.Ready -> PillText(elideInviteUrl(invite.url)) invite == InviteLinkState.Creating -> Row( verticalAlignment = Alignment.CenterVertically, @@ -140,6 +181,7 @@ private fun LinkCard( } } } + Spacer(Modifier.height(CodeTheme.dimens.grid.x3)) CodeButton( modifier = Modifier.fillMaxWidth().testTag(InviteHostShareTag), text = stringResource(R.string.action_shareLink), @@ -148,23 +190,26 @@ private fun LinkCard( enabled = !state.creating, onClick = onShare, ) - val caption = when { - state.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(), - text = caption, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - textAlign = TextAlign.Center, - ) + // 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 = CodeTheme.dimens.grid.x3), + text = caption, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } } } } @@ -187,7 +232,7 @@ private fun PillText(text: String) { @Composable private fun SheetRow( title: String, - subtitle: String, + subtitle: @Composable () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier, trailing: @Composable () -> Unit, @@ -204,8 +249,13 @@ private fun SheetRow( ) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1)) { Text(text = title, style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain) - Text(text = subtitle, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary) + 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/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() + } +} From 56b66a17b165ba8a2a79a38f22a79e6e8f3c453c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 16:01:07 -0400 Subject: [PATCH 06/12] style(groups): match the host link card's type to iOS iOS is now the reference for this card. The pill's link is Medium 14, and the caption under Share Link and both Approve New Members subtitles are Medium 13 at white 0.68. Both styles use the true Medium face (AvenirMedium); the theme's Avenir draws FontWeight.Medium as Demi. The Manage Invites subtitle keeps its shared style, as on iOS. The card fill (White08) and pill fill (0x0F, about 0.06) already matched, so they are unchanged. --- .../internal/invites/InviteHostSheet.kt | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheet.kt index 0dc571a33d..9148f9d927 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 @@ -32,9 +32,11 @@ 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 @@ -81,10 +83,12 @@ internal fun InviteHostSheetContent( title = stringResource(R.string.label_approveNewMembers), subtitle = { ApproveCrossfade(approve = state.approve) { on -> - SubtitleText( - stringResource( + Text( + text = stringResource( if (on) R.string.subtitle_approveNewMembersOn else R.string.subtitle_approveNewMembersOff - ) + ), + style = cardNoteStyle(), + color = CardNoteColor, ) } }, @@ -112,6 +116,17 @@ private val LinkPillHeight = 30.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. @@ -205,8 +220,8 @@ private fun LinkCard( Text( modifier = Modifier.fillMaxWidth().padding(top = CodeTheme.dimens.grid.x3), text = caption, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, + style = cardNoteStyle(), + color = CardNoteColor, textAlign = TextAlign.Center, ) } @@ -218,7 +233,7 @@ private fun LinkCard( private fun PillText(text: String) { Text( text = text, - style = CodeTheme.typography.textMedium, + style = pillLinkStyle(), color = CodeTheme.colors.textMain, maxLines = 1, // No wrap: a link breaks at its slashes and hyphens, and maxLines would clip what follows. From a662163c3212b120ee0c45ad9009b932a50e098e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 16:19:46 -0400 Subject: [PATCH 07/12] style(ui): draw default bottom-bar sheets on the screen background Sheets shown through BottomBarManager with the DEFAULT type drew on bannerThemed (37,37,38), lighter than the navigation sheets, which draw on the theme background (25,25,26). iOS made the same change to PartialSheet's default. Sheets this changes, among the DEFAULT showMessage and bottomBarCardAction sheets: - Currency More (TokenInfoViewModel.presentMore) - Add Money start (InternalPurchaseMethodController.present) - Group money actions (ChatViewModel.presentMoneyActions) The host link card sheet and the E2EE learn-more sheet already drew on the background. ERROR, DESTRUCTIVE, INFO, WARNING and SUCCESS keep their banner colors. --- .../com/getcode/ui/components/bars/BottomBarContainer.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/components/src/androidMain/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt b/ui/components/src/androidMain/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt index 915f696638..19cee54128 100644 --- a/ui/components/src/androidMain/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt +++ b/ui/components/src/androidMain/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt @@ -436,7 +436,8 @@ private fun BottomBarManager.BottomBarMessageType.backgroundColor(): Color = whe BottomBarManager.BottomBarMessageType.DESTRUCTIVE -> CodeTheme.colors.bannerError BottomBarManager.BottomBarMessageType.INFO -> CodeTheme.colors.brandLight - BottomBarManager.BottomBarMessageType.DEFAULT -> CodeTheme.colors.bannerThemed + // The screen's own background, so an options sheet matches the navigation sheets beside it. + BottomBarManager.BottomBarMessageType.DEFAULT -> CodeTheme.colors.background BottomBarManager.BottomBarMessageType.WARNING -> CodeTheme.colors.bannerWarning // use themed banner's for success modals too BottomBarManager.BottomBarMessageType.SUCCESS -> CodeTheme.colors.bannerThemed // CodeTheme.colors.bannerSuccess From 7cc317579ec426b00a3275f457bec2d689e0eb59 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 16:30:31 -0400 Subject: [PATCH 08/12] feat(messenger): label the group card tile "Show QR Code" Renames the tile's string (action_showGroupCard); the tile's icon has no content description, so the label is also what TalkBack reads. Class, file, resource and analytics names are unchanged. --- apps/flipcash/core/src/androidMain/res/values/strings.xml | 2 +- .../src/commonMain/kotlin/com/flipcash/app/core/AppRoute.kt | 2 +- .../flipcash/app/messenger/internal/invites/InviteHostSheet.kt | 2 +- .../flipcash/app/messenger/internal/screens/GroupInviteSheet.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index f65e4f2c97..649602c320 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1852,7 +1852,7 @@ 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 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/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 9148f9d927..5b6aee6ac4 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 @@ -51,7 +51,7 @@ 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 Group Card are on + * 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 diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index 2ad31a3ecc..f658334d09 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -166,7 +166,7 @@ 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" /** A group's tile, for either link, carries [GroupCardTileTag]; the profile tile has none. */ From 1ad068a03489c17a5814dfbeff9f943f134018e3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 16:30:32 -0400 Subject: [PATCH 09/12] fix(chat): read Approve New Members as off when its store fails The switch was already off when nothing is stored, but an IOException reading the DataStore ended the flow instead of answering. Unreadable preferences now read as empty, so the switch is off. --- .../internal/DataStoreApproveNewMembersStore.kt | 6 +++++- .../DataStoreApproveNewMembersStoreTest.kt | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStore.kt index 055ddc3b7a..cbdc4cad4c 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStore.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStore.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.flipcash.services.models.chat.ChatId @@ -12,9 +13,11 @@ import com.flipcash.shared.chat.ApproveNewMembersStore import com.getcode.utils.hexEncodedString import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -43,7 +46,8 @@ internal class DataStoreApproveNewMembersStore( override fun observe(chatId: ChatId): Flow = combine( owners.distinctUntilChanged(), - dataStore.data, + // A store that cannot be read leaves the switch off: off is the default for every group. + dataStore.data.catch { if (it is IOException) emit(emptyPreferences()) else throw it }, ) { owner, prefs -> owner != null && chatId.bytes.toHexString() in prefs[key(owner)].orEmpty() }.distinctUntilChanged() diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStoreTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStoreTest.kt index 145b35440e..67e8bf8369 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStoreTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/DataStoreApproveNewMembersStoreTest.kt @@ -1,15 +1,20 @@ package com.flipcash.shared.chat.internal +import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences import com.flipcash.services.models.chat.ChatId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import java.io.File +import java.io.IOException import kotlin.io.path.createTempDirectory import kotlin.test.AfterTest import kotlin.test.Test @@ -39,6 +44,18 @@ class DataStoreApproveNewMembersStoreTest { assertFalse(store.observe(moony).first()) } + @Test + fun `the switch is off when the store cannot be read`() = runBlocking { + val broken = object : DataStore { + override val data: Flow = flow { throw IOException("unreadable") } + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences) = + throw IOException("unwritable") + } + val store = DataStoreApproveNewMembersStore(broken, owners = owner, currentOwner = { owner.value }) + + assertFalse(store.observe(moony).first()) + } + @Test fun `the switch is kept per group`() = runBlocking { store.set(moony, true) From bf843676597e9e671ba0078864e2ae366b8ce653 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 17:10:23 -0400 Subject: [PATCH 10/12] fix(messenger): match the host link card's pill and padding to iOS The 1m link card now uses iOS #1175's values: a 48dp capsule pill with the link 16dp from its ends, 20dp inside the card and 12dp between its items. The pill shows the whole link and cuts it in the middle, so the end of the token stays visible. Share Link stays the standard button. LinkPillFrame takes a shape and horizontal padding; their defaults keep the other pills as they were. The 1m preview uses a real-length invite link so its render shows the middle cut. --- .../internal/invites/InviteHostSheet.kt | 24 ++++++++++++------- .../internal/invites/InvitePreviews.kt | 2 +- .../internal/screens/GroupInviteSheet.kt | 7 ++++-- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheet.kt index 5b6aee6ac4..8995941cd2 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 @@ -14,10 +14,10 @@ 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.heightIn 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 @@ -110,9 +110,12 @@ internal fun InviteHostSheetContent( // Figma 1m's pill sits darker than the card around it; no theme token is white at 0.06. private val LinkPillColor = Color(0x0FFFFFFF) -// Figma 1m: the card's content sits 14 in, and its pill is one 30-high line. -private val LinkCardPadding = 14.dp -private val LinkPillHeight = 30.dp +// 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 @@ -166,9 +169,11 @@ private fun LinkCard( horizontalAlignment = Alignment.CenterHorizontally, ) { LinkPillFrame( - modifier = Modifier.heightIn(min = LinkPillHeight), + modifier = Modifier.height(LinkPillHeight), color = LinkPillColor, - verticalPadding = CodeTheme.dimens.grid.x1, + shape = CircleShape, + horizontalPadding = LinkPillInset, + verticalPadding = 0.dp, ) { ApproveCrossfade( approve = state.approve, @@ -177,7 +182,8 @@ private fun LinkCard( ) { approve -> when { approve -> state.plainUrl?.let { PillText(plainLinkText(it)) } - invite is InviteLinkState.Ready -> PillText(elideInviteUrl(invite.url)) + // 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), @@ -196,7 +202,7 @@ private fun LinkCard( } } } - Spacer(Modifier.height(CodeTheme.dimens.grid.x3)) + Spacer(Modifier.height(LinkCardSpacing)) CodeButton( modifier = Modifier.fillMaxWidth().testTag(InviteHostShareTag), text = stringResource(R.string.action_shareLink), @@ -218,7 +224,7 @@ private fun LinkCard( } if (caption != null) { Text( - modifier = Modifier.fillMaxWidth().padding(top = CodeTheme.dimens.grid.x3), + modifier = Modifier.fillMaxWidth().padding(top = LinkCardSpacing), text = caption, style = cardNoteStyle(), color = CardNoteColor, 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 045c32f03c..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 @@ -129,7 +129,7 @@ private fun InviteSheetFrame(subject: ShareToChatsSubject, selection: List Unit, ) { Row( modifier = modifier .fillMaxWidth() - .clip(CodeTheme.shapes.extraSmall) + .clip(shape) .background(color) - .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = verticalPadding), + .padding(horizontal = horizontalPadding, vertical = verticalPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), content = content, From 3c5bc0cf053c930be82684fab1a363ea06c876af Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 17:26:25 -0400 Subject: [PATCH 11/12] style(invites): match the host sheet's vertical spacing to iOS From iOS #1175's InvitePeopleSheet: the cards sit 12dp apart (8dp before), the link card starts 20dp under the header (16dp before) and the sheet ends 20dp under Manage Invites (16dp before). The header is still the shared AppBarWithTitle, so the gap is measured from the bottom of the bar. --- .../app/messenger/internal/invites/InviteHostSheet.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheet.kt index 8995941cd2..cd7cd95d58 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 @@ -73,9 +73,8 @@ internal fun InviteHostSheetContent( modifier = Modifier .fillMaxWidth() .padding(horizontal = CodeTheme.dimens.inset) - // The top keeps the link card clear of the header and its close button. - .padding(vertical = CodeTheme.dimens.grid.x4), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + .padding(top = HeaderToCardGap, bottom = SheetBottomPadding), + verticalArrangement = Arrangement.spacedBy(CardGap), ) { LinkCard(state = state, onShare = onShare) SheetRow( @@ -107,6 +106,12 @@ internal fun InviteHostSheetContent( } } +// 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) From 4b161d11b342e7cc7911d8099c9fe9d38fe29fe9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 17:33:24 -0400 Subject: [PATCH 12/12] fix(invites): keep Approve New Members off after 1e's Share Group Link 1e's Share Group Link turned the switch on, from the host sheet and from the Invite People sheet. It now shares the plain /chat link once and leaves the switch as it was. Only the host's tap on the switch turns it on (spec #82, 02f6c77). InviteLinkViewModel no longer writes the switch, so it drops its ApproveNewMembersStore. InviteHostSheetViewModelTest covers 1e leaving the switch off and the host's tap turning it on. --- .../invites/InviteHostSheetViewModel.kt | 9 +- .../internal/invites/InviteLinkViewModel.kt | 10 +- .../invites/InviteHostSheetViewModelTest.kt | 96 +++++++++++++++++++ .../invites/InviteLinkViewModelTest.kt | 25 +---- 4 files changed, 102 insertions(+), 38 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostSheetViewModelTest.kt 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 581b015696..436feb38e7 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 @@ -161,14 +161,11 @@ internal class InviteHostSheetViewModel @Inject constructor( } InviteCreation.NotAvailable -> { _state.update { it.copy(invite = InviteLinkState.None) } - // 1e (Open 10): its button turns Approve new members on, so the host isn't sent - // back here on the next open, and shares the plain link it now shows. + // 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 = { - onApproveChanged(true) - _effects.trySend(Effect.ShareJoin) - }, + onShareJoinLink = { _effects.trySend(Effect.ShareJoin) }, ) null } 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 27a15e963e..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,7 +8,6 @@ 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.ApproveNewMembersStore import com.flipcash.shared.chat.GroupCardLinks import com.flipcash.shared.chat.HostInvite import com.flipcash.shared.chat.HostInvites @@ -69,7 +68,6 @@ internal class InviteLinkViewModel @Inject constructor( private val clipboardManager: ClipboardManager, private val analytics: FlipcashAnalytics, private val cardLinks: GroupCardLinks, - private val approveStore: ApproveNewMembersStore, ) : ViewModel() { enum class Action { Share, Copy, Send } @@ -198,12 +196,8 @@ internal class InviteLinkViewModel @Inject constructor( _state.update { it.copy(link = InviteLinkState.None) } showInviteLinksNotAvailable( resources, - onShareJoinLink = { - // Open 10: the host who hits this turns Approve new members on, so the - // host sheet shows the plain link next time rather than sending them back here. - viewModelScope.launch { approveStore.set(chatId, true) } - _effects.trySend(Effect.OpenJoinLink) - }, + // 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/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>>(emptyMap()) + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + every { hostInvites.current(chatId) } returns null + } + + @After + fun tearDown() { + BottomBarManager.clear() + Dispatchers.resetMain() + } + + private fun viewModel() = + InviteHostSheetViewModel(hostInvites, approveStore, resources, RecordingAnalytics()) + + @Test + fun `1e shares the plain link and leaves Approve New Members off`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.NotAvailable + val model = viewModel() + model.open(chatId) + + model.onShare() + BottomBarManager.messages.value.single().actions.first().onClick() + + assertEquals(InviteHostSheetViewModel.Effect.ShareJoin, model.effects.first()) + assertFalse(model.state.value.approve) + assertTrue("nothing turned the switch on: $writes", writes.none { it }) + } + + @Test + fun `the host's tap on the switch turns it on`() = runTest { + val model = viewModel() + model.open(chatId) + + model.onApproveChanged(true) + + assertTrue(model.state.value.approve) + assertEquals(listOf(true), writes) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt index adb3966693..68c6972416 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt @@ -11,7 +11,6 @@ import com.flipcash.services.models.GetChatInvitesError 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.GroupCardLinks import com.flipcash.shared.chat.HostInvite import com.flipcash.shared.chat.HostInvites @@ -28,9 +27,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -80,16 +77,8 @@ class InviteLinkViewModelTest { private val analytics = RecordingAnalytics() - private val approved = mutableSetOf() - private val approveStore = object : ApproveNewMembersStore { - override fun observe(chatId: ChatId): Flow = flowOf(chatId in approved) - override suspend fun set(chatId: ChatId, on: Boolean) { - if (on) approved += chatId else approved -= chatId - } - } - private fun viewModel() = - InviteLinkViewModel(hostInvites, resources, clipboard, analytics, GroupCardLinks(), approveStore) + InviteLinkViewModel(hostInvites, resources, clipboard, analytics, GroupCardLinks()) private var seed = 1 private fun hostInvite(url: String?, useCount: Long = 0, maxUses: Long = 0): HostInvite { @@ -211,18 +200,6 @@ class InviteLinkViewModelTest { assertEquals(InviteLinkState.None, model.state.value.link) } - @Test - fun `not available turns Approve new members on and opens the plain link`() = runTest { - coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.NotAvailable - val model = viewModel() - model.open(chatId, null) - - BottomBarManager.messages.value.single().actions.first().onClick() - - assertEquals(InviteLinkViewModel.Effect.OpenJoinLink, model.effects.first()) - assertTrue(chatId in approved) - } - @Test fun `any other failure shows the generic error and closes behind it`() = runTest { coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.Failed(IllegalStateException())