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