diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index ef38496c65..917cada750 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -28,6 +28,19 @@ data object ChatSendResult : Parcelable @Serializable data class GroupInviteResult(val chatId: ChatId) : Parcelable +/** What the host picked on Manage Invites' row sheet (frame 2b). */ +@Parcelize +@Serializable +enum class InviteLinkAction : Parcelable { Share, Revoke } + +/** + * 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. + */ +@Parcelize +@Serializable +data class NewInviteLinkChoice(val expiry: String, val uses: String) : Parcelable + /** A custom paid-reaction amount, in whole US cents. */ @Parcelize @Serializable @@ -87,6 +100,55 @@ sealed interface ChatStep : FlowStep, Parcelable { Sheet, com.getcode.navigation.FullscreenSheet + /** + * Frames 1b, 1f: a private group's invite link, in the share-to-chats sheet. [inviteIdHex] null + * is the sheet's current link, which the first Share or Copy tap makes when there is none; + * otherwise it is one invite from Manage Invites (2b's Share Link). + * + * Carries the invite's id and never its link: a step is logged and saved, and the link's + * fragment is the secret. The sheet rebuilds the link from the host's own invites. + */ + @Parcelize + @Serializable + data class ShareInviteLink(val inviteIdHex: String? = null) : + ChatStep, + NavigationRetVal, + Sheet, + com.getcode.navigation.FullscreenSheet + + /** + * 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 + * confirm (2c). + * + * Carries the invite's id and never its link, as [ShareInviteLink] does. + */ + @Parcelize + @Serializable + data class InviteLinkActions(val inviteIdHex: String) : + ChatStep, + NavigationRetVal, + Sheet, + WrapContentSheet + + /** + * Frame 2d: expiry and use-limit presets for a new invite link. Returns the chosen + * [NewInviteLinkChoice] and closes; Manage Invites, still beneath it, makes the link and shows + * the creating state. The result is presets only, never a key or link. + */ + @Parcelize + @Serializable + data object NewInviteLink : + ChatStep, + NavigationRetVal, + Sheet, + WrapContentSheet + + /** Frame 2a: the invite links this host has made, Active then Inactive. Pushed, like [EditGroup]. */ + @Parcelize + @Serializable + data object ManageInvites : ChatStep + /** * Sharing one message into the viewer's other chats — [InviteToGroup]'s sheet with the * message's link as what it hands out. Returns the chat picked first, as that one does. diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/Clipboard.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/Clipboard.kt index be9224729b..d73b069e92 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/Clipboard.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/extensions/Clipboard.kt @@ -1,8 +1,30 @@ package com.flipcash.app.core.extensions import android.content.ClipData +import android.content.ClipDescription import android.content.ClipboardManager +import android.os.Build +import android.os.PersistableBundle fun ClipboardManager.setText(text: CharSequence, label: String) { setPrimaryClip(ClipData.newPlainText(label, text)) -} \ No newline at end of file +} + +/** + * Copies [text] and marks the clip sensitive, so Android 13+ hides it from the clipboard preview + * and older Android keeps it out of keyboard suggestions that honour the flag. For a value that + * is a secret, such as an invite link, which is as good as the chat key. + */ +fun ClipboardManager.setSensitiveText(text: CharSequence, label: String) { + val clip = ClipData.newPlainText(label, text) + clip.description.extras = PersistableBundle().apply { putBoolean(SensitiveClipKey, true) } + setPrimaryClip(clip) +} + +/** [ClipDescription.EXTRA_IS_SENSITIVE], which the platform only declares from API 33. */ +val SensitiveClipKey: String = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ClipDescription.EXTRA_IS_SENSITIVE + } else { + "android.content.extra.IS_SENSITIVE" + } diff --git a/apps/flipcash/core/src/main/res/drawable/ic_link_outlined.xml b/apps/flipcash/core/src/main/res/drawable/ic_link_outlined.xml new file mode 100644 index 0000000000..454ca6294a --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_link_outlined.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 01880b39b8..240941398b 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1479,6 +1479,63 @@ Invite People Share Recent Chats + + + 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… + 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 + + Created %1$s + Expires %1$s + Never expires + %1$d joined + %1$d of %2$d joined + Can\'t share from this device + Expired %1$s + Revoked + Reached its limit + No longer available + Current + Invite Links + Invite Link + ACTIVE + INACTIVE + New Link + No invite links + Make a link to let people join without waiting for approval. + Revoke Link + Revoke this link? + No one new can join with it. People who already joined stay in the group + New Invite Link + Expires + Who can join + 1 day + 7 days + 30 days + Never + 1 + 5 + 10 + 25 + No limit + Create Link + Too Many Active Links + Revoke a link in Manage Invites to make another + Invite Links Aren\'t Available Yet + You can still share the group link. People who open it can ask to join, and you approve them + Share Group Link + Couldn\'t Create Link + Something went wrong. Please try again + Couldn\'t Revoke Link + Couldn\'t Load Invite Links + Something went wrong. Please try again. Send to Chats Suggested 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 e1e5f6062b..ad25d04d63 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 @@ -38,6 +38,14 @@ import com.flipcash.app.core.util.Linkify import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.GroupInviteViewModel +import com.flipcash.app.core.chat.InviteLinkAction +import com.flipcash.app.core.chat.NewInviteLinkChoice +import com.flipcash.app.messenger.internal.invites.InviteLinkActions +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.ManageInvitesScreen import com.flipcash.app.messenger.internal.screens.EmojiPickerViewModel import com.flipcash.app.messenger.internal.screens.GroupInviteSheet import com.flipcash.app.messenger.internal.screens.MessengerScreen @@ -45,6 +53,7 @@ import com.flipcash.app.messenger.internal.screens.ReactionPickerSheet 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.request.ChatRequestsViewModel import com.flipcash.app.messenger.internal.request.RequestDetailsScreen import com.flipcash.app.messenger.internal.request.RequestMoneyScreen @@ -170,6 +179,18 @@ private fun chatEntryProvider( annotatedEntry { FlowGroupInviteSheet() } + annotatedEntry { step -> + FlowInviteLinkSheet(step.inviteIdHex) + } + annotatedEntry { + FlowNewInviteLinkSheet() + } + annotatedEntry { step -> + FlowInviteLinkActionsSheet(step.inviteIdHex) + } + annotatedEntry { + ManageInvitesScreen(flowSharedViewModel().stateFlow.collectAsStateWithLifecycle().value.chatId) + } annotatedEntry { step -> FlowShareMessageSheet(step.messageId) } @@ -514,6 +535,109 @@ private fun FlowGroupInviteSheet() { ) } +/** + * [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 + * join variant, limited to chats an invite link may be sent into. + */ +@Composable +private fun FlowInviteLinkSheet(inviteIdHex: String?) { + val viewModel = flowSharedViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val inviteViewModel = hiltViewModel() + val inviteState by inviteViewModel.state.collectAsStateWithLifecycle() + val linkViewModel = hiltViewModel() + val link by linkViewModel.state.collectAsStateWithLifecycle() + val dismissSheet = LocalBottomSheetDismissDispatcher.current + val keyboard = rememberKeyboardController() + val navigator = LocalCodeNavigator.current + val resultBack = resultBackNavigator(exit = dismissSheet) + val flowNavigator = rememberFlowNavigator() + + LaunchedEffect(inviteViewModel, state.chatId) { + state.chatId?.let(inviteViewModel::exclude) + inviteViewModel.restrictToEncrypted(true) + } + LaunchedEffect(linkViewModel, state.chatId, inviteIdHex) { + state.chatId?.let { linkViewModel.open(it, inviteIdHex) } + } + LaunchedEffect(inviteViewModel) { + inviteViewModel.invited.collect { chatId -> + keyboard.hideIfVisible { resultBack.returnValue(GroupInviteResult(chatId)) } + } + } + + GroupInviteSheet( + inviteUrl = null, + subject = ShareToChatsSubject.GroupInviteLink(state.subject as? ChatSubject.Group, link.link), + state = inviteState, + onShare = linkViewModel::onShare, + onCopy = linkViewModel::onCopy, + onToggle = inviteViewModel::toggle, + onMessageChanged = inviteViewModel::onMessageChanged, + onInvite = linkViewModel::onSend, + onDismiss = dismissSheet, + inviteEffects = linkViewModel.effects, + onInviteEffect = { effect -> + when (effect) { + is InviteLinkViewModel.Effect.Send -> inviteViewModel.invite(effect.url) + // 1e's offer: the plain join link, in place of this sheet. + InviteLinkViewModel.Effect.OpenJoinLink -> { + dismissSheet() + navigator.openGroupInvite() + } + // 1c's offer: revoke a link there to make room for another. + InviteLinkViewModel.Effect.OpenManageInvites -> { + dismissSheet() + flowNavigator.navigateTo(ChatStep.ManageInvites) + } + InviteLinkViewModel.Effect.Close -> dismissSheet() + else -> Unit + } + }, + ) +} + +/** + * [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. + * An invite that is gone by the time the sheet draws closes it, as a missing share link does. + */ +@Composable +private fun FlowInviteLinkActionsSheet(inviteIdHex: String) { + val chatId = flowSharedViewModel().stateFlow.collectAsStateWithLifecycle().value.chatId + val viewModel = hiltViewModel() + val dismissSheet = LocalBottomSheetDismissDispatcher.current + val resultBack = resultBackNavigator(exit = dismissSheet) + val invite = chatId?.let { viewModel.invite(it, inviteIdHex) } + + LaunchedEffect(invite == null && chatId != null) { + if (invite == null && chatId != null) dismissSheet() + } + invite ?: return + + InviteLinkActions( + invite = invite, + onShare = { resultBack.returnValue(InviteLinkAction.Share) }, + onRevoke = { resultBack.returnValue(InviteLinkAction.Revoke) }, + onDismiss = dismissSheet, + ) +} + +/** + * [ChatStep.NewInviteLink]: frame 2d on the app's sheet scene. It returns the chosen settings and + * closes; Manage Invites, still beneath it, makes the link and shows the creating state. + */ +@Composable +private fun FlowNewInviteLinkSheet() { + val dismissSheet = LocalBottomSheetDismissDispatcher.current + val resultBack = resultBackNavigator(exit = dismissSheet) + NewInviteLinkContent( + onCreate = { resultBack.returnValue(it.toChoice()) }, + onDismiss = dismissSheet, + ) +} + /** * [ChatStep.ShareMessage]: the invite sheet handing out one message's link. Same send path and * result as [FlowGroupInviteSheet]; the link is the whole subject. 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 663bb5c4c6..659f59f1cf 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 @@ -8,6 +8,7 @@ import com.flipcash.app.bills.share.TipCodePreviewCache import com.flipcash.app.core.extensions.setText import com.flipcash.app.core.share.TipCodePreview import com.flipcash.features.messenger.R +import com.flipcash.services.chat.E2eePolicy import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator @@ -49,6 +50,7 @@ internal class GroupInviteViewModel @Inject constructor( private val resources: ResourceHelper, private val clipboardManager: ClipboardManager, private val tipCodePreviewCache: TipCodePreviewCache, + private val e2eePolicy: E2eePolicy, ) : ViewModel() { data class State( @@ -63,6 +65,13 @@ internal class GroupInviteViewModel @Inject constructor( val selection: List = emptyList(), val message: String = "", val sending: Boolean = false, + /** The recent chats that are private groups or end-to-end encrypted DMs. */ + val encryptedChats: Set = emptySet(), + /** + * An invite link's secret may only go into a chat that is encrypted, so the invite variant + * of the sheet offers just those (spec surface 1). + */ + val encryptedOnly: Boolean = false, ) { /** The message bar only comes up once there is someone to send to (node 10330:19549). */ val showsComposer: Boolean get() = selection.isNotEmpty() @@ -72,7 +81,9 @@ internal class GroupInviteViewModel @Inject constructor( * sending a person their own profile is pointless. */ val invitable: List? - get() = recentChats?.filterNot { it.chatId == excludedChatId } + get() = recentChats?.filterNot { + it.chatId == excludedChatId || (encryptedOnly && it.chatId !in encryptedChats) + } } private val _state = MutableStateFlow(State()) @@ -94,7 +105,10 @@ internal class GroupInviteViewModel @Inject constructor( val summaries = chatCoordinator.chatListFeed().first() val selfId = userManager.accountId val chats = summaries.map { it.toConversationReference(selfId, emptyMap(), resources, exchange = exchange) } - _state.update { it.copy(recentChats = chats) } + val encrypted = summaries + .filter { it.metadata.isPrivate || e2eePolicy.shouldEncrypt(it.metadata) } + .mapTo(mutableSetOf()) { it.metadata.chatId } + _state.update { it.copy(recentChats = chats, encryptedChats = encrypted) } } } @@ -105,6 +119,16 @@ internal class GroupInviteViewModel @Inject constructor( } } + /** Limits Recent Chats to private groups and encrypted DMs, for an invite link. */ + fun restrictToEncrypted(encryptedOnly: Boolean) { + _state.update { state -> + state.copy( + encryptedOnly = encryptedOnly, + selection = if (encryptedOnly) state.selection.filter { it in state.encryptedChats } else state.selection, + ) + } + } + /** The rendered share-sheet card for [userId], if one is ready. Null shares the link alone. */ fun previewFor(userId: ID): TipCodePreview? = tipCodePreviewCache.get(userId) 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 new file mode 100644 index 0000000000..4909d1fee1 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteHostViewModel.kt @@ -0,0 +1,41 @@ +package com.flipcash.app.messenger.internal.invites + +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). + * + * 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. */ + val canInvite: StateFlow> = hostInvites.canInvite + + /** Asks the server whether the viewer can invite to [chatId]. A failure keeps the last answer. */ + 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 new file mode 100644 index 0000000000..532cb4e030 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabels.kt @@ -0,0 +1,89 @@ +package com.flipcash.app.messenger.internal.invites + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.Invite +import com.flipcash.shared.chat.HostInvite +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.time.Instant + +/** "Nov 3": a link's dates are read as days, so the year and time are left out. */ +internal fun shortDate( + at: Instant, + zone: ZoneId = ZoneId.systemDefault(), + locale: Locale = Locale.getDefault(), +): String = DateTimeFormatter.ofPattern("MMM d", locale) + .withZone(zone) + .format(java.time.Instant.ofEpochMilli(at.toEpochMilliseconds())) + +/** How many have joined through a link: "12 joined" for no limit, "3 of 10 joined" for a cap. */ +internal data class JoinedCount(val useCount: Long, val maxUses: Long) { + val isCapped: Boolean get() = maxUses > 0 +} + +/** What a link's line says about when it ends, or why it can't be used. */ +internal sealed interface LinkStatus { + data class Expires(val at: Instant) : LinkStatus + data object NeverExpires : LinkStatus + data class Expired(val at: Instant?) : LinkStatus + data object Revoked : LinkStatus + data object ReachedLimit : LinkStatus + + /** A status this build doesn't know. Listed as inactive, so it says so rather than promising an expiry. */ + data object Unavailable : LinkStatus +} + +internal fun Invite.linkStatus(): LinkStatus = when (status) { + Invite.Status.ACTIVE -> expiresAt?.let(LinkStatus::Expires) ?: LinkStatus.NeverExpires + Invite.Status.UNKNOWN -> LinkStatus.Unavailable + Invite.Status.EXPIRED -> LinkStatus.Expired(expiresAt) + Invite.Status.REVOKED -> LinkStatus.Revoked + Invite.Status.EXHAUSTED -> LinkStatus.ReachedLimit +} + +@Composable +internal fun JoinedCount.label(): String = + if (isCapped) { + stringResource(R.string.label_inviteJoinedOf, useCount.toInt(), maxUses.toInt()) + } else { + stringResource(R.string.label_inviteJoined, useCount.toInt()) + } + +@Composable +internal fun LinkStatus.label(): String = when (this) { + is LinkStatus.Expires -> stringResource(R.string.label_inviteExpires, shortDate(at)) + LinkStatus.NeverExpires -> stringResource(R.string.label_inviteNeverExpires) + is LinkStatus.Expired -> at?.let { stringResource(R.string.label_inviteExpired, shortDate(it)) } + ?: stringResource(R.string.label_inviteExpired, "") + LinkStatus.Revoked -> stringResource(R.string.label_inviteRevoked) + LinkStatus.ReachedLimit -> stringResource(R.string.label_inviteReachedLimit) + LinkStatus.Unavailable -> stringResource(R.string.label_inviteUnavailable) +} + +/** The invite sheet's line under the link: "Expires Nov 3 · 0 joined". */ +@Composable +internal fun inviteMetaLine(expiresAt: Instant?, useCount: Long, maxUses: Long): String { + val ends = (expiresAt?.let(LinkStatus::Expires) ?: LinkStatus.NeverExpires).label() + return "$ends · ${JoinedCount(useCount, maxUses).label()}" +} + +/** + * Manage Invites' second line. An active link says how many joined, then when it ends (or why it + * can't be shared). An inactive one shows only its status and dates (spec surface 2). + */ +@Composable +internal fun HostInvite.detailLine(): String { + if (!isActive) return invite.linkStatus().label() + val joined = JoinedCount(invite.useCount, invite.maxUses).label() + val second = if (canShare) invite.linkStatus().label() else stringResource(R.string.label_inviteCantShare) + return "$joined · $second" +} + +/** 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://") + return if (bare.length <= head + tail + 1) bare else bare.take(head) + "…" + bare.takeLast(tail) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkActionsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkActionsViewModel.kt new file mode 100644 index 0000000000..74cbb9104d --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkActionsViewModel.kt @@ -0,0 +1,21 @@ +package com.flipcash.app.messenger.internal.invites + +import androidx.lifecycle.ViewModel +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.HostInvite +import com.flipcash.shared.chat.HostInvites +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * Frame 2b's lookup: the invite a row sheet was opened on, from the invites Manage Invites has + * already loaded. The step carries only the invite's id, so the sheet finds the rest here. + */ +@HiltViewModel +internal class InviteLinkActionsViewModel @Inject constructor( + private val hostInvites: HostInvites, +) : ViewModel() { + + fun invite(chatId: ChatId, inviteIdHex: String): HostInvite? = + hostInvites.state.value[chatId].orEmpty().firstOrNull { it.invite.id.toHex() == inviteIdHex } +} 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 new file mode 100644 index 0000000000..0232384f00 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModel.kt @@ -0,0 +1,230 @@ +package com.flipcash.app.messenger.internal.invites + +import android.content.ClipboardManager +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.extensions.setSensitiveText +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId +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 +import kotlin.time.Instant + +/** The invite link the share sheet is showing (frames 1b and 1f). */ +internal sealed interface InviteLinkState { + /** Fetching the invite the sheet was opened on. */ + data object Loading : InviteLinkState + + /** Nothing to show: the sheet is about to close. */ + data object None : InviteLinkState + + /** Frame 1b: the tap on Share Invite Link is making the link. */ + data object Creating : InviteLinkState + + /** + * Frame 1f. The link is as good as the chat key, so [toString] leaves it out and nothing + * here ever logs it. + */ + class Ready( + val url: String, + val expiresAt: Instant?, + val useCount: Long, + val maxUses: Long, + ) : InviteLinkState { + override fun toString(): String = "Ready(useCount=$useCount, maxUses=$maxUses)" + } +} + +/** + * Backs the invite variant of the share-to-chats sheet: the host's current link, or one invite + * picked in Manage Invites. The sheet opens from the tap on Share Invite Link, so opening it + * with no current link makes one (spec Decision 9, Open 9); opening it for a specific invite + * never creates. + * + * What happens after a tap is an [Effect] for the sheet to carry out, because sharing needs the + * share controller and the group's picture, which only the composition has. + */ +@HiltViewModel +internal class InviteLinkViewModel @Inject constructor( + private val hostInvites: HostInvites, + private val resources: ResourceHelper, + private val clipboardManager: ClipboardManager, +) : ViewModel() { + + enum class Action { Share, Copy, Send } + + sealed interface Effect { + /** Hand [url] to the system share sheet. */ + class Share(val url: String) : Effect { + override fun toString(): String = "Share" + } + + /** Send [url] to the chats picked in Recent Chats. */ + class Send(val url: String) : Effect { + override fun toString(): String = "Send" + } + + /** The link is on the clipboard. */ + data object Copied : Effect + + /** Frame 1e's offer: switch to the plain join link. */ + data object OpenJoinLink : Effect + + /** Frame 1c's offer: the host revokes a link in Manage Invites. */ + data object OpenManageInvites : Effect + + /** There is nothing to show, or the error that said so is done with; go back. */ + data object Close : Effect + } + + data class State(val link: InviteLinkState = InviteLinkState.Loading) + + 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 specific: String? = null + private var job: Job? = null + + /** + * 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. + */ + fun open(chatId: ChatId, inviteIdHex: String?) { + if (this.chatId == chatId && specific == inviteIdHex) return + this.chatId = chatId + specific = inviteIdHex + job?.cancel() + val known = if (inviteIdHex == null) hostInvites.current(chatId)?.toState() else null + _state.update { + State( + link = known + // No current link: the tap on the row made this sheet to make one. + ?: if (inviteIdHex == null) InviteLinkState.Creating else InviteLinkState.Loading, + ) + } + job = viewModelScope.launch { load(chatId, inviteIdHex) } + } + + private suspend fun load(chatId: ChatId, inviteIdHex: String?) { + val loaded = hostInvites.load(chatId) + loaded.exceptionOrNull()?.let { error -> + if (error.isLostInviteAccess()) { + // The viewer is no longer allowed to invite: back to the profile, as Manage Invites does. + hostInvites.revokeAccess(chatId) + _effects.send(Effect.Close) + return + } + } + if (inviteIdHex != null) { + val picked = hostInvites.state.value[chatId].orEmpty().firstOrNull { it.invite.id.toHex() == inviteIdHex } + val link = picked?.toState() + if (link is InviteLinkState.Ready) { + _state.update { it.copy(link = link) } + } else { + _state.update { it.copy(link = InviteLinkState.None) } + _effects.send(Effect.Close) + } + return + } + if (loaded.isFailure) { + // A load that failed could be hiding a link that exists; make none, or this would add a second. + _state.update { it.copy(link = InviteLinkState.None) } + showInviteLinksNotLoaded(resources) { _effects.trySend(Effect.Close) } + return + } + val current = hostInvites.current(chatId)?.toState() + if (current != null) { + _state.update { it.copy(link = current) } + return + } + _state.update { it.copy(link = InviteLinkState.Creating) } + create(chatId) + } + + private suspend fun create(chatId: ChatId) { + when (val result = hostInvites.currentOrCreate(chatId)) { + is InviteCreation.Created -> { + val link = result.invite.toState() + _state.update { it.copy(link = link) } + if (link !is InviteLinkState.Ready) failed() + } + InviteCreation.TooMany -> { + _state.update { it.copy(link = InviteLinkState.None) } + showTooManyInviteLinks( + resources, + onManageInvites = { + _effects.trySend(Effect.OpenManageInvites) + }, + onCancel = { _effects.trySend(Effect.Close) }, + ) + } + InviteCreation.NotAvailable -> { + _state.update { it.copy(link = InviteLinkState.None) } + showInviteLinksNotAvailable( + resources, + onShareJoinLink = { _effects.trySend(Effect.OpenJoinLink) }, + onCancel = { _effects.trySend(Effect.Close) }, + ) + } + is InviteCreation.Failed -> failed() + } + } + + private fun failed() { + _state.update { it.copy(link = InviteLinkState.None) } + showInviteLinkNotCreated(resources) { _effects.trySend(Effect.Close) } + } + + fun onShare() = act(Action.Share) + + fun onCopy() = act(Action.Copy) + + fun onSend() = act(Action.Send) + + // The tiles are disabled until the link is ready, so a tap that gets here without one is a + // stale gesture; it has nothing to act on. + private fun act(action: Action) { + val link = _state.value.link as? InviteLinkState.Ready ?: return + perform(action, link.url) + } + + private fun perform(action: Action, url: String) { + when (action) { + Action.Share -> _effects.trySend(Effect.Share(url)) + Action.Send -> _effects.trySend(Effect.Send(url)) + Action.Copy -> { + clipboardManager.setSensitiveText( + text = url, + label = resources.getString(R.string.title_clipboardLabelGroupInviteLink), + ) + _effects.trySend(Effect.Copied) + } + } + } + + private fun HostInvite.toState(): InviteLinkState = + url?.let { InviteLinkState.Ready(it, invite.expiresAt, invite.useCount, invite.maxUses) } + ?: InviteLinkState.None +} + +/** An invite's id as hex: what a nav step carries in place of the link. The id is not the secret. */ +internal fun com.flipcash.services.models.chat.InviteId.toHex(): String = + bytes.joinToString("") { "%02x".format(it) } 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 new file mode 100644 index 0000000000..d50a5262ba --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InviteMessages.kt @@ -0,0 +1,123 @@ +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 +import com.getcode.util.resources.ResourceHelper + +/** + * The bottom-bar messages of the invite flow. They are top-level so the view models and the + * 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 + * inside Manage Invites itself. [onCancel] runs when the message is dismissed without a button. + */ +internal fun showTooManyInviteLinks( + resources: ResourceHelper, + onManageInvites: (() -> Unit)? = null, + onCancel: () -> Unit = {}, +) = BottomBarManager.showAlert( + title = resources.getString(R.string.title_tooManyInviteLinks), + message = resources.getString(R.string.description_tooManyInviteLinks), + actions = if (onManageInvites != null) { + listOf(BottomBarAction(text = resources.getString(R.string.action_manageInvites), onClick = onManageInvites)) + } else { + listOf(BottomBarAction.Ok) + }, + showCancel = onManageInvites != null, + onDismiss = { fromAction -> if (!fromAction) onCancel() }, +) + +/** The generic "Couldn't Create Link". [onDismiss] runs once it is closed, however. */ +internal fun showInviteLinkNotCreated(resources: ResourceHelper, onDismiss: () -> Unit = {}) = + BottomBarManager.showError( + title = resources.getString(R.string.title_inviteLinkNotCreated), + message = resources.getString(R.string.description_inviteLinkNotCreated), + onDismiss = { onDismiss() }, + ) + +/** "Couldn't Load Invite Links". [onDismiss] runs once it is closed, however. */ +internal fun showInviteLinksNotLoaded(resources: ResourceHelper, onDismiss: () -> Unit = {}) = + BottomBarManager.showError( + title = resources.getString(R.string.title_inviteLinksNotLoaded), + message = resources.getString(R.string.description_inviteLinkNotCreated), + onDismiss = { onDismiss() }, + ) + +/** + * Frame 1e: invite links are not available to this host yet; [onShareJoinLink], when given, offers + * the plain link. [onCancel] runs when the message is dismissed without taking the offer. + */ +internal fun showInviteLinksNotAvailable( + resources: ResourceHelper, + onShareJoinLink: (() -> Unit)? = null, + onCancel: () -> Unit = {}, +) = BottomBarManager.showMessage( + title = resources.getString(R.string.title_inviteLinksNotAvailable), + message = resources.getString(R.string.description_inviteLinksNotAvailable), + actions = if (onShareJoinLink != null) { + listOf( + BottomBarAction( + text = resources.getString(R.string.action_shareGroupLink), + onClick = onShareJoinLink, + ), + ) + } else { + listOf(BottomBarAction.Ok) + }, + showCancel = onShareJoinLink != null, + onDismiss = { if (it.index == -1) onCancel() }, +) + +/** Frame 2c: revoking cannot be undone. */ +internal fun showRevokeInviteLink(resources: ResourceHelper, onRevoke: () -> Unit) = BottomBarManager.showAlert( + title = resources.getString(R.string.title_revokeInviteLink), + message = resources.getString(R.string.description_revokeInviteLink), + actions = listOf( + BottomBarAction(text = resources.getString(R.string.action_revokeLink), onClick = onRevoke), + ), + showCancel = true, +) 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 new file mode 100644 index 0000000000..0285f6e0cd --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/InvitePreviews.kt @@ -0,0 +1,242 @@ +package com.flipcash.app.messenger.internal.invites + +import android.content.Context +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.util.DisplayMetrics +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.GroupInviteViewModel +import com.flipcash.app.messenger.internal.screens.GroupInviteSheet +import com.flipcash.app.messenger.internal.screens.ShareToChatsSubject +import com.flipcash.app.messenger.internal.screens.profile.NoJoinRequests +import com.flipcash.app.theme.FlipcashThemeWrapper +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.HostInvite +import com.flipcash.shared.chat.InviteExpiry +import com.flipcash.shared.chat.InviteSettings +import com.flipcash.shared.chat.InviteUses +import com.flipcash.shared.chat.ui.ConversationReference +import com.getcode.manager.BottomBarManager +import com.getcode.ui.components.bars.BottomBarView +import com.getcode.util.resources.ResourceHelper +import com.getcode.util.resources.ResourceType +import java.io.File +import kotlin.time.Instant + +/* + * One preview per frame state of the invite flow. They are internal and parameterless so the + * screenshot test draws the same composables. + */ + +private val PreviewChat = ChatId(ByteArray(32) { 3 }) +private val Group = ChatSubject.Group( + chatId = PreviewChat, + groupTitle = "Bad Boys", + picture = null, + memberCount = 412L, + rules = null, + isMember = true, +) +private const val Url = "https://app.flipcash.com/chat/invite#s=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEf" +private val Expiry = Instant.fromEpochSeconds(1_794_000_000) + +private val RecentChats = listOf( + ConversationReference(chatId = ChatId(ByteArray(32) { 1 }), title = "Bad Boys 2", isGroup = true), + ConversationReference(chatId = ChatId(ByteArray(32) { 2 }), displayName = "Dana"), + ConversationReference(chatId = ChatId(ByteArray(32) { 4 }), title = "Coffee Crew", isGroup = true), +) + +private fun invite( + n: Int, + status: Invite.Status = Invite.Status.ACTIVE, + useCount: Long = 0, + maxUses: Long = 0, + expiresAt: Instant? = Expiry, + url: String? = Url, +) = HostInvite( + Invite( + id = InviteId(ByteArray(32) { n.toByte() }), + chatId = PreviewChat, + createdBy = List(32) { 1 }, + maxUses = maxUses, + useCount = useCount, + createdAt = Instant.fromEpochSeconds(1_790_000_000L - n * 86_400L), + expiresAt = expiresAt, + status = status, + secretEnvelope = null, + ), + url, +) + +private val Active = invite(1, useCount = 3, maxUses = 10) +private val Inactive = listOf( + invite(2, status = Invite.Status.EXPIRED), + invite(3, status = Invite.Status.REVOKED, expiresAt = null), +) + +@Composable +private fun rememberPreviewResources(): ResourceHelper { + val context = LocalContext.current + return remember(context) { PreviewResources(context) } +} + +/** Draws whatever [show] puts on the bottom bar, as the app's host does. */ +@Composable +private fun BottomBarFrame(show: (ResourceHelper) -> Unit) { + val resources = rememberPreviewResources() + val message = remember { + BottomBarManager.clear() + show(resources) + BottomBarManager.messages.value.last().also { BottomBarManager.clear() } + } + BottomBarView(bottomBarMessage = message, onShown = {}, onClose = {}, onBackPressed = {}) +} + +@Composable +private fun InviteSheetFrame(subject: ShareToChatsSubject, selection: List = emptyList()) { + GroupInviteSheet( + inviteUrl = if (subject is ShareToChatsSubject.Group) Url.removePrefix("https://app.flipcash.com/chat/invite#s=") else null, + subject = subject, + state = GroupInviteViewModel.State( + recentChats = RecentChats, + selection = selection, + encryptedOnly = subject is ShareToChatsSubject.GroupInviteLink, + encryptedChats = RecentChats.map { it.chatId }.toSet(), + ), + onShare = {}, + onCopy = {}, + onToggle = {}, + onMessageChanged = {}, + onInvite = {}, + onDismiss = {}, + onShowCard = if (subject is ShareToChatsSubject.Group) ({}) else null, + ) +} + +/** Frame 1a, node 12844:60. */ +@Preview(heightDp = 520) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame1aHostSheet() = BottomBarFrame { + showInviteHostSheet(it, "Bad Boys", {}, {}, {}) +} + +/** Frame 1b, node 12933:210: the first tap is making the link. */ +@Preview(heightDp = 817) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame1bCreating() = + InviteSheetFrame(ShareToChatsSubject.GroupInviteLink(Group, InviteLinkState.Creating)) + +/** Frame 1c, node 12844:161. */ +@Preview(heightDp = 400) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame1cTooMany() = BottomBarFrame { showTooManyInviteLinks(it) } + +/** Frame 1d, node 12844:185: Join Requests' empty state for a viewer who can invite. */ +@Preview(heightDp = 600) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame1dJoinRequestsEmpty() = + NoJoinRequests(groupName = "Bad Boys", canInvite = true, onShare = {}) + +/** Frame 1e, node 12855:117. */ +@Preview(heightDp = 400) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame1eNotAvailable() = BottomBarFrame { showInviteLinksNotAvailable(it) {} } + +/** Frame 1f, node 12933:113: the invite variant with a link. */ +@Preview(heightDp = 817) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame1fReady() = InviteSheetFrame( + ShareToChatsSubject.GroupInviteLink(Group, InviteLinkState.Ready(Url, Expiry, 0, 0)), +) + +/** Frame 1g, node 12933:313: the join variant, unchanged. */ +@Preview(heightDp = 817) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame1gJoin() = InviteSheetFrame(ShareToChatsSubject.Group(Group)) + +/** Frame 2a, node 12844:200. */ +@Preview(heightDp = 817) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame2aManage() = ManageInvitesContent( + state = ManageInvitesViewModel.State( + loading = false, + active = listOf(Active, invite(4, url = null)), + inactive = Inactive, + current = Active, + ), + onBack = {}, + onRow = {}, + onNewLink = {}, +) + +/** Frame 2b, node 12886:56. */ +@Preview(heightDp = 300) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame2bRowSheet() = + InviteLinkActions(invite = Active, onShare = {}, onRevoke = {}, onDismiss = {}) + +/** Frame 2c, node 12844:288. */ +@Preview(heightDp = 400) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame2cRevoke() = BottomBarFrame { showRevokeInviteLink(it) {} } + +/** Frame 2d, node 12844:334. */ +@Preview(heightDp = 560) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame2dNewLink() = + NewInviteLinkContent(onCreate = {}, onDismiss = {}, initial = InviteSettings.Default) + +/** Frame 2d with presets other than the defaults chosen, so the chip highlight shows off its start. */ +@Preview(heightDp = 560) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame2dNewLinkChosen() = NewInviteLinkContent( + onCreate = {}, + onDismiss = {}, + initial = InviteSettings(InviteExpiry.ThirtyDays, InviteUses.Ten), +) + +/** Frame 2e, node 12844:395. */ +@Preview(heightDp = 817) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +internal fun InviteFrame2eEmpty() = ManageInvitesContent( + state = ManageInvitesViewModel.State(loading = false), + onBack = {}, + onRow = {}, + onNewLink = {}, +) + +/** Strings only: a preview has no injected resources. */ +private class PreviewResources(private val context: Context) : ResourceHelper { + override fun getString(resourceId: Int): String = context.getString(resourceId) + override fun getString(resourceId: Int, vararg formatArgs: Any): String = + context.getString(resourceId, *formatArgs) + override fun getRawResource(resourceId: Int): String = "" + override fun getQuantityString(id: Int, quantity: Int, vararg formatArgs: Any, default: String): String = default + override fun getDimension(dimenId: Int, default: Float): Float = default + override fun getDimensionPixelSize(dimenId: Int, default: Int): Int = default + override fun getDir(name: String, mode: Int): File? = null + override val displayMetrics: DisplayMetrics get() = context.resources.displayMetrics + override fun getDrawable(drawableResId: Int): Drawable? = null + override fun getIdentifier(name: String, type: ResourceType): Int? = null + override fun getFont(fontResId: Int): Typeface? = null +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt new file mode 100644 index 0000000000..64e0e57803 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesScreen.kt @@ -0,0 +1,488 @@ +package com.flipcash.app.messenger.internal.invites + +import android.os.Parcelable +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +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.automirrored.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.InviteLinkAction +import com.flipcash.app.core.chat.NewInviteLinkChoice +import com.flipcash.app.messenger.internal.screens.openInviteLinkShare +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.HostInvite +import com.flipcash.shared.chat.InviteExpiry +import com.flipcash.shared.chat.InviteSettings +import com.flipcash.shared.chat.InviteUses +import com.flipcash.shared.chat.ui.media.rememberReducedMotion +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.navigation.results.NavResultOrCanceled +import com.getcode.navigation.results.navigateForResult +import com.getcode.theme.CodeTheme +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.components.ChoiceRow +import com.getcode.ui.components.SlidingChipRow +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.ui.theme.CodeScaffold +import kotlinx.coroutines.launch + +/** + * Frames 2a to 2e: the host's invite links, Active then Inactive, with New Link and a row menu. + * A pushed step of the chat flow, reached from the invite sheet and the profile's overflow menu. + */ +@Composable +internal fun ManageInvitesScreen(chatId: ChatId?) { + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val flowNavigator = rememberFlowNavigator() + val navigator = LocalCodeNavigator.current + + LaunchedEffect(chatId) { chatId?.let(viewModel::open) } + + LaunchedEffect(viewModel) { + viewModel.effects.collect { effect -> + when (effect) { + // Back to the profile that opened this. + ManageInvitesViewModel.Effect.LostAccess -> flowNavigator.back() + } + } + } + + ManageInvitesContent( + state = state, + onBack = { flowNavigator.back() }, + onRow = { invite -> + // The row sheet is a step of its own; the pick comes back here, where the list is. + navigator.navigateForResult( + ChatStep.InviteLinkActions(invite.invite.id.toHex()) + ) { result -> + if (result !is NavResultOrCanceled.ReturnValue) return@navigateForResult + when (result.value) { + InviteLinkAction.Share -> navigator.openInviteLinkShare(invite.invite.id.toHex()) + InviteLinkAction.Revoke -> viewModel.confirmRevoke(invite) + } + } + }, + onNewLink = { + // The settings sheet is a step of its own and closes on Create Link; this screen makes + // the link and shows the creating state. + navigator.navigateForResult(ChatStep.NewInviteLink) { result -> + if (result is NavResultOrCanceled.ReturnValue) viewModel.create(result.value.toSettings()) + } + }, + onRetry = viewModel::refresh, + ) +} + +/** Frames 2a and 2e, with no navigation or view model, so the previews and tests can draw them. */ +@Composable +internal fun ManageInvitesContent( + state: ManageInvitesViewModel.State, + onBack: () -> Unit, + onRow: (HostInvite) -> Unit, + onNewLink: () -> Unit, + onRetry: () -> Unit = {}, +) { + val creating = state.creating + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_inviteLinks), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = onBack, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .testTag("manage_invites_screen"), + ) { + if (state.loading && state.active.isEmpty() && state.inactive.isEmpty()) { + // First load: not an empty list, which would read as "no links". + CodeCircularProgressIndicator( + modifier = Modifier + .align(Alignment.Center) + .testTag("manage_invites_loading"), + ) + } else if (state.failed) { + LoadFailed(modifier = Modifier.align(Alignment.Center), onRetry = onRetry) + } else if (state.isEmpty) { + EmptyInvites(modifier = Modifier.align(Alignment.Center), creating = creating, onNewLink = onNewLink) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.inset, + vertical = CodeTheme.dimens.grid.x3, + ), + ) { + if (state.active.isNotEmpty()) { + item(key = "active") { + InviteSection( + header = stringResource(R.string.header_inviteLinksActive), + invites = state.active, + current = state.current, + onRow = onRow, + ) + } + } + if (state.inactive.isNotEmpty()) { + item(key = "inactive") { + InviteSection( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x5), + header = stringResource(R.string.header_inviteLinksInactive), + invites = state.inactive, + current = null, + onRow = null, + ) + } + } + // Room for the pinned button. + item(key = "clearance") { Box(Modifier.padding(bottom = 80.dp)) } + } + CodeButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.action_newInviteLink), + isLoading = creating, + enabled = !creating, + buttonState = ButtonState.Filled, + onClick = onNewLink, + ) + } + } + } +} + +@Composable +private fun InviteSection( + header: String, + invites: List, + current: HostInvite?, + onRow: ((HostInvite) -> Unit)?, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + modifier = Modifier.padding(start = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x1), + text = header, + style = CodeTheme.typography.textSmall, + color = White50, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(CodeTheme.shapes.small) + .background(White05), + ) { + invites.forEachIndexed { index, invite -> + if (index > 0) HorizontalDivider(color = CodeTheme.colors.divider) + InviteRow( + invite = invite, + isCurrent = current != null && current.invite.id == invite.invite.id, + onClick = onRow?.let { { it(invite) } }, + ) + } + } + } +} + +/** + * One link. An inactive row is greyed and has no click. A link this device can't rebuild says so + * and still opens its menu, which then offers only Revoke. + */ +@Composable +private fun InviteRow(invite: HostInvite, isCurrent: Boolean, onClick: (() -> Unit)?) { + val clickable = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier + Row( + modifier = Modifier + .fillMaxWidth() + .then(clickable) + .alpha(if (invite.isActive) 1f else InactiveAlpha) + .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.label_inviteCreated, shortDate(invite.invite.createdAt)), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + Text( + text = invite.detailLine(), + style = CodeTheme.typography.textSmall, + color = White50, + ) + } + if (isCurrent) CurrentChip() + if (onClick != null) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = White50, + ) + } + } +} + +@Composable +private fun CurrentChip() { + Text( + modifier = Modifier + .clip(CircleShape) + .background(White05) + .padding(horizontal = CodeTheme.dimens.grid.x2, vertical = 2.dp), + text = stringResource(R.string.label_inviteCurrent), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + ) +} + +/** The first load failed: say so, and offer to try again rather than showing a blank list. */ +@Composable +private fun LoadFailed(onRetry: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .padding(horizontal = CodeTheme.dimens.inset) + .testTag("manage_invites_failed"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + text = stringResource(R.string.title_inviteLinksNotLoaded), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier.widthIn(max = 280.dp), + text = stringResource(R.string.description_inviteLinksNotLoaded), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + CodeButton( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.action_retry), + buttonState = ButtonState.Filled10, + shape = CircleShape, + style = CodeTheme.typography.textSmall, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 7.dp), + overrideContentPadding = true, + onClick = onRetry, + ) + } +} + +/** Frame 2e. */ +@Composable +private fun EmptyInvites(creating: Boolean, onNewLink: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(horizontal = CodeTheme.dimens.inset), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + text = stringResource(R.string.title_noInviteLinks), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier.widthIn(max = 280.dp), + text = stringResource(R.string.subtitle_noInviteLinks), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + CodeButton( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.action_newInviteLink), + isLoading = creating, + enabled = !creating, + buttonState = ButtonState.Filled10, + shape = CircleShape, + style = CodeTheme.typography.textSmall, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 7.dp), + overrideContentPadding = true, + onClick = onNewLink, + ) + } +} + +/** Frame 2b: Share Link and Revoke Link, in the mute sheet's style. A link that can't be shared gets Revoke only. */ +@Composable +internal fun InviteLinkActions( + invite: HostInvite, + onShare: () -> Unit, + onRevoke: () -> Unit, + onDismiss: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) { + AppBarWithTitle( + title = stringResource(R.string.title_inviteLink), + titleAlignment = Alignment.CenterHorizontally, + endContent = { AppBarDefaults.Close(onClick = onDismiss) }, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + text = "${stringResource(R.string.label_inviteCreated, shortDate(invite.invite.createdAt))} · ${invite.detailLine()}", + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(vertical = CodeTheme.dimens.grid.x4), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + if (invite.canShare) { + ChoiceRow( + label = stringResource(R.string.action_shareLink), + icon = R.drawable.ic_send_outlined, + onClick = onShare, + ) + } + ChoiceRow( + label = stringResource(R.string.action_revokeLink), + icon = rememberVectorPainter(Icons.Outlined.Block), + contentColor = CodeTheme.colors.errorText, + onClick = onRevoke, + ) + } + } +} + +/** Frame 2d: expiry and use-limit presets, and Create Link. Returns the pick; the caller creates. */ +@Composable +internal fun NewInviteLinkContent( + onCreate: (InviteSettings) -> Unit, + onDismiss: () -> Unit, + initial: InviteSettings = InviteSettings.Default, +) { + var expiry by remember { mutableStateOf(initial.expiry) } + var uses by remember { mutableStateOf(initial.uses) } + val reducedMotion = rememberReducedMotion() + Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) { + AppBarWithTitle( + title = stringResource(R.string.title_newInviteLink), + titleAlignment = Alignment.CenterHorizontally, + endContent = { AppBarDefaults.Close(onClick = onDismiss) }, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + Text( + text = stringResource(R.string.label_inviteExpiresHeader), + style = CodeTheme.typography.textSmall, + color = White50, + ) + SlidingChipRow( + options = InviteExpiry.entries, + selected = expiry, + onSelect = { expiry = it }, + label = { stringResource(it.labelRes()) }, + reducedMotion = reducedMotion, + ) + Text( + text = stringResource(R.string.label_inviteWhoCanJoin), + style = CodeTheme.typography.textSmall, + color = White50, + ) + SlidingChipRow( + options = InviteUses.entries, + selected = uses, + onSelect = { uses = it }, + label = { stringResource(it.labelRes()) }, + reducedMotion = reducedMotion, + ) + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.action_createInviteLink), + buttonState = ButtonState.Filled, + onClick = { onCreate(InviteSettings(expiry, uses)) }, + ) + } + } +} + +internal fun InviteSettings.toChoice() = NewInviteLinkChoice(expiry = expiry.name, uses = uses.name) + +internal fun NewInviteLinkChoice.toSettings() = + InviteSettings(expiry = InviteExpiry.valueOf(expiry), uses = InviteUses.valueOf(uses)) + +private fun InviteExpiry.labelRes(): Int = when (this) { + InviteExpiry.OneDay -> R.string.label_inviteExpiry_oneDay + InviteExpiry.SevenDays -> R.string.label_inviteExpiry_sevenDays + InviteExpiry.ThirtyDays -> R.string.label_inviteExpiry_thirtyDays + InviteExpiry.Never -> R.string.label_inviteExpiry_never +} + +private fun InviteUses.labelRes(): Int = when (this) { + InviteUses.One -> R.string.label_inviteUses_one + InviteUses.Five -> R.string.label_inviteUses_five + InviteUses.Ten -> R.string.label_inviteUses_ten + InviteUses.TwentyFive -> R.string.label_inviteUses_twentyFive + InviteUses.NoLimit -> R.string.label_inviteUses_noLimit +} + +/** Inactive rows are greyed (frame 2a). */ +private const val InactiveAlpha = 0.5f diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt new file mode 100644 index 0000000000..9580f6fe5e --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModel.kt @@ -0,0 +1,149 @@ +package com.flipcash.app.messenger.internal.invites + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatId +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.flipcash.shared.chat.InviteSettings +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import dagger.hilt.android.lifecycle.HiltViewModel +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 +import kotlin.time.Clock + +/** Frames 2a to 2e: the host's invite links, Active then Inactive. */ +@HiltViewModel +internal class ManageInvitesViewModel @Inject constructor( + private val hostInvites: HostInvites, + private val resources: ResourceHelper, +) : ViewModel() { + + data class State( + val loading: Boolean = true, + /** The first load failed, so an empty list means nothing was fetched rather than no links. */ + val failed: Boolean = false, + val active: List = emptyList(), + val inactive: List = emptyList(), + /** The link the invite sheet shares; its row carries the Current chip. */ + val current: HostInvite? = null, + val creating: Boolean = false, + ) { + val isEmpty: Boolean get() = !loading && !failed && active.isEmpty() && inactive.isEmpty() + } + + sealed interface Effect { + /** The viewer lost `can_invite`; back to the profile. */ + data object LostAccess : 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 + + fun open(chatId: ChatId) { + if (this.chatId == chatId) return + this.chatId = chatId + refresh() + } + + fun refresh() { + val chatId = chatId ?: return + _state.update { it.copy(loading = true, failed = false) } + viewModelScope.launch { + hostInvites.load(chatId) + .onSuccess { publish(chatId) } + .onFailure { error -> + if (error.isLostInviteAccess()) { + lostAccess(chatId) + } else { + _state.update { it.copy(loading = false, failed = true) } + showInviteLinksNotLoaded(resources) + } + } + } + } + + /** Asks first, because revoking cannot be undone (frame 2c). */ + fun confirmRevoke(invite: HostInvite) { + showRevokeInviteLink(resources) { revoke(invite) } + } + + private fun revoke(invite: HostInvite) { + val chatId = chatId ?: return + viewModelScope.launch { + hostInvites.revoke(chatId, invite.invite.id) + .onSuccess { publish(chatId) } + .onFailure { error -> + if (error.isLostInviteAccess()) { + lostAccess(chatId) + } else { + BottomBarManager.showError( + title = resources.getString(R.string.title_inviteLinkNotRevoked), + message = resources.getString(R.string.description_inviteLinkNotCreated), + ) + } + } + } + } + + /** Frame 2d's Create Link, with the settings the sheet returned. The new link becomes the current one. */ + fun create(settings: InviteSettings) { + val chatId = chatId ?: return + if (_state.value.creating) return + _state.update { it.copy(creating = true) } + viewModelScope.launch { + when (val result = hostInvites.create(chatId, settings)) { + is InviteCreation.Created -> { + _state.update { it.copy(creating = false) } + publish(chatId) + } + InviteCreation.TooMany -> { + _state.update { it.copy(creating = false) } + showTooManyInviteLinks(resources) + } + InviteCreation.NotAvailable -> { + _state.update { it.copy(creating = false) } + showInviteLinksNotAvailable(resources) + } + is InviteCreation.Failed -> { + _state.update { it.copy(creating = false) } + showInviteLinkNotCreated(resources) + } + } + } + } + + private fun publish(chatId: ChatId) { + val (active, inactive) = HostInvites.sections(hostInvites.state.value[chatId].orEmpty(), Clock.System.now()) + _state.update { + it.copy( + loading = false, + failed = false, + active = active, + inactive = inactive, + current = hostInvites.current(chatId), + ) + } + } + + private fun lostAccess(chatId: ChatId) { + hostInvites.revokeAccess(chatId) + _effects.trySend(Effect.LostAccess) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt index 524efda737..364966579d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/TimeToPaySheet.kt @@ -1,6 +1,5 @@ package com.flipcash.app.messenger.internal.request -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.Spring @@ -10,7 +9,6 @@ import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,8 +19,6 @@ 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.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Keyboard @@ -44,7 +40,6 @@ import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -54,11 +49,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.RoundRect -import androidx.compose.ui.layout.onPlaced -import androidx.compose.ui.layout.positionInParent -import androidx.compose.ui.unit.toSize import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path @@ -90,6 +81,7 @@ import com.getcode.theme.CodeTheme import com.getcode.theme.White import com.getcode.theme.White05 import com.getcode.theme.White10 +import com.getcode.ui.components.SlidingChipRow import com.getcode.ui.theme.CodeButton import com.flipcash.shared.chat.ui.media.rememberReducedMotion import kotlinx.coroutines.launch @@ -143,10 +135,12 @@ internal fun TimeToPaySheet( textAlign = TextAlign.Center, ) RangeFields(sheet) - PresetChips( + SlidingChipRow( + options = Deadline.Preset.entries, selected = (sheet.choice as? TimeToPay.Choice.Preset)?.preset, + onSelect = { sheet = sheet.choosePreset(it) }, + label = { stringResource(it.label) }, reducedMotion = reducedMotion, - onChoose = { sheet = sheet.choosePreset(it) }, ) DayGrid(sheet, reducedMotion, onChoose = { sheet = sheet.chooseDay(it) }) TimeRow(time = sheet.time, onClick = { pickingTime = true }) @@ -246,69 +240,6 @@ private fun RangeField(label: String, value: String, outlined: Boolean, modifier } } -/** - * The preset chips. One white highlight slides to the chosen chip and fades out when the choice - * becomes custom. - */ -@Composable -private fun PresetChips(selected: Deadline.Preset?, reducedMotion: Boolean, onChoose: (Deadline.Preset) -> Unit) { - val bounds = remember { mutableStateMapOf() } - val highlight = remember { Animatable(Rect.Zero, Rect.VectorConverter) } - val shown = remember { Animatable(0f) } - var placed by remember { mutableStateOf(false) } - val target = selected?.let { bounds[it] } - LaunchedEffect(selected, target) { - val spec = TimeToPayMotion.spec(reducedMotion) - when { - selected == null -> shown.animateTo(0f, spec) - target == null -> Unit - !placed -> { - highlight.snapTo(target) - shown.snapTo(1f) - placed = true - } - else -> { - launch { highlight.animateTo(target, TimeToPayMotion.spec(reducedMotion)) } - shown.animateTo(1f, spec) - } - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .drawBehind { - bounds.values.forEach { chip -> - drawRoundRect(White10, chip.topLeft, chip.size, CornerRadius(chip.height / 2)) - } - val chip = highlight.value - drawRoundRect(White, chip.topLeft, chip.size, CornerRadius(chip.height / 2), alpha = shown.value) - }, - horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), - ) { - Deadline.Preset.entries.forEach { preset -> - val isSelected = preset == selected - val color by animateColorAsState( - targetValue = if (isSelected) Color.Black else CodeTheme.colors.textMain, - animationSpec = TimeToPayMotion.spec(reducedMotion), - label = "chip text", - ) - Text( - modifier = Modifier - .onPlaced { bounds[preset] = Rect(it.positionInParent(), it.size.toSize()) } - .clip(CircleShape) - .clickable(role = Role.Button) { onChoose(preset) } - .semantics { this.selected = isSelected } - .padding(horizontal = 10.dp, vertical = 7.dp), - text = stringResource(preset.label), - style = CodeTheme.typography.caption, - color = color, - maxLines = 1, - ) - } - } -} - /** * The days a request can run to. The range draws over the grid as one layer: the band as one span * per week row, today's grey circle, and one white closing-day circle that slides to a new day, @@ -615,7 +546,7 @@ private fun TimeDialog(initial: LocalTime, onPicked: (LocalTime) -> Unit, onDism } } -private val Deadline.Preset.label: Int +internal val Deadline.Preset.label: Int get() = when (this) { Deadline.Preset.Day -> R.string.option_timeToPay_day Deadline.Preset.ThreeDays -> R.string.option_timeToPay_threeDays 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 d5906ae982..230155ad50 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 @@ -42,6 +42,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow @@ -50,6 +51,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.getcode.theme.extraSmall import androidx.compose.ui.unit.sp +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset @@ -74,6 +76,11 @@ import com.flipcash.app.core.share.SharePreviewImage import com.flipcash.app.core.share.TipCodePreview import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.GroupInviteViewModel +import com.flipcash.app.messenger.internal.invites.InviteLinkState +import com.flipcash.app.messenger.internal.invites.InviteLinkViewModel +import com.flipcash.app.messenger.internal.invites.elideInviteUrl +import com.flipcash.app.messenger.internal.invites.inviteMetaLine +import kotlinx.coroutines.flow.Flow import com.flipcash.app.shareable.LocalShareController import com.flipcash.app.shareable.Shareable import com.flipcash.features.messenger.R @@ -92,6 +99,7 @@ import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.chat.ChatInput import com.getcode.ui.components.chat.ChatInputSubmit +import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.utils.sheetResignmentBehavior import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState @@ -119,6 +127,18 @@ internal fun CodeNavigator.openGroupInvite() { } } +/** + * Opens the invite variant of the sheet: the host's current link, or the invite [inviteIdHex] picked + * in Manage Invites. Returns the chat picked first, as [openGroupInvite] does. + */ +internal fun CodeNavigator.openInviteLinkShare(inviteIdHex: String? = null) { + navigateForResult(ChatStep.ShareInviteLink(inviteIdHex)) { result -> + if (result is NavResultOrCanceled.ReturnValue) { + navigate(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(result.value.chatId))) + } + } +} + /** 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 -> @@ -141,7 +161,10 @@ internal fun cardTileTag(subject: ShareToChatsSubject): String? = * link has no card. */ internal fun showsCardTile(subject: ShareToChatsSubject, onShowCard: (() -> Unit)?): Boolean = - onShowCard != null && subject !is ShareToChatsSubject.Message && subject !is ShareToChatsSubject.Currency + onShowCard != null && + subject !is ShareToChatsSubject.Message && + subject !is ShareToChatsSubject.Currency && + subject !is ShareToChatsSubject.GroupInviteLink /** * What [GroupInviteSheet] hands out: a group's invite link, a person's profile link, or a link to @@ -155,6 +178,15 @@ internal sealed interface ShareToChatsSubject { */ data class Group(val group: ChatSubject.Group?) : ShareToChatsSubject + /** + * A private group's invite link (frames 1b and 1f): the join variant's sheet with a link pill, + * Share Link and Copy Link in place of the group card, and Recent Chats cut to the chats an + * invite link may be sent into. + * + * [link] carries the link; its `toString` leaves the link out, so this prints without it. + */ + data class GroupInviteLink(val group: ChatSubject.Group?, val link: InviteLinkState) : ShareToChatsSubject + /** * @param preview the tip-code image for the system share sheet's card, when one is rendered. */ @@ -204,6 +236,9 @@ internal fun GroupInviteSheet( onInvite: () -> Unit, onDismiss: () -> Unit, onShowCard: (() -> Unit)? = null, + /** The invite variant's Share, Copy and Send answers; see [InviteLinkViewModel.Effect]. */ + inviteEffects: Flow? = null, + onInviteEffect: (InviteLinkViewModel.Effect) -> Unit = {}, ) { val shareController = LocalShareController.current val scope = rememberCoroutineScope() @@ -211,7 +246,10 @@ internal fun GroupInviteSheet( // Resolved here rather than in the share controller: the stored download URL expires, and // re-minting it needs the chat's profile to authorize it — neither of which the shareable // module can see. Same size and access as the avatar the conversation bar draws. - val group = (subject as? ShareToChatsSubject.Group)?.group + val inviteLink = subject as? ShareToChatsSubject.GroupInviteLink + val group = (subject as? ShareToChatsSubject.Group)?.group ?: inviteLink?.group + // Share, Copy and Send wait for the link: the tiles are disabled rather than dropping the tap. + val creating = inviteLink != null && inviteLink.link !is InviteLinkState.Ready val picture = group?.picture val pictureUrl = rememberMediaUrl( media = picture, @@ -230,6 +268,29 @@ internal fun GroupInviteSheet( var fieldHeight by remember { mutableStateOf(0.dp) } var copied by remember { mutableStateOf(false) } + if (inviteEffects != null) { + val groupTitle = group?.groupTitle + val cacheKey = picture?.cacheKeyForSize(SharePreviewImage.TARGET_PX) + // The collector outlives recompositions, so it reads the latest callback rather than the first. + val currentOnInviteEffect by rememberUpdatedState(onInviteEffect) + LaunchedEffect(inviteEffects) { + inviteEffects.collect { effect -> + when (effect) { + is InviteLinkViewModel.Effect.Share -> shareController.present( + Shareable.GroupInvite( + url = effect.url, + title = groupTitle, + imageUrl = pictureUrl.url, + imageCacheKey = cacheKey, + ) + ) + InviteLinkViewModel.Effect.Copied -> copied = true + else -> currentOnInviteEffect(effect) + } + } + } + } + // Held above the gesture bar, and dropped once the keyboard is up: the keyboard already lifts // the whole sheet clear of it, so counting both would leave a gap over the keys. val navigationBar = WindowInsets.navigationBars @@ -265,7 +326,17 @@ internal fun GroupInviteSheet( }, ), ) { - if (inviteUrl != null) { + if (inviteUrl != null || inviteLink != null) { + if (inviteLink != null) { + item(key = "pill") { + InviteLinkPill( + link = inviteLink.link, + modifier = Modifier + .padding(horizontal = ListInset) + .padding(top = TileRowTop), + ) + } + } item(key = "links") { // Intrinsic height, so a tile whose label wraps stretches the others to // match rather than standing taller than them. @@ -274,16 +345,24 @@ internal fun GroupInviteSheet( .fillMaxWidth() .height(IntrinsicSize.Min) .padding(horizontal = ListInset) - .padding(top = TileRowTop, bottom = TileRowBottom), + .padding( + top = if (inviteLink != null) TileRowBottom else TileRowTop, + bottom = TileRowBottom, + ), horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { InviteLinkTile( modifier = Modifier.weight(1f).fillMaxHeight(), + enabled = !creating, icon = R.drawable.ic_share_os, - label = stringResource(R.string.action_shareInviteLink), + label = stringResource( + if (inviteLink != null) R.string.action_shareLink else R.string.action_shareInviteLink + ), onClick = { onShare() - scope.launch { + // The invite variant shares what the view model answers with, once + // there is a link to share. + if (inviteUrl != null) scope.launch { shareController.present( when (subject) { is ShareToChatsSubject.Group -> Shareable.GroupInvite( @@ -301,6 +380,7 @@ internal fun GroupInviteSheet( ) ShareToChatsSubject.Message -> Shareable.MessageLink(inviteUrl) is ShareToChatsSubject.Currency -> Shareable.MessageLink(inviteUrl) + is ShareToChatsSubject.GroupInviteLink -> error("shared by effect") } ) } @@ -308,6 +388,7 @@ internal fun GroupInviteSheet( ) InviteLinkTile( modifier = Modifier.weight(1f).fillMaxHeight(), + enabled = !creating, icon = if (copied) R.drawable.ic_check else R.drawable.ic_copy, label = stringResource( when { @@ -318,7 +399,8 @@ internal fun GroupInviteSheet( ), onClick = { onCopy() - copied = true + // The invite variant has nothing to copy until the link exists. + if (inviteLink == null) copied = true }, ) if (showsCardTile(subject, onShowCard)) { @@ -346,10 +428,22 @@ internal fun GroupInviteSheet( RecentChatRow( chat = chat, selected = chat.chatId in state.selection, - enabled = !state.sending, + enabled = !state.sending && !creating, onClick = { onToggle(chat.chatId) }, ) } + if (inviteLink != null) { + item(key = "footnote") { + Text( + modifier = Modifier + .padding(horizontal = ListInset) + .padding(top = TileRowBottom), + text = stringResource(R.string.footnote_inviteLink), + style = CodeTheme.typography.textSmall, + color = White50, + ) + } + } } } @@ -397,7 +491,8 @@ internal fun GroupInviteSheet( stringResource(R.string.title_shareCurrency, subject.name) is ShareToChatsSubject.User -> stringResource(R.string.title_shareProfileToChats) is ShareToChatsSubject.Message -> stringResource(R.string.title_shareMessageToChats) - is ShareToChatsSubject.Group -> stringResource(R.string.title_invitePeople) + is ShareToChatsSubject.Group, + is ShareToChatsSubject.GroupInviteLink -> stringResource(R.string.title_invitePeople) }, titleAlignment = Alignment.CenterHorizontally, contentPadding = PaddingValues(HeaderPadding), @@ -408,7 +503,7 @@ internal fun GroupInviteSheet( // length of the fade. AnimatedVisibility( modifier = Modifier.align(Alignment.BottomCenter), - visible = inviteUrl != null && state.showsComposer, + visible = (inviteUrl != null || inviteLink != null) && state.showsComposer, enter = slideInVertically(ComposerSpring) { with(density) { ComposerTravel.roundToPx() } }, exit = slideOutVertically(ComposerSpring) { with(density) { ComposerTravel.roundToPx() } }, ) { @@ -425,7 +520,7 @@ internal fun GroupInviteSheet( hazeState = hazeState, sending = state.sending, sendLabel = stringResource( - if (subject is ShareToChatsSubject.Group) R.string.action_invite else R.string.action_send + if (subject is ShareToChatsSubject.Group || inviteLink != null) R.string.action_invite else R.string.action_send ), onMessageChanged = onMessageChanged, onInvite = onInvite, @@ -447,14 +542,16 @@ private fun InviteLinkTile( onClick: () -> Unit, modifier: Modifier = Modifier, testTag: String? = null, + enabled: Boolean = true, ) { Column( modifier = modifier .then(if (testTag != null) Modifier.testTag(testTag) else Modifier) + .alpha(if (enabled) 1f else DimmedAlpha) .heightIn(min = CodeTheme.dimens.grid.x19) .clip(CodeTheme.shapes.extraSmall) .background(White05) - .clickable(role = Role.Button, onClick = onClick) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) .padding(horizontal = CodeTheme.dimens.grid.x2, vertical = CodeTheme.dimens.grid.x3), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1, Alignment.CenterVertically), @@ -474,6 +571,54 @@ private fun InviteLinkTile( } } +/** + * Node 12933:113 — the invite link in a pill, with its expiry and joined count under it; while the + * tap on Share Invite Link makes the link, a spinner and "Creating link…" (node 12933:210). The link is cut in the + * middle so the secret is never shown whole. + */ +@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), + ) { + when (link) { + InviteLinkState.Creating -> { + CodeCircularProgressIndicator(modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6 / 2)) + Text( + text = stringResource(R.string.label_creatingInviteLink), + style = CodeTheme.typography.textMedium, + color = White50, + ) + } + is InviteLinkState.Ready -> Text( + text = elideInviteUrl(link.url), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Clip, + ) + // 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)) + } + } + if (link is InviteLinkState.Ready) { + Text( + text = inviteMetaLine(link.expiresAt, link.useCount, link.maxUses), + style = CodeTheme.typography.textSmall, + color = White50, + ) + } + } +} + /** Node 10330:19568 — the list's label, an ordinary row that scrolls with the chats. */ @Composable private fun RecentChatsHeader() { @@ -503,6 +648,7 @@ private fun RecentChatRow( Row( modifier = Modifier .fillMaxWidth() + .alpha(if (enabled) 1f else DimmedAlpha) .toggleable( value = selected, enabled = enabled, @@ -611,6 +757,9 @@ private val TileRowBottom = 12.dp private val ComposerMinBottom = 8.dp +/** Share, Copy and Recent Chats while the first tap makes the link (node 12933:210). */ +private const val DimmedAlpha = 0.4f + /** How far each edge scrim runs past its bar, as on the Chats list. */ private val ScrimTail = 48.dp // Far enough that the field starts fully below the sheet's bottom edge. 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 9e20349caf..2bc55d51d1 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 @@ -64,6 +64,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.analytics.GroupInviteSheetSource import com.flipcash.app.core.AppRoute @@ -77,7 +78,9 @@ import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel 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 @@ -124,6 +127,13 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val group = state.subject as? ChatSubject.Group val isMember = group?.isMember == true + val inviteHost = hiltViewModel() + val canInviteByChat by inviteHost.canInvite.collectAsStateWithLifecycle() + val canInvite = state.chatId?.let { canInviteByChat[it] } == true + LaunchedEffect(state.chatId, group?.isPrivate) { + // Only a private group has invite links, and the answer is the server's, not the cached chat's. + if (group?.isPrivate == true && isMember) state.chatId?.let(inviteHost::refresh) + } // A private group's non-member has no chat to open: no envelope, no transcript. The profile is // where they ask in, and where they wait. val privateJoin = PrivateGroupJoin.resolve( @@ -246,7 +256,18 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent( ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.PROFILE) ) - navigator.openGroupInvite() + // A host sees the three-way sheet (frame 1a); 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) }, + ) + } 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 2dc07692e6..4c480c630b 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 @@ -27,6 +27,7 @@ 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.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.analytics.GroupInviteSheetSource import com.flipcash.app.core.chat.ChatParticipant @@ -34,7 +35,9 @@ import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.chat.ProfileOrigin 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 @@ -64,6 +67,10 @@ internal fun JoinRequestsScreen(viewModel: ChatViewModel) { val flowNavigator = rememberFlowNavigator() val navigator = LocalCodeNavigator.current val group = state.subject as? ChatSubject.Group + val inviteHost = hiltViewModel() + val canInviteByChat by inviteHost.canInvite.collectAsStateWithLifecycle() + val canInvite = state.chatId?.let { canInviteByChat[it] } == true + LaunchedEffect(state.chatId) { state.chatId?.let(inviteHost::refresh) } // LobbyUpdate is best-effort, so the list refetches whenever it comes on screen. LaunchedEffect(Unit) { viewModel.dispatchEvent(ChatViewModel.Event.JoinRequestsOpened) } @@ -91,9 +98,19 @@ internal fun JoinRequestsScreen(viewModel: ChatViewModel) { .fillMaxSize() .padding(innerPadding), groupName = group?.groupTitle.orEmpty(), + canInvite = canInvite, onShare = { viewModel.dispatchEvent(ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.PROFILE)) - navigator.openGroupInvite() + if (canInvite) { + inviteHost.presentHostSheet( + groupName = group?.groupTitle.orEmpty(), + onShareInviteLink = { navigator.openInviteLinkShare() }, + onShareJoinLink = { navigator.openGroupInvite() }, + onManageInvites = { flowNavigator.navigateTo(ChatStep.ManageInvites) }, + ) + } else { + navigator.openGroupInvite() + } }, ) } else { @@ -219,7 +236,12 @@ private fun JoinRequestRow( } @Composable -private fun NoJoinRequests(groupName: String, onShare: () -> Unit, modifier: Modifier = Modifier) { +internal fun NoJoinRequests( + groupName: String, + canInvite: Boolean, + onShare: () -> Unit, + modifier: Modifier = Modifier, +) { Box(modifier = modifier.padding(horizontal = CodeTheme.dimens.inset), contentAlignment = Alignment.Center) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -241,7 +263,7 @@ private fun NoJoinRequests(groupName: String, onShare: () -> Unit, modifier: Mod ) CodeButton( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), - text = stringResource(R.string.action_shareGroup), + text = stringResource(if (canInvite) R.string.action_shareInviteLinkLong else R.string.action_shareGroup), buttonState = ButtonState.Filled10, shape = CircleShape, style = CodeTheme.typography.textSmall, 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 0b9a039a0d..5912a8d5ce 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 @@ -1,6 +1,8 @@ package com.flipcash.app.messenger.internal import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.getcode.chatcipher.ChatEncryptionPolicy import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.flipcash.services.user.UserManager @@ -70,6 +72,7 @@ class GroupInviteViewModelTest { resources = resources, clipboardManager = mockk(relaxed = true), tipCodePreviewCache = mockk(relaxed = true), + e2eePolicy = com.flipcash.services.chat.E2eePolicy(), ) @Test @@ -187,6 +190,61 @@ class GroupInviteViewModelTest { assertEquals(emptyList(), model.state.value.selection) } + @Test + fun `an invite link is only offered to private groups`() = runTest(scheduler) { + val privateGroup = ChatId(ByteArray(32) { 8 }) + val publicGroup = ChatId(ByteArray(32) { 9 }) + every { chatCoordinator.feed(*anyVararg()) } returns flowOf( + listOf( + summary(privateGroup, ChatType.GROUP).let { it.copy(metadata = it.metadata.copy(isPrivate = true)) }, + summary(publicGroup, ChatType.GROUP), + ), + ) + val model = viewModel() + advanceUntilIdle() + + model.restrictToEncrypted(true) + assertEquals(listOf(privateGroup), model.state.value.invitable?.map { it.chatId }) + + model.restrictToEncrypted(false) + assertEquals(listOf(privateGroup, publicGroup), model.state.value.invitable?.map { it.chatId }) + } + + @Test + fun `restricting to encrypted keeps an E2EE DM, drops the flipcash DM and prunes the selection`() = runTest(scheduler) { + val e2eeDm = ChatId(ByteArray(32) { 10 }) + val flipcashDm = ChatId(ByteArray(32) { 11 }) + fun dm(id: ChatId, peer: ByteArray) = summary(id, ChatType.CONTACT_DM).let { + it.copy( + metadata = it.metadata.copy( + useE2ee = true, + members = listOf( + ChatMember( + userId = peer.toList(), + userProfile = mockk(relaxed = true), + pointers = emptyList(), + ), + ), + ), + ) + } + every { chatCoordinator.feed(*anyVararg()) } returns flowOf( + listOf( + dm(e2eeDm, ByteArray(16) { 3 }), + dm(flipcashDm, ChatEncryptionPolicy.FLIPCASH_USER_ID), + ), + ) + val model = viewModel() + advanceUntilIdle() + model.toggle(e2eeDm) + model.toggle(flipcashDm) + + model.restrictToEncrypted(true) + + assertEquals(listOf(e2eeDm), model.state.value.invitable?.map { it.chatId }) + assertEquals(listOf(e2eeDm), model.state.value.selection) + } + @Test fun `a person's DM is left out of the chats their profile is sent to`() = runTest(scheduler) { val dm = ChatId(ByteArray(32) { 7 }) 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 new file mode 100644 index 0000000000..a0325448f5 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteFramesScreenshotTest.kt @@ -0,0 +1,89 @@ +package com.flipcash.app.messenger.internal.invites + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashPreview +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.test.assertTrue +import java.io.File + +/** + * Renders every invite frame's preview to `build/screenshots/invite_.png`, to be compared + * with the design by eye. Apart from 2d's preset chips, it fails only if a frame throws. + * + * Same mechanics as the group chrome screenshot test: pause the clock, pump a fixed number of + * frames and draw the view directly, so a composable that keeps scheduling frames can't hang. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [32], qualifiers = "w411dp-h891dp-xxhdpi") +class InviteFramesScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun render(name: String, content: @Composable () -> Unit) { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { FlipcashPreview(showBackground = true) { content() } } + repeat(12) { composeRule.mainClock.advanceTimeByFrame() } + + val root: View = composeRule.activity.findViewById(android.R.id.content) + val bitmap = Bitmap.createBitmap(root.width, root.height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val file = File(File("build/screenshots").apply { mkdirs() }, "invite_$name.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath}") + } + + @Test fun frame1a() = render("1a") { InviteFrame1aHostSheet() } + @Test fun frame1b() = render("1b") { InviteFrame1bCreating() } + @Test fun frame1c() = render("1c") { InviteFrame1cTooMany() } + @Test fun frame1d() = render("1d") { InviteFrame1dJoinRequestsEmpty() } + @Test fun frame1e() = render("1e") { InviteFrame1eNotAvailable() } + @Test fun frame1f() = render("1f") { InviteFrame1fReady() } + @Test fun frame1g() = render("1g") { InviteFrame1gJoin() } + @Test fun frame2a() = render("2a") { InviteFrame2aManage() } + @Test fun frame2b() = render("2b") { InviteFrame2bRowSheet() } + @Test fun frame2c() = render("2c") { InviteFrame2cRevoke() } + @Test fun frame2d() { + render("2d") { InviteFrame2dNewLink() } + // The spec's Uses presets are bare counts (1, 5, 10, 25), not "1 person". + listOf("1", "5", "10", "25", "No limit").forEach { + composeRule.onNodeWithText(it).assertExists() + } + composeRule.onNodeWithText("1 person").assertDoesNotExist() + // Each row's chips share its width equally, as on iOS. + listOf(listOf("1 day", "7 days", "30 days", "Never"), listOf("1", "5", "10", "25", "No limit")) + .forEach { row -> + val widths = row.map { composeRule.onNodeWithText(it).getBoundsInRoot().let { b -> b.right - b.left } } + assertTrue(widths.max() - widths.min() <= 1.dp, "chip widths $widths") + } + } + + // Android's largest font scale. The chips cut a label at its word break rather than shrink it, + // so this shows "30" and "No" for "30 days" and "No limit". + @Test fun frame2dLargestFont() = render("2d-fontScale2") { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 2f)) { + InviteFrame2dNewLink() + } + } + + @Test fun frame2dChosen() = render("2d-chosen") { InviteFrame2dNewLinkChosen() } + @Test fun frame2e() = render("2e") { InviteFrame2eEmpty() } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabelsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabelsTest.kt new file mode 100644 index 0000000000..988d7deaf8 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLabelsTest.kt @@ -0,0 +1,25 @@ +package com.flipcash.app.messenger.internal.invites + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InviteLabelsTest { + + @Test + fun `the pill drops the scheme and elides the middle of the secret`() { + val url = "https://app.flipcash.com/chat/invite#s=" + "A".repeat(10) + "B".repeat(30) + "wxyz" + + val shown = elideInviteUrl(url) + + assertTrue(shown.startsWith("app.flipcash.com/chat/invite#s=")) + assertTrue(shown.endsWith("…wxyz")) + assertFalse(shown.contains("B".repeat(30))) + } + + @Test + fun `a short link is shown whole`() { + assertEquals("a.com/x", elideInviteUrl("https://a.com/x")) + } +} 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 new file mode 100644 index 0000000000..5ce4ca479d --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/InviteLinkViewModelTest.kt @@ -0,0 +1,278 @@ +package com.flipcash.app.messenger.internal.invites + +import android.content.ClipboardManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.core.extensions.SensitiveClipKey +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.HostInvite +import com.flipcash.shared.chat.HostInvites +import com.flipcash.shared.chat.InviteCreation +import com.getcode.manager.BottomBarManager +import com.getcode.manager.SelectedBottomBarAction +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.verify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.CompletableDeferred +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 +import kotlin.time.Instant + +/** + * The invite variant of the share sheet (frames 1b, 1f, 1c, 1e). The sheet opens from the tap on + * Share Invite Link, so opening with no current link makes one; each way CreateInvite can end maps + * to its own alert; a specific invite from Manage Invites never creates. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class InviteLinkViewModelTest { + + private val chatId = ChatId(ByteArray(32) { 5 }) + private val hostInvites = mockk(relaxed = true) + private val resources = mockk(relaxed = true) { + every { getString(any()) } returns "text" + } + private val clipboard = + ApplicationProvider.getApplicationContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + private val listed = MutableStateFlow>>(emptyMap()) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + BottomBarManager.clear() + every { hostInvites.state } returns listed + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + every { hostInvites.current(chatId) } returns null + } + + @After + fun tearDown() { + BottomBarManager.clear() + Dispatchers.resetMain() + } + + private fun viewModel() = InviteLinkViewModel(hostInvites, resources, clipboard) + + private var seed = 1 + private fun hostInvite(url: String?, useCount: Long = 0, maxUses: Long = 0): HostInvite { + val n = seed++ + val invite = mockk(relaxed = true) { + every { id } returns InviteId(ByteArray(32) { n.toByte() }) + every { expiresAt } returns Instant.fromEpochSeconds(1_800_000_000) + every { this@mockk.useCount } returns useCount + every { this@mockk.maxUses } returns maxUses + } + return HostInvite(invite, url) + } + + @Test + fun `opening with no current link creates at once and goes through creating to ready`() = runTest { + val gate = CompletableDeferred() + coEvery { hostInvites.currentOrCreate(chatId) } coAnswers { gate.await() } + val model = viewModel() + + model.open(chatId, null) + assertEquals(InviteLinkState.Creating, model.state.value.link) + coVerify(exactly = 1) { hostInvites.currentOrCreate(chatId) } + + gate.complete(InviteCreation.Created(hostInvite("https://x/chat/invite#s=new", maxUses = 5))) + val ready = model.state.value.link as InviteLinkState.Ready + assertEquals("https://x/chat/invite#s=new", ready.url) + assertEquals(5L, ready.maxUses) + } + + @Test + fun `tiles do nothing while the link is being made`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } coAnswers { kotlinx.coroutines.awaitCancellation() } + val model = viewModel() + model.open(chatId, null) + + model.onShare() + model.onCopy() + model.onSend() + + assertEquals(InviteLinkState.Creating, model.state.value.link) + assertEquals(null, clipboard.primaryClip) + coVerify(exactly = 1) { hostInvites.currentOrCreate(chatId) } + } + + @Test + fun `an existing current link opens ready without creating`() = runTest { + every { hostInvites.current(chatId) } returns hostInvite("https://x/chat/invite#s=a", useCount = 2, maxUses = 10) + val model = viewModel() + model.open(chatId, null) + + val ready = model.state.value.link as InviteLinkState.Ready + assertEquals(2L, ready.useCount) + model.onShare() + + assertEquals("https://x/chat/invite#s=a", (model.effects.first() as InviteLinkViewModel.Effect.Share).url) + coVerify(exactly = 0) { hostInvites.currentOrCreate(any()) } + } + + @Test + fun `copy puts the link on the clipboard marked sensitive and says copied`() = runTest { + every { hostInvites.current(chatId) } returns hostInvite("https://x/chat/invite#s=a") + val model = viewModel() + model.open(chatId, null) + + model.onCopy() + + val clip = clipboard.primaryClip!! + assertEquals("https://x/chat/invite#s=a", clip.getItemAt(0).text.toString()) + assertTrue(clip.description.extras!!.getBoolean(SensitiveClipKey)) + assertEquals(InviteLinkViewModel.Effect.Copied, model.effects.first()) + } + + @Test + fun `send hands the link to the picked chats`() = runTest { + every { hostInvites.current(chatId) } returns hostInvite("https://x/chat/invite#s=a") + val model = viewModel() + model.open(chatId, null) + + model.onSend() + + assertEquals("https://x/chat/invite#s=a", (model.effects.first() as InviteLinkViewModel.Effect.Send).url) + } + + @Test + fun `denied shows 1e with the join link on offer and closes behind it`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.NotAvailable + val model = viewModel() + model.open(chatId, null) + + assertEquals(InviteLinkState.None, model.state.value.link) + val message = BottomBarManager.messages.value.single() + assertTrue(message.showCancel) + message.actions.first().onClick() + assertEquals(InviteLinkViewModel.Effect.OpenJoinLink, model.effects.first()) + coVerify(exactly = 1) { hostInvites.currentOrCreate(chatId) } + } + + @Test + fun `dismissing 1e closes the sheet`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.NotAvailable + val model = viewModel() + model.open(chatId, null) + + BottomBarManager.messages.value.single().onClose(SelectedBottomBarAction(-1)) + + assertEquals(InviteLinkViewModel.Effect.Close, model.effects.first()) + } + + @Test + fun `too many offers Manage Invites and Cancel`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.TooMany + val model = viewModel() + model.open(chatId, null) + + val message = BottomBarManager.messages.value.single() + assertTrue(message.showCancel) + message.actions.first().onClick() + assertEquals(InviteLinkViewModel.Effect.OpenManageInvites, model.effects.first()) + assertEquals(InviteLinkState.None, model.state.value.link) + } + + @Test + fun `any other failure shows the generic error and closes behind it`() = runTest { + coEvery { hostInvites.currentOrCreate(chatId) } returns InviteCreation.Failed(IllegalStateException()) + val model = viewModel() + model.open(chatId, null) + + assertEquals(InviteLinkState.None, model.state.value.link) + BottomBarManager.messages.value.single().onClose(SelectedBottomBarAction(-1)) + assertEquals(InviteLinkViewModel.Effect.Close, model.effects.first()) + } + + @Test + fun `a denied list at open takes the access away and closes`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.failure(GetChatInvitesError.Denied()) + val model = viewModel() + + model.open(chatId, null) + + verify { hostInvites.revokeAccess(chatId) } + assertEquals(InviteLinkViewModel.Effect.Close, model.effects.first()) + coVerify(exactly = 0) { hostInvites.currentOrCreate(any()) } + } + + @Test + fun `a list that fails to load makes nothing`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.failure(IllegalStateException()) + val model = viewModel() + + model.open(chatId, null) + + assertEquals(InviteLinkState.None, model.state.value.link) + coVerify(exactly = 0) { hostInvites.currentOrCreate(any()) } + } + + @Test + fun `an invite picked in Manage Invites that can't be opened closes the sheet`() = runTest { + val picked = hostInvite(url = null) + listed.value = mapOf(chatId to listOf(picked)) + val model = viewModel() + + model.open(chatId, picked.invite.id.toHex()) + + assertTrue(model.effects.first() is InviteLinkViewModel.Effect.Close) + } + + @Test + fun `a specific invite shares its own link and never creates`() = runTest { + val picked = hostInvite("https://x/chat/invite#s=picked") + listed.value = mapOf(chatId to listOf(picked)) + val model = viewModel() + model.open(chatId, picked.invite.id.toHex()) + + model.onShare() + + assertEquals("https://x/chat/invite#s=picked", (model.effects.first() as InviteLinkViewModel.Effect.Share).url) + coVerify(exactly = 0) { hostInvites.currentOrCreate(any()) } + } + + @Test + fun `reopening on a different invite reloads and on the same one does nothing`() = runTest { + val first = hostInvite("https://x/chat/invite#s=one") + val second = hostInvite("https://x/chat/invite#s=two") + listed.value = mapOf(chatId to listOf(first, second)) + val model = viewModel() + + model.open(chatId, first.invite.id.toHex()) + model.open(chatId, first.invite.id.toHex()) + coVerify(exactly = 1) { hostInvites.load(chatId) } + assertEquals("https://x/chat/invite#s=one", (model.state.value.link as InviteLinkState.Ready).url) + + model.open(chatId, second.invite.id.toHex()) + coVerify(exactly = 2) { hostInvites.load(chatId) } + assertEquals("https://x/chat/invite#s=two", (model.state.value.link as InviteLinkState.Ready).url) + } + + @Test + fun `state and effects never print the link`() { + val ready = InviteLinkState.Ready("https://x/chat/invite#s=secret", null, 0, 0) + assertFalse(ready.toString().contains("secret")) + assertFalse(InviteLinkViewModel.Effect.Share("https://x/chat/invite#s=secret").toString().contains("secret")) + assertFalse(InviteLinkViewModel.Effect.Send("https://x/chat/invite#s=secret").toString().contains("secret")) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt new file mode 100644 index 0000000000..be8c1fcbc0 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/ManageInvitesViewModelTest.kt @@ -0,0 +1,289 @@ +package com.flipcash.app.messenger.internal.invites + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.GetChatInvitesError +import com.flipcash.services.models.RevokeInviteError +import com.flipcash.services.models.chat.InviteId +import com.flipcash.shared.chat.HostInvite +import com.flipcash.shared.chat.HostInvites +import com.flipcash.shared.chat.InviteCreation +import com.flipcash.shared.chat.InviteExpiry +import com.flipcash.shared.chat.InviteSettings +import com.flipcash.shared.chat.InviteUses +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +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 kotlin.time.Clock + +/** Frames 2a to 2e: load, the undecryptable row, revoke and its DENIED, and create. */ +@OptIn(ExperimentalCoroutinesApi::class) +class ManageInvitesViewModelTest { + + private val chatId = ChatId(ByteArray(32) { 5 }) + private val hostInvites = mockk() + private val resources = mockk(relaxed = true) { + every { getString(any()) } returns "text" + } + private val listed = MutableStateFlow>>(emptyMap()) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + BottomBarManager.clear() + every { hostInvites.state } returns listed + every { hostInvites.current(chatId) } returns null + every { hostInvites.revokeAccess(chatId) } returns Unit + } + + @After + fun tearDown() { + BottomBarManager.clear() + Dispatchers.resetMain() + } + + private fun viewModel() = ManageInvitesViewModel(hostInvites, resources) + + private var seed = 0 + private fun hostInvite( + url: String? = "https://x/chat/invite#s=a", + status: Invite.Status = Invite.Status.ACTIVE, + ): HostInvite { + val n = seed++ + val invite = mockk(relaxed = true) { + every { id } returns InviteId(ByteArray(32) { n.toByte() }) + every { this@mockk.status } returns status + every { createdAt } returns Clock.System.now() + every { expiresAt } returns null + } + return HostInvite(invite, url) + } + + @Test + fun `load puts active links first and marks the current one`() = runTest { + val active = hostInvite() + val revoked = hostInvite(status = Invite.Status.REVOKED) + listed.value = mapOf(chatId to listOf(active, revoked)) + coEvery { hostInvites.load(chatId) } returns Result.success(listOf(active, revoked)) + every { hostInvites.current(chatId) } returns active + + val model = viewModel() + model.open(chatId) + + assertEquals(listOf(active), model.state.value.active) + assertEquals(listOf(revoked), model.state.value.inactive) + assertEquals(active, model.state.value.current) + assertFalse(model.state.value.isEmpty) + } + + @Test + fun `no invites at all is the empty state`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + + val model = viewModel() + model.open(chatId) + + assertTrue(model.state.value.isEmpty) + } + + @Test + fun `a link this device can't open stays in the list without a link`() = runTest { + val locked = hostInvite(url = null) + listed.value = mapOf(chatId to listOf(locked)) + coEvery { hostInvites.load(chatId) } returns Result.success(listOf(locked)) + + val model = viewModel() + model.open(chatId) + + assertFalse(model.state.value.active.single().canShare) + } + + @Test + fun `a failed load is not an empty list`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.failure(IllegalStateException()) + + val model = viewModel() + model.open(chatId) + + assertTrue(model.state.value.failed) + assertFalse(model.state.value.isEmpty) + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `a denied load takes the access away and leaves the screen`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.failure(GetChatInvitesError.Denied()) + + val model = viewModel() + model.open(chatId) + + assertTrue(model.effects.first() is ManageInvitesViewModel.Effect.LostAccess) + verify { hostInvites.revokeAccess(chatId) } + } + + @Test + fun `a denied revoke takes the access away and leaves the screen`() = runTest { + val active = hostInvite() + listed.value = mapOf(chatId to listOf(active)) + coEvery { hostInvites.load(chatId) } returns Result.success(listOf(active)) + coEvery { hostInvites.revoke(chatId, active.invite.id) } returns Result.failure(RevokeInviteError.Denied()) + val model = viewModel() + model.open(chatId) + + model.confirmRevoke(active) + BottomBarManager.messages.value.single().actions.single().onClick() + + assertTrue(model.effects.first() is ManageInvitesViewModel.Effect.LostAccess) + verify { hostInvites.revokeAccess(chatId) } + } + + @Test + fun `revoking asks first, and a created link clears the creating state`() = runTest { + val active = hostInvite() + coEvery { hostInvites.load(chatId) } returns Result.success(listOf(active)) + val created = hostInvite() + coEvery { hostInvites.create(chatId, InviteSettings.Default) } returns InviteCreation.Created(created) + val model = viewModel() + model.open(chatId) + + model.confirmRevoke(active) + assertTrue(BottomBarManager.messages.value.single().showCancel) + + model.create(InviteSettings.Default) + assertFalse(model.state.value.creating) + } + + @Test + fun `create sends the settings the sheet returned and shows creating until it finishes`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + val gate = kotlinx.coroutines.CompletableDeferred() + val settings = InviteSettings(InviteExpiry.OneDay, InviteUses.Five) + coEvery { hostInvites.create(chatId, settings) } coAnswers { gate.await() } + val model = viewModel() + model.open(chatId) + + model.create(settings) + assertTrue(model.state.value.creating) + + gate.complete(InviteCreation.Created(hostInvite())) + assertFalse(model.state.value.creating) + coVerify(exactly = 1) { hostInvites.create(chatId, settings) } + } + + @Test + fun `a created link becomes the current one`() = runTest { + val created = hostInvite() + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + coEvery { hostInvites.create(chatId, InviteSettings.Default) } coAnswers { + listed.value = mapOf(chatId to listOf(created)) + every { hostInvites.current(chatId) } returns created + InviteCreation.Created(created) + } + val model = viewModel() + model.open(chatId) + + model.create(InviteSettings.Default) + + assertEquals(created, model.state.value.current) + assertEquals(listOf(created), model.state.value.active) + } + + @Test + fun `too many from inside Manage Invites is OK only`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + coEvery { hostInvites.create(chatId, any()) } returns InviteCreation.TooMany + val model = viewModel() + model.open(chatId) + + model.create(InviteSettings.Default) + + val message = BottomBarManager.messages.value.single() + assertFalse(message.showCancel) + assertEquals(1, message.actions.size) + assertFalse(model.state.value.creating) + } + + @Test + fun `denied and failed creates each show one message and re-enable the button`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + val model = viewModel() + model.open(chatId) + + coEvery { hostInvites.create(chatId, any()) } returns InviteCreation.NotAvailable + model.create(InviteSettings.Default) + assertEquals(1, BottomBarManager.messages.value.size) + assertFalse(model.state.value.creating) + + BottomBarManager.clear() + coEvery { hostInvites.create(chatId, any()) } returns InviteCreation.Failed(IllegalStateException()) + model.create(InviteSettings.Default) + assertEquals(1, BottomBarManager.messages.value.size) + assertFalse(model.state.value.creating) + } + + @Test + fun `a second create while one is in flight is ignored`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + coEvery { hostInvites.create(chatId, any()) } coAnswers { kotlinx.coroutines.awaitCancellation() } + val model = viewModel() + model.open(chatId) + + model.create(InviteSettings.Default) + model.create(InviteSettings.Default) + + assertTrue(model.state.value.creating) + coVerify(exactly = 1) { hostInvites.create(chatId, any()) } + } + + @Test + fun `a failed revoke shows an error and leaves the list as it was`() = runTest { + val active = hostInvite() + listed.value = mapOf(chatId to listOf(active)) + coEvery { hostInvites.load(chatId) } returns Result.success(listOf(active)) + coEvery { hostInvites.revoke(chatId, active.invite.id) } returns Result.failure(IllegalStateException()) + val model = viewModel() + model.open(chatId) + + model.confirmRevoke(active) + val confirm = BottomBarManager.messages.value.single() + confirm.actions.single().onClick() + + assertEquals(listOf(active), model.state.value.active) + // The confirmation is still up (the UI removes it on a tap); the error is the second. + assertEquals(2, BottomBarManager.messages.value.size) + assertTrue(BottomBarManager.messages.value.last().isError) + verify(exactly = 0) { hostInvites.revokeAccess(any()) } + } + + @Test + fun `refresh after a failed first load loads again`() = runTest { + coEvery { hostInvites.load(chatId) } returns Result.failure(IllegalStateException()) + val model = viewModel() + model.open(chatId) + assertTrue(model.state.value.failed) + + coEvery { hostInvites.load(chatId) } returns Result.success(emptyList()) + model.refresh() + + assertFalse(model.state.value.failed) + assertTrue(model.state.value.isEmpty) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/NewInviteLinkChoiceTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/NewInviteLinkChoiceTest.kt new file mode 100644 index 0000000000..a6839ee88b --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/invites/NewInviteLinkChoiceTest.kt @@ -0,0 +1,34 @@ +package com.flipcash.app.messenger.internal.invites + +import android.os.Parcel +import com.flipcash.app.core.chat.NewInviteLinkChoice +import com.flipcash.shared.chat.InviteExpiry +import com.flipcash.shared.chat.InviteSettings +import com.flipcash.shared.chat.InviteUses +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +/** New Invite Link returns its pick as a [NewInviteLinkChoice], so every preset must survive a Parcel and map back. */ +@RunWith(RobolectricTestRunner::class) +class NewInviteLinkChoiceTest { + + @Test + fun `every expiry and use limit round-trips through a parcel and back to settings`() { + for (expiry in InviteExpiry.entries) { + for (uses in InviteUses.entries) { + val settings = InviteSettings(expiry, uses) + val parcel = Parcel.obtain() + try { + parcel.writeParcelable(settings.toChoice(), 0) + parcel.setDataPosition(0) + val read = parcel.readParcelable(NewInviteLinkChoice::class.java.classLoader) + assertEquals(settings, read!!.toSettings()) + } finally { + parcel.recycle() + } + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/TimeToPayChipsScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/TimeToPayChipsScreenshotTest.kt new file mode 100644 index 0000000000..92e6442d2d --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/TimeToPayChipsScreenshotTest.kt @@ -0,0 +1,70 @@ +package com.flipcash.app.messenger.internal.request + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.Density +import com.flipcash.app.messenger.internal.request.NewRequestDraft.Deadline +import com.flipcash.app.theme.FlipcashPreview +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.SlidingChipRow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** + * Renders Time To Pay's preset chips to `build/screenshots/timeToPay_chips_.png`. Only + * the chip row: the sheet itself is a ModalBottomSheet, whose window this view-drawing harness + * doesn't capture. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [32], qualifiers = "w411dp-h891dp-xxhdpi") +class TimeToPayChipsScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun render(fontScale: Float) { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale)) { + FlipcashPreview(showBackground = true) { + SlidingChipRow( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + options = Deadline.Preset.entries, + selected = Deadline.Preset.Week, + onSelect = {}, + label = { stringResource(it.label) }, + ) + } + } + } + repeat(12) { composeRule.mainClock.advanceTimeByFrame() } + + val root: View = composeRule.activity.findViewById(android.R.id.content) + val bitmap = Bitmap.createBitmap(root.width, root.height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val file = File(File("build/screenshots").apply { mkdirs() }, "timeToPay_chips_$fontScale.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath}") + } + + @Test fun defaultFont() = render(1f) + + // Android's largest font scale: "2 Weeks" and "4 Weeks" are cut to "2" and "4". + @Test fun largestFont() = render(2f) +} + 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 3f403af148..c7f4264093 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 @@ -27,4 +27,9 @@ class GroupCardTileTest { fun `tile id is group-card-tile`() { assertEquals("group-card-tile", GroupCardTileTag) } + + @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)) {}) + } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt new file mode 100644 index 0000000000..67eff5d5a2 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostInvites.kt @@ -0,0 +1,370 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.chat.ChatKeySource +import com.flipcash.services.chat.GroupKeySource +import com.flipcash.services.chat.toEnvelope +import com.flipcash.services.chat.toInviteSecretEnvelope +import com.flipcash.services.chat.toPayload +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.CreateInviteError +import com.flipcash.services.models.GetChatInvitesError +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.RevokeInviteError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.chat.InviteId +import com.flipcash.services.models.chat.InviteToken +import com.flipcash.services.models.chat.ViewMode +import com.getcode.chatcipher.InviteCipher +import com.getcode.chatcipher.InviteLink +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +/** New Invite Link's Expires presets (node 12844:334). [Never] sends no `expires_at`. */ +enum class InviteExpiry(val duration: Duration?) { + OneDay(1.days), + SevenDays(7.days), + ThirtyDays(30.days), + Never(null), + ; + + /** The next shorter preset, for a server that refuses this one's `expires_at`. */ + val shorter: InviteExpiry? + get() = when (this) { + Never -> ThirtyDays + ThirtyDays -> SevenDays + SevenDays -> OneDay + OneDay -> null + } +} + +/** New Invite Link's Who can join presets, in the spec's order (group-invites.md). 0 is no limit. */ +enum class InviteUses(val maxUses: Int) { + One(1), + Five(5), + Ten(10), + TwentyFive(25), + NoLimit(0), +} + +data class InviteSettings(val expiry: InviteExpiry, val uses: InviteUses) { + companion object { + /** The current link's settings when the sheet makes one: 7 days, no use limit. */ + val Default = InviteSettings(InviteExpiry.SevenDays, InviteUses.NoLimit) + } +} + +/** + * One of the host's invites, with its link when this device can rebuild it. [url] is null when + * the invite's `secret_envelope` is missing or won't open here: the host can still revoke it, but + * can't share it. The link is as good as the chat key, so [toString] never prints it. + */ +class HostInvite(val invite: Invite, val url: String?) { + val canShare: Boolean get() = url != null + val isActive: Boolean get() = invite.status == Invite.Status.ACTIVE + + override fun toString(): String = "HostInvite(status=${invite.status}, canShare=$canShare)" +} + +/** How a create attempt ended. */ +sealed interface InviteCreation { + class Created(val invite: HostInvite) : InviteCreation + + /** `TOO_MANY_INVITES`: the group holds the most ACTIVE links the server allows. */ + data object TooMany : InviteCreation + + /** + * `DENIED`. While `CreateInvite` is staff-only, a host with `can_invite` gets this too + * (spec Decision 10), so it is shown as invite links not being available yet. + */ + data object NotAvailable : InviteCreation + + /** Anything else. A transport failure keeps the attempt's secret for the retry. */ + class Failed(val cause: Throwable) : InviteCreation +} + +/** + * The invite links of the private groups this account hosts: listing them with their rebuilt + * links, making a new one, and revoking. One copy for the invite sheet and Manage Invites, so the + * link the sheet shares is the one Manage Invites marks Current. + * + * Nothing here logs a link, its secret or its token. + */ +@Singleton +class HostInvites internal constructor( + private val chatController: ChatController, + private val groupKeys: GroupKeySource, + private val keys: ChatKeySource, + private val cipher: InviteCipher, + private val clock: Clock, +) { + @Inject + constructor( + chatController: ChatController, + groupKeys: GroupKeySource, + keys: ChatKeySource, + cipher: InviteCipher, + ) : this(chatController, groupKeys, keys, cipher, Clock.System) + + private val invites = MutableStateFlow>>(emptyMap()) + private val access = MutableStateFlow>(emptyMap()) + + /** + * Each checked chat's `can_invite`, as `GetChat` last returned it. Kept here because the local + * store does not carry the permission yet, so a chat read from disk always says false. + */ + val canInvite: StateFlow> = access.asStateFlow() + + /** Each loaded chat's invites, newest first. A chat never loaded is absent. */ + val state: StateFlow>> = invites.asStateFlow() + + // One secret per create attempt, kept until the server answers for certain, so a retry after a + // dropped response sends the same token and the server hands back the first invite rather + // than making a second. + // Concurrent because clear() can run (logout) while an attempt is in flight. + private val pending = ConcurrentHashMap, ByteArray>() + private val createLock = Mutex() + + // Bumped by clear(), so a load or create that was in flight for the old account does not + // write its answer into the next one's state. + @Volatile + private var epoch = 0 + + /** Reads [chatId]'s `can_invite` from the server into [canInvite]. A failure leaves it as it was. */ + suspend fun refreshAccess(chatId: ChatId): Result = + chatController.getChat(chatId, ViewMode.FULL_OR_REDACTED) + .map { it.viewerState?.permissions?.canInvite == true } + .onSuccess { allowed -> access.update { it + (chatId to allowed) } } + + /** Records that the viewer can no longer invite to [chatId], after a `DENIED`. */ + fun revokeAccess(chatId: ChatId) { + access.update { it + (chatId to false) } + } + + /** Fetches every page of [chatId]'s invites and replaces what was held for it. */ + suspend fun load(chatId: ChatId): Result> { + val started = epoch + return catching { loadAll(chatId) }.onSuccess { list -> + if (started == epoch) invites.update { it + (chatId to list) } + } + } + + private suspend fun loadAll(chatId: ChatId): List { + val all = mutableListOf() + var options = QueryOptions() + do { + val page = chatController.getChatInvites(chatId, options).getOrThrow() + all += page.invites + options = options.copy(token = page.pagingToken) + } while (page.hasMore && page.pagingToken != null) + return all.distinctBy { it.id } + .sortedByDescending { it.createdAt } + .map { HostInvite(it, linkFor(it)) } + } + + /** + * The link the sheet shares: the newest ACTIVE invite the viewer created whose link this + * device can rebuild. Null when there is none, and tapping Share Invite Link makes one. + */ + fun current(chatId: ChatId): HostInvite? = currentOf(invites.value[chatId].orEmpty(), keys.ownUserId()) + + /** + * The link the sheet shares, made when there is none: the tap on Share Invite Link (spec + * Decision 9, Open 9). The new link is 7 days with no use limit. [load] has to have run first, + * or a link that exists but was never fetched is not seen and a second one is made. The check + * runs again under the create lock, so two callers waiting on it make one link between them. + */ + suspend fun currentOrCreate(chatId: ChatId): InviteCreation = createLock.withLock { + current(chatId)?.let { InviteCreation.Created(it) } ?: createLocked(chatId, InviteSettings.Default) + } + + /** + * Makes an invite with [settings]. If the server answers OK with an invite that is no longer + * ACTIVE, which only a reused token can cause, the secret is dropped and one new attempt made. + * A refused `expires_at` is retried once at the next shorter preset. + */ + suspend fun create(chatId: ChatId, settings: InviteSettings): InviteCreation = + createLock.withLock { createLocked(chatId, settings) } + + private suspend fun createLocked(chatId: ChatId, settings: InviteSettings): InviteCreation { + val started = epoch + var attempt = settings + var clamped = false + var renewed = false + while (true) { + when (val result = attemptCreate(chatId, attempt)) { + is AttemptResult.Created -> { + val created = HostInvite(result.invite, result.url) + if (started != epoch) return InviteCreation.Created(created) + invites.update { all -> + val others = all[chatId].orEmpty().filterNot { it.invite.id == created.invite.id } + all + (chatId to listOf(created) + others) + } + return InviteCreation.Created(created) + } + AttemptResult.NotActive -> { + if (renewed) return InviteCreation.Failed(IllegalStateException("Created invite is not active")) + renewed = true + } + AttemptResult.ExpiryRefused -> { + val shorter = attempt.expiry.shorter + if (clamped || shorter == null) { + return InviteCreation.Failed(CreateInviteError.InvalidExpiresAt()) + } + clamped = true + attempt = attempt.copy(expiry = shorter) + } + is AttemptResult.Done -> return result.creation + } + } + } + + /** Revokes [inviteId]. Revoking an invite already revoked is OK, so a retry is safe. */ + suspend fun revoke(chatId: ChatId, inviteId: InviteId): Result = + chatController.revokeInvite(chatId, inviteId).onFailure { if (it is CancellationException) throw it }.onSuccess { + invites.update { all -> + val list = all[chatId] ?: return@update all + all + (chatId to list.map { item -> + if (item.invite.id == inviteId) { + HostInvite(item.invite.copy(status = Invite.Status.REVOKED), item.url) + } else { + item + } + }) + } + } + + /** Drops everything held. For logout: the invites belong to the account. */ + fun clear() { + epoch++ + invites.value = emptyMap() + access.value = emptyMap() + pending.clear() + } + + private sealed interface AttemptResult { + class Created(val invite: Invite, val url: String) : AttemptResult + data object NotActive : AttemptResult + data object ExpiryRefused : AttemptResult + class Done(val creation: InviteCreation) : AttemptResult + } + + private suspend fun attemptCreate(chatId: ChatId, settings: InviteSettings): AttemptResult { + val own = keys.ownKeyPair() + ?: return AttemptResult.Done(InviteCreation.Failed(IllegalStateException("No account key pair"))) + val chatKey = groupKeys.key(chatId).getOrElse { + if (it is CancellationException) throw it + return AttemptResult.Done(InviteCreation.Failed(it)) + } + + val slot = chatId to settings + val secret = pending.getOrPut(slot) { cipher.newSecret() } + val sealed = catching { + val inviteId = cipher.inviteId(secret) + Triple( + InviteToken(cipher.inviteToken(secret)), + cipher.sealInviteKeyEnvelope(own, secret, chatId.bytes, chatKey).toEnvelope(), + cipher.sealInviteSecretEnvelope(own, secret, chatId.bytes, inviteId).toInviteSecretEnvelope(), + ) + }.getOrElse { + pending.remove(slot) + return AttemptResult.Done(InviteCreation.Failed(it)) + } + val (token, keyEnvelope, secretEnvelope) = sealed + + val result = chatController.createInvite( + chatId = chatId, + token = token, + keyEnvelope = keyEnvelope, + secretEnvelope = secretEnvelope, + maxUses = settings.uses.maxUses, + expiresAt = settings.expiry.duration?.let { clock.now() + it }, + ) + val invite = result.getOrElse { error -> + if (error is CancellationException) throw error + if (error.isAnswered()) pending.remove(slot) + return when (error) { + is CreateInviteError.TooManyInvites -> AttemptResult.Done(InviteCreation.TooMany) + is CreateInviteError.Denied -> AttemptResult.Done(InviteCreation.NotAvailable) + is CreateInviteError.InvalidExpiresAt -> AttemptResult.ExpiryRefused + else -> AttemptResult.Done(InviteCreation.Failed(error)) + } + } + pending.remove(slot) + if (invite.status != Invite.Status.ACTIVE) return AttemptResult.NotActive + return AttemptResult.Created(invite, InviteLink.url(secret)) + } + + // runCatching that lets a cancelled caller cancel instead of reporting it as a failure. + private inline fun catching(block: () -> T): Result = try { + Result.success(block()) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Result.failure(e) + } + + private fun linkFor(invite: Invite): String? { + val own = keys.ownKeyPair() ?: return null + val payload = invite.secretEnvelope?.toPayload() ?: return null + return runCatching { + val secret = cipher.openInviteSecretEnvelope(own, invite.chatId.bytes, invite.id.bytes, payload) + // A secret for another invite would share a link that leads somewhere else. + check(cipher.inviteId(secret).contentEquals(invite.id.bytes)) + InviteLink.url(secret) + }.getOrNull() + } + + companion object { + /** How long Manage Invites keeps showing a link after it stops letting people in (spec Open 9). */ + val InactiveShownFor: Duration = 30.days + + internal fun currentOf(list: List, viewer: ID?): HostInvite? = + list.firstOrNull { it.isActive && it.canShare && it.invite.createdBy == viewer } + + /** + * Manage Invites' sections: ACTIVE links, then the rest, each newest first. An inactive link + * is hidden once [InactiveShownFor] has passed since it ended, which for an expired link is + * its expiry and otherwise, since the contract records no revoke or exhaust time, its + * creation. + */ + fun sections(list: List, now: Instant): Pair, List> { + val (active, inactive) = list.partition { it.isActive } + val shown = inactive.filter { item -> + val ended = when (item.invite.status) { + Invite.Status.EXPIRED -> item.invite.expiresAt ?: item.invite.createdAt + else -> item.invite.createdAt + } + now - ended <= InactiveShownFor + } + return active to shown + } + + /** Whether `GetChatInvites` or `RevokeInvite` failed because the viewer can no longer invite. */ + fun Throwable.isLostInviteAccess(): Boolean = + this is GetChatInvitesError.Denied || this is RevokeInviteError.Denied + + // The server answered, so the token is spent either way and a retry needs a new secret. + private fun Throwable.isAnswered(): Boolean = when (this) { + is CreateInviteError.Denied, + is CreateInviteError.NotFound, + is CreateInviteError.InvalidExpiresAt, + is CreateInviteError.TooManyInvites, + is CreateInviteError.InvalidSignature -> true + else -> false + } + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index 4558d821fd..735ede2f93 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -11,6 +11,7 @@ import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.HostInvites import com.flipcash.shared.chat.LobbyUpdateSink import com.flipcash.shared.chat.PaymentRequests import com.flipcash.shared.chat.RosterUpdateSink @@ -107,6 +108,7 @@ class RealChatCoordinator @Inject constructor( private val lobbyUpdates: LobbyUpdateSink = LobbyUpdateSink.None, private val rosterUpdates: RosterUpdateSink = RosterUpdateSink.None, private val paymentRequests: PaymentRequests? = null, + private val hostInvites: HostInvites? = null, ) : ChatCoordinator, SessionListener, DefaultLifecycleObserver, @@ -436,6 +438,7 @@ class RealChatCoordinator @Inject constructor( lobbyUpdates.clear() rosterUpdates.clear() paymentRequests?.clear() + hostInvites?.clear() cluster.value = null supervisorJob.cancel() trace(tag = TAG, message = "teardown complete", type = TraceType.Process) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt index 179da505f6..0836686397 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt @@ -25,6 +25,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow @@ -54,6 +55,7 @@ class ChatCoordinatorTeardownTest { private lateinit var draftStore: ChatDraftStore private lateinit var archiveStore: ChatArchiveStore private lateinit var groupFeedDelegate: GroupFeedDelegate + private lateinit var hostInvites: HostInvites private lateinit var coordinator: RealChatCoordinator @Before @@ -75,6 +77,7 @@ class ChatCoordinatorTeardownTest { draftStore = mockk(relaxed = true) archiveStore = mockk(relaxed = true) groupFeedDelegate = mockk(relaxed = true) + hostInvites = mockk(relaxed = true) val messagingController = mockk(relaxed = true) val stateHolder = ChatStateHolder() @@ -126,6 +129,7 @@ class ChatCoordinatorTeardownTest { groupFeedDelegate = groupFeedDelegate, reactionsDelegate = mockk(relaxed = true), archiveStore = archiveStore, + hostInvites = hostInvites, ) } @@ -150,6 +154,14 @@ class ChatCoordinatorTeardownTest { coVerify(exactly = 0) { draftStore.clearAll() } } + /** The invites belong to the account, and the links among them are as good as the chat key. */ + @Test + fun `teardown drops the held invites`() = runTest { + coordinator.teardown() + + verify(exactly = 1) { hostInvites.clear() } + } + @Test fun `clearCache erases metadata, messages, and members`() = runTest { coordinator.clearCache() diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt new file mode 100644 index 0000000000..c73d7bc47d --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostInvitesTest.kt @@ -0,0 +1,487 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.chat.ChatKeySource +import com.flipcash.services.chat.FakeInviteCipher +import com.flipcash.services.chat.GroupKeySource +import com.flipcash.services.chat.toInviteSecretEnvelope +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.CreateInviteError +import com.flipcash.services.models.GetChatInvitesError +import com.flipcash.services.models.RevokeInviteError +import com.flipcash.services.models.chat.InviteSecretEnvelope +import com.flipcash.shared.chat.HostInvites.Companion.isLostInviteAccess +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.chat.InviteId +import com.flipcash.services.models.chat.InviteToken +import com.flipcash.services.models.chat.InvitesPage +import com.flipcash.services.models.chat.ViewerState +import com.getcode.chatcipher.InviteCipher +import com.getcode.chatcipher.InviteLink +import com.getcode.ed25519kmp.KeyPair +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +class HostInvitesTest { + + private val now = Instant.fromEpochSeconds(1_800_000_000) + private val clock = object : Clock { + override fun now() = now + } + private val chatId = ChatId(ByteArray(32) { 7 }) + private val self = List(32) { 1.toByte() } + private val other = List(32) { 2.toByte() } + private val ownKeys = KeyPair(publicKey = ByteArray(32) { 11 }, privateKey = ByteArray(64) { 12 }) + private val keys = object : ChatKeySource { + override fun ownKeyPair() = ownKeys + override fun ownUserId() = self + override suspend fun peerPublicKey(userId: List) = Result.success(ByteArray(32)) + } + private val groupKeys = GroupKeySource { Result.success(ByteArray(32) { 9 }) } + private val cipher = RecordingInviteCipher() + private val chatController = mockk() + private val subject = HostInvites(chatController, groupKeys, keys, cipher, clock) + + /** An invite as the server would list it, with a secret envelope this device can open. */ + private fun invite( + secret: ByteArray, + status: Invite.Status = Invite.Status.ACTIVE, + createdAt: Instant = now, + expiresAt: Instant? = null, + createdBy: List = self, + openable: Boolean = true, + ): Invite { + val id = cipher.inviteId(secret) + val envelope = cipher.sealInviteSecretEnvelope( + if (openable) ownKeys else KeyPair(ByteArray(32) { 99 }, ByteArray(64)), + secret, + chatId.bytes, + id, + ).toInviteSecretEnvelope() + return Invite( + id = InviteId(id), + chatId = chatId, + createdBy = createdBy, + maxUses = 0, + useCount = 0, + createdAt = createdAt, + expiresAt = expiresAt, + status = status, + secretEnvelope = envelope, + ) + } + + private fun secret(n: Int) = ByteArray(32) { (100 + n).toByte() } + + @Test + fun `load pages to the end and keeps newest first`() = runTest { + val older = invite(secret(1), createdAt = now - 2.days) + val newer = invite(secret(2), createdAt = now - 1.days) + coEvery { chatController.getChatInvites(chatId, QueryOptions()) } returns + Result.success(InvitesPage(listOf(older), pagingToken = listOf(1), hasMore = true)) + coEvery { chatController.getChatInvites(chatId, QueryOptions(token = listOf(1))) } returns + Result.success(InvitesPage(listOf(newer), pagingToken = null, hasMore = false)) + + val list = subject.load(chatId).getOrThrow() + + assertEquals(listOf(newer.id, older.id), list.map { it.invite.id }) + assertEquals(InviteLink.url(secret(2)), list.first().url) + } + + @Test + fun `current is the newest active link the viewer made and can open`() = runTest { + val unopenable = invite(secret(1), createdAt = now, openable = false) + val someoneElses = invite(secret(2), createdAt = now - 1.days, createdBy = other) + val revoked = invite(secret(3), createdAt = now - 2.days, status = Invite.Status.REVOKED) + val mine = invite(secret(4), createdAt = now - 3.days) + coEvery { chatController.getChatInvites(chatId, any()) } returns + Result.success(InvitesPage(listOf(unopenable, someoneElses, revoked, mine), null, false)) + + val list = subject.load(chatId).getOrThrow() + + assertFalse(list.first().canShare) + assertEquals(mine.id, subject.current(chatId)?.invite?.id) + } + + @Test + fun `create seals both envelopes and sends the token of one fresh secret`() = runTest { + val token = slot() + coEvery { + chatController.createInvite(chatId, capture(token), any(), any(), 0, now + 7.days) + } answers { + Result.success(invite(cipher.last)) + } + + val result = subject.create(chatId, InviteSettings.Default) + + val created = assertIs(result).invite + assertEquals(InviteLink.url(cipher.last), created.url) + assertTrue(token.captured.bytes.contentEquals(cipher.inviteToken(cipher.last))) + assertEquals(created.invite.id, subject.current(chatId)?.invite?.id) + } + + @Test + fun `a transport failure keeps the secret so the retry sends the same token`() = runTest { + val tokens = mutableListOf() + coEvery { chatController.createInvite(chatId, capture(tokens), any(), any(), any(), any()) } returnsMany listOf( + Result.failure(CreateInviteError.Other(IOException("dropped"))), + Result.success(invite(secret(0))), + ) + + assertIs(subject.create(chatId, InviteSettings.Default)) + subject.create(chatId, InviteSettings.Default) + + assertEquals(tokens[0], tokens[1]) + } + + @Test + fun `an answered refusal drops the secret`() = runTest { + val tokens = mutableListOf() + coEvery { chatController.createInvite(chatId, capture(tokens), any(), any(), any(), any()) } returns + Result.failure(CreateInviteError.TooManyInvites()) + + assertEquals(InviteCreation.TooMany, subject.create(chatId, InviteSettings.Default)) + subject.create(chatId, InviteSettings.Default) + + assertTrue(tokens[0] != tokens[1]) + } + + @Test + fun `denied means not available and is not retried`() = runTest { + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } returns + Result.failure(CreateInviteError.Denied()) + + assertEquals(InviteCreation.NotAvailable, subject.create(chatId, InviteSettings.Default)) + coVerify(exactly = 1) { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } + } + + @Test + fun `invalid signature fails without retry`() = runTest { + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } returns + Result.failure(CreateInviteError.InvalidSignature()) + + val failed = assertIs(subject.create(chatId, InviteSettings.Default)) + assertIs(failed.cause) + coVerify(exactly = 1) { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } + } + + @Test + fun `an OK that is no longer active retries once with a new secret`() = runTest { + val tokens = mutableListOf() + coEvery { chatController.createInvite(chatId, capture(tokens), any(), any(), any(), any()) } answers { + val status = if (tokens.size == 1) Invite.Status.EXHAUSTED else Invite.Status.ACTIVE + Result.success(invite(cipher.last, status = status)) + } + + assertIs(subject.create(chatId, InviteSettings.Default)) + assertEquals(2, tokens.size) + assertTrue(tokens[0] != tokens[1]) + } + + @Test + fun `a refused expiry is retried once at the next shorter preset`() = runTest { + val expiries = mutableListOf() + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), captureNullable(expiries)) } answers { + if (expiries.size == 1) Result.failure(CreateInviteError.InvalidExpiresAt()) + else Result.success(invite(cipher.last)) + } + + subject.create(chatId, InviteSettings(InviteExpiry.Never, InviteUses.NoLimit)) + + assertEquals(listOf(null, now + 30.days), expiries) + } + + @Test + fun `a refused expiry that is refused again fails with the expiry error`() = runTest { + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } returns + Result.failure(CreateInviteError.InvalidExpiresAt()) + + val failed = assertIs( + subject.create(chatId, InviteSettings(InviteExpiry.Never, InviteUses.NoLimit)) + ) + assertIs(failed.cause) + coVerify(exactly = 2) { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } + } + + @Test + fun `the shortest expiry has nothing shorter to retry at`() = runTest { + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } returns + Result.failure(CreateInviteError.InvalidExpiresAt()) + + assertIs(subject.create(chatId, InviteSettings(InviteExpiry.OneDay, InviteUses.NoLimit))) + coVerify(exactly = 1) { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } + } + + @Test + fun `an envelope that opens to another invite's secret gives no link`() = runTest { + val real = invite(secret(1)) + // Sealed under this invite's id but holding a different secret, so it opens and then fails the id check. + val id = real.id.bytes + val forged = real.copy( + secretEnvelope = cipher.sealInviteSecretEnvelope(ownKeys, secret(2), chatId.bytes, id) + .toInviteSecretEnvelope(), + ) + coEvery { chatController.getChatInvites(chatId, any()) } returns + Result.success(InvitesPage(listOf(forged), null, false)) + + val item = subject.load(chatId).getOrThrow().single() + + assertNull(item.url) + assertFalse(item.canShare) + assertNull(subject.current(chatId)) + } + + @Test + fun `an envelope with an unsupported scheme gives no link`() = runTest { + val real = invite(secret(1)) + val old = real.secretEnvelope!! + val odd = real.copy(secretEnvelope = InviteSecretEnvelope(99, old.nonce, old.ciphertext)) + coEvery { chatController.getChatInvites(chatId, any()) } returns + Result.success(InvitesPage(listOf(odd), null, false)) + + val item = subject.load(chatId).getOrThrow().single() + + assertNull(item.url) + assertFalse(item.canShare) + assertNull(subject.current(chatId)) + } + + @Test + fun `currentOrCreate reuses an existing link without calling createInvite`() = runTest { + val mine = invite(secret(1)) + coEvery { chatController.getChatInvites(chatId, any()) } returns Result.success(InvitesPage(listOf(mine), null, false)) + subject.load(chatId) + + val result = assertIs(subject.currentOrCreate(chatId)) + + assertEquals(mine.id, result.invite.invite.id) + coVerify(exactly = 0) { chatController.createInvite(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `two currentOrCreate calls at once make one link`() = runTest { + val gate = CompletableDeferred() + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } coAnswers { + gate.await() + Result.success(invite(cipher.last)) + } + + val both = (1..2).map { async { subject.currentOrCreate(chatId) } } + runCurrent() + gate.complete(Unit) + val results = both.awaitAll() + + assertTrue(results.all { it is InviteCreation.Created }) + coVerify(exactly = 1) { chatController.createInvite(any(), any(), any(), any(), any(), any()) } + assertEquals(1, subject.state.value[chatId]?.size) + } + + @Test + fun `concurrent creates run one after the other`() = runTest { + val gate = CompletableDeferred() + var inFlight = 0 + var peak = 0 + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } coAnswers { + inFlight++ + peak = maxOf(peak, inFlight) + gate.await() + inFlight-- + Result.success(invite(cipher.last)) + } + + val both = (1..2).map { async { subject.create(chatId, InviteSettings.Default) } } + runCurrent() + assertEquals(1, inFlight) + gate.complete(Unit) + both.awaitAll() + + assertEquals(1, peak) + coVerify(exactly = 2) { chatController.createInvite(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `the server returning the same invite twice adds one row`() = runTest { + val same = invite(secret(5)) + coEvery { chatController.createInvite(chatId, any(), any(), any(), any(), any()) } returns Result.success(same) + + subject.create(chatId, InviteSettings.Default) + subject.create(chatId, InviteSettings.Default) + + assertEquals(listOf(same.id), subject.state.value[chatId]?.map { it.invite.id }) + } + + @Test + fun `clear between a failure and a retry makes a new secret`() = runTest { + val tokens = mutableListOf() + coEvery { chatController.createInvite(chatId, capture(tokens), any(), any(), any(), any()) } returnsMany listOf( + Result.failure(CreateInviteError.Other(IOException("dropped"))), + Result.success(invite(secret(0))), + ) + + subject.create(chatId, InviteSettings.Default) + subject.clear() + subject.create(chatId, InviteSettings.Default) + + assertNotEquals(tokens[0], tokens[1]) + } + + @Test + fun `a revoke failure leaves the invite as it was`() = runTest { + val mine = invite(secret(1)) + coEvery { chatController.getChatInvites(chatId, any()) } returns Result.success(InvitesPage(listOf(mine), null, false)) + coEvery { chatController.revokeInvite(chatId, mine.id) } returns Result.failure(RevokeInviteError.Other(IOException())) + subject.load(chatId) + + assertTrue(subject.revoke(chatId, mine.id).isFailure) + + assertEquals(Invite.Status.ACTIVE, subject.state.value[chatId]?.single()?.invite?.status) + assertEquals(mine.id, subject.current(chatId)?.invite?.id) + } + + @Test + fun `a load failure keeps what was loaded before`() = runTest { + val mine = invite(secret(1)) + coEvery { chatController.getChatInvites(chatId, any()) } returnsMany listOf( + Result.success(InvitesPage(listOf(mine), null, false)), + Result.failure(GetChatInvitesError.Other(IOException())), + ) + subject.load(chatId) + + assertTrue(subject.load(chatId).isFailure) + + assertEquals(listOf(mine.id), subject.state.value[chatId]?.map { it.invite.id }) + } + + @Test + fun `only a denied list or revoke means the viewer lost access`() { + assertTrue(GetChatInvitesError.Denied().isLostInviteAccess()) + assertTrue(RevokeInviteError.Denied().isLostInviteAccess()) + assertFalse(CreateInviteError.Denied().isLostInviteAccess()) + assertFalse(GetChatInvitesError.NotFound().isLostInviteAccess()) + assertFalse(RevokeInviteError.NotFound().isLostInviteAccess()) + assertFalse(GetChatInvitesError.Other(IOException()).isLostInviteAccess()) + assertFalse(IOException().isLostInviteAccess()) + } + + @Test + fun `a cancelled load throws instead of returning a failure`() = runTest { + coEvery { chatController.getChatInvites(chatId, any()) } throws CancellationException("cancelled") + + assertFailsWith { subject.load(chatId) } + assertNull(subject.state.value[chatId]) + } + + @Test + fun `a cancelled load result throws too`() = runTest { + coEvery { chatController.getChatInvites(chatId, any()) } returns Result.failure(CancellationException("cancelled")) + + assertFailsWith { subject.load(chatId) } + } + + @Test + fun `a cancelled create is not reported as a failure and keeps its secret`() = runTest { + val tokens = mutableListOf() + coEvery { chatController.createInvite(chatId, capture(tokens), any(), any(), any(), any()) } throws + CancellationException("cancelled") andThen Result.success(invite(secret(0))) + + assertFailsWith { subject.create(chatId, InviteSettings.Default) } + assertIs(subject.create(chatId, InviteSettings.Default)) + + assertEquals(tokens[0], tokens[1]) + } + + @Test + fun `revoke marks the invite revoked so it is no longer current`() = runTest { + val mine = invite(secret(1)) + coEvery { chatController.getChatInvites(chatId, any()) } returns Result.success(InvitesPage(listOf(mine), null, false)) + coEvery { chatController.revokeInvite(chatId, mine.id) } returns Result.success(Unit) + subject.load(chatId) + + subject.revoke(chatId, mine.id).getOrThrow() + + assertNull(subject.current(chatId)) + assertEquals(Invite.Status.REVOKED, subject.state.value[chatId]?.single()?.invite?.status) + } + + @Test + fun `sections hide inactive links that ended more than 30 days ago`() { + val active = HostInvite(invite(secret(1)), url = "a") + val recentRevoke = HostInvite(invite(secret(2), status = Invite.Status.REVOKED, createdAt = now - 10.days), null) + val oldRevoke = HostInvite(invite(secret(3), status = Invite.Status.REVOKED, createdAt = now - 31.days), null) + val expiredRecently = HostInvite( + invite(secret(4), status = Invite.Status.EXPIRED, createdAt = now - 60.days, expiresAt = now - 5.days), + null, + ) + + val (shownActive, shownInactive) = HostInvites.sections(listOf(active, recentRevoke, oldRevoke, expiredRecently), now) + + assertEquals(listOf(active), shownActive) + assertEquals(listOf(recentRevoke, expiredRecently), shownInactive) + } + + @Test + fun `refreshAccess keeps the server's can_invite until a denial takes it away`() = runTest { + coEvery { chatController.getChat(chatId, any()) } returns Result.success( + ChatMetadata( + chatId = chatId, + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = now, + viewerState = ViewerState(permissions = ViewerState.Permissions(canInvite = true)), + ), + ) + + assertTrue(subject.refreshAccess(chatId).getOrThrow()) + assertEquals(true, subject.canInvite.value[chatId]) + + subject.revokeAccess(chatId) + assertEquals(false, subject.canInvite.value[chatId]) + } + + @Test + fun `a failed refreshAccess leaves what was known`() = runTest { + subject.revokeAccess(chatId) + coEvery { chatController.getChat(chatId, any()) } returns Result.failure(IOException("offline")) + + assertTrue(subject.refreshAccess(chatId).isFailure) + assertEquals(false, subject.canInvite.value[chatId]) + } + + @Test + fun `a host invite never prints its link`() { + val item = HostInvite(invite(secret(1)), InviteLink.url(secret(1))) + assertFalse(item.toString().contains("#s=")) + } +} + +/** Remembers the secret of the most recent create attempt. */ +private class RecordingInviteCipher( + private val fake: FakeInviteCipher = FakeInviteCipher(), +) : InviteCipher by fake { + lateinit var last: ByteArray + override fun newSecret(): ByteArray = fake.newSecret().also { last = it } +} diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt index 7827617c05..d6fae2d769 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt @@ -67,6 +67,9 @@ sealed interface Shareable { val imageCacheKey: String? = null, ) : Shareable { override val pendingData: ShareablePendingData? = null + + // A private group's invite link carries its secret in the fragment; never print the url. + override fun toString(): String = "GroupInvite(title=$title)" } /** diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt index f17616aac7..af2b07938d 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp @@ -35,12 +36,14 @@ fun ChoiceRow( label: String, icon: Int, modifier: Modifier = Modifier, + contentColor: Color = CodeTheme.colors.textMain, onClick: () -> Unit, ) { ChoiceRow( label = label, icon = painterResource(icon), modifier = modifier, + contentColor = contentColor, onClick = onClick, ) } @@ -54,6 +57,8 @@ fun ChoiceRow( label: String, icon: Painter, modifier: Modifier = Modifier, + /** Icon and label colour; a destructive choice passes the error text colour. */ + contentColor: Color = CodeTheme.colors.textMain, onClick: () -> Unit, ) { Row( @@ -70,13 +75,13 @@ fun ChoiceRow( modifier = Modifier.size(IconSize), painter = icon, contentDescription = null, - tint = CodeTheme.colors.textMain, + tint = contentColor, ) Text( text = label, // 17sp Demi, matching the settings rows rather than the 16sp of `textMedium`. style = CodeTheme.typography.textMedium.copy(fontSize = 17.sp, lineHeight = 22.sp), - color = CodeTheme.colors.textMain, + color = contentColor, ) } } diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/SlidingChipRow.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/SlidingChipRow.kt new file mode 100644 index 0000000000..1578a54b54 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/SlidingChipRow.kt @@ -0,0 +1,183 @@ +package com.getcode.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +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.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import com.getcode.theme.CodeTheme +import com.getcode.theme.DesignSystem +import com.getcode.theme.White +import com.getcode.theme.White10 +import kotlinx.coroutines.launch + +/** + * A row of preset chips with one white highlight that slides to the chosen chip, and fades out + * when [selected] is null (a choice none of the chips stands for). The chips share the row's width + * equally, as iOS's SlidingChipPicker does. A label too wide for its chip shrinks by up to 20%, + * like iOS's minimumScaleFactor(0.8), and past that wraps at the space, so none is cut. Every chip + * takes the height of the tallest, so the capsules stay uniform. + * + * With [reducedMotion] on, the highlight moves and fades straight to its new place. + */ +@Composable +fun SlidingChipRow( + options: List, + selected: T?, + onSelect: (T) -> Unit, + label: @Composable (T) -> String, + modifier: Modifier = Modifier, + reducedMotion: Boolean = false, +) { + val bounds = remember { mutableStateMapOf() } + val highlight = remember { Animatable(Rect.Zero, Rect.VectorConverter) } + val shown = remember { Animatable(0f) } + var placed by remember { mutableStateOf(false) } + val target = selected?.let { bounds[it] } + val labels = options.map { label(it) } + val style = CodeTheme.typography.caption + val measurer = rememberTextMeasurer() + LaunchedEffect(selected, target) { + val spec = chipMotion(reducedMotion) + when { + selected == null -> shown.animateTo(0f, spec) + target == null -> Unit + !placed -> { + highlight.snapTo(target) + shown.snapTo(1f) + placed = true + } + else -> { + launch { highlight.animateTo(target, chipMotion(reducedMotion)) } + shown.animateTo(1f, spec) + } + } + } + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val gap = 6.dp + val textWidth = with(LocalDensity.current) { + ((maxWidth - gap * (options.size - 1)) / options.size - ChipPadding * 2).toPx().toInt() + } + val styles = remember(labels, style, textWidth, measurer) { + labels.map { fitStyle(it, style, textWidth, measurer) } + } + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .selectableGroup() + .drawBehind { + bounds.values.forEach { chip -> + drawRoundRect(White10, chip.topLeft, chip.size, CornerRadius(chip.height / 2)) + } + val chip = highlight.value + drawRoundRect(White, chip.topLeft, chip.size, CornerRadius(chip.height / 2), alpha = shown.value) + }, + horizontalArrangement = Arrangement.spacedBy(gap), + ) { + options.forEachIndexed { index, option -> + val isSelected = option == selected + val color by animateColorAsState( + targetValue = if (isSelected) Color.Black else CodeTheme.colors.textMain, + animationSpec = chipMotion(reducedMotion), + label = "chip text", + ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .onPlaced { bounds[option] = Rect(it.positionInParent(), it.size.toSize()) } + .clip(CircleShape) + .selectable(selected = isSelected, role = Role.RadioButton) { onSelect(option) } + .padding(horizontal = ChipPadding, vertical = 7.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = labels[index], + style = styles[index], + color = color, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +private val ChipPadding = 6.dp + +/** + * The largest of [style]'s size down to 80% at which [text] fits [width] on one line; failing + * that, 80%, at which the label wraps. + */ +private fun fitStyle(text: String, style: TextStyle, width: Int, measurer: TextMeasurer): TextStyle { + val sizes = (100 downTo 80 step 2).map { style.copy(fontSize = style.fontSize * (it / 100f)) } + return sizes.firstOrNull { candidate -> + val layout = measurer.measure( + text = text, + style = candidate, + maxLines = 1, + softWrap = false, + constraints = Constraints(maxWidth = width.coerceAtLeast(0)), + ) + !layout.didOverflowWidth + } ?: sizes.last() +} + +private fun chipMotion(reducedMotion: Boolean): AnimationSpec = + if (reducedMotion) snap() else spring(Spring.DampingRatioNoBouncy, Spring.StiffnessMediumLow) + +@Preview +@Composable +private fun SlidingChipRowPreview() { + DesignSystem { + var selected by remember { mutableStateOf("7 days") } + SlidingChipRow( + options = listOf("1 day", "7 days", "30 days", "Never"), + selected = selected, + onSelect = { selected = it }, + label = { it }, + ) + } +} diff --git a/ui/components/src/test/kotlin/com/getcode/ui/components/SlidingChipRowTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/SlidingChipRowTest.kt new file mode 100644 index 0000000000..e4eeb430a0 --- /dev/null +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/SlidingChipRowTest.kt @@ -0,0 +1,176 @@ +package com.getcode.ui.components + +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// Wide enough for the widest row the tests lay out. +@Config(qualifiers = "w411dp-h891dp") +// Measures real text, so the fit test sees the labels' true widths. +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@RunWith(RobolectricTestRunner::class) +class SlidingChipRowTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val options = listOf("1 day", "7 days", "30 days") + + @Test + fun `the selected chip is selected and the others are not`() { + composeTestRule.setContent { + DesignSystem { + SlidingChipRow(options = options, selected = "7 days", onSelect = {}, label = { it }) + } + } + composeTestRule.onNodeWithText("7 days").assertIsSelected() + composeTestRule.onNodeWithText("1 day").assertIsNotSelected() + composeTestRule.onNodeWithText("30 days").assertIsNotSelected() + } + + @Test + fun `each chip is a radio button`() { + composeTestRule.setContent { + DesignSystem { + SlidingChipRow(options = options, selected = "1 day", onSelect = {}, label = { it }) + } + } + composeTestRule.onNodeWithText("30 days") + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.RadioButton)) + } + + @Test + fun `tapping a chip selects it`() { + val picks = mutableListOf() + composeTestRule.setContent { + DesignSystem { + var selected by remember { mutableStateOf("1 day") } + SlidingChipRow( + options = options, + selected = selected, + onSelect = { picks += it; selected = it }, + label = { it }, + ) + } + } + composeTestRule.onNodeWithText("30 days").performClick() + assertEquals(listOf("30 days"), picks) + composeTestRule.onNodeWithText("30 days").assertIsSelected() + composeTestRule.onNodeWithText("1 day").assertIsNotSelected() + } + + @Test + fun `no chip is selected for a choice none of them stands for`() { + composeTestRule.setContent { + DesignSystem { + SlidingChipRow(options = options, selected = null, onSelect = {}, label = { it }) + } + } + options.forEach { composeTestRule.onNodeWithText(it).assertIsNotSelected() } + } + + @Test + fun `the chips share the row's width equally`() { + composeTestRule.setContent { + DesignSystem { + SlidingChipRow( + options = listOf("1", "5", "10", "25", "No limit"), + selected = "1", + onSelect = {}, + label = { it }, + modifier = Modifier.width(371.dp), + ) + } + } + val widths = listOf("1", "5", "10", "25", "No limit") + .map { composeTestRule.onNodeWithText(it).getBoundsInRoot().let { b -> b.right - b.left } } + // Equal to the pixel: the row's 347dp of chips doesn't divide by five exactly. + assertTrue(widths.max() - widths.min() <= 1.dp, "chip widths $widths") + // Five chips and four 6dp gaps fill the row. + val filled = widths.reduce { a, b -> a + b } + 24.dp + assertTrue(filled in 370.dp..372.dp, "row filled to $filled") + } + + @Test + fun `no label is truncated at font scales up to 2 on a 411dp or a 360dp phone`() { + var case by mutableStateOf(Triple(labelSets.first(), 371, 1f)) + composeTestRule.setContent { + val density = LocalDensity.current + val (labels, rowWidth, fontScale) = case + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale)) { + DesignSystem { + SlidingChipRow( + options = labels, + selected = labels.first(), + onSelect = {}, + label = { it }, + modifier = Modifier.width(rowWidth.dp), + ) + } + } + } + // The row is the phone's width less the 20dp inset on each side. + for (rowWidth in listOf(371, 320)) { + for (fontScale in listOf(1f, 1.5f, 2f)) { + for (labels in labelSets) { + case = Triple(labels, rowWidth, fontScale) + composeTestRule.waitForIdle() + val where = "in a ${rowWidth}dp row at font scale $fontScale" + labels.forEach { label -> + val layout = textLayout(label) + val shown = label.substring(0, layout.getLineEnd(layout.lineCount - 1, visibleEnd = true)) + val widest = (0 until layout.lineCount).maxOf { layout.getLineRight(it) - layout.getLineLeft(it) } + assertTrue( + shown == label && !layout.didOverflowHeight && + widest <= layout.layoutInput.constraints.maxWidth, + "\"$label\" shows \"$shown\" $where", + ) + } + val heights = labels.map { composeTestRule.onNodeWithText(it).getBoundsInRoot().let { b -> b.bottom - b.top } } + assertEquals(1, heights.distinct().size, "chip heights $heights $where") + } + } + } + } + + private val labelSets = listOf( + listOf("1 day", "7 days", "30 days", "Never"), + listOf("1", "5", "10", "25", "No limit"), + listOf("1 Day", "3 Days", "1 Week", "2 Weeks", "4 Weeks"), + ) + + private fun textLayout(label: String): TextLayoutResult { + val results = mutableListOf() + composeTestRule.onNodeWithText(label, useUnmergedTree = true).fetchSemanticsNode() + .config[SemanticsActions.GetTextLayoutResult].action!!.invoke(results) + return results.single() + } +}