From 24a1e4afaea96fee9f28090e4f0dadc24a007f1f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 10:50:10 -0400 Subject: [PATCH 1/3] docs(tokens): BalanceOrder's note about iOS is out of date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment said iOS still sorted on the exact value and carried the same card swap latent in it. It doesn't: `StoredBalance.walletOrder` compares `displayedUSDF`, which is `usdf.roundedToSmallestUnit()`, and breaks ties by name — the same comparison at the same precision, recorded against the same reasoning. Comment only. Checked against the iOS working copy after the iOS session disputed the claim. --- .../kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt index 3758ebbabb..d930f95748 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SelectTokenViewModel.kt @@ -234,7 +234,8 @@ class SelectTokenViewModel @Inject constructor( * exact comparison had Dollars and Dad Cash trading places under the user while the deck was * on screen. Rounding first means cards showing the same figure hold a stable order. * - * iOS (Session.balances) still sorts on the exact value and has the same swap latent in it. + * iOS reaches the same answer the same way: `StoredBalance.walletOrder` compares + * `displayedUSDF` — `usdf.roundedToSmallestUnit()` — and breaks ties by name. */ val BalanceOrder: Comparator = compareByDescending { it.balance.nativeAmount.toDouble() } From c9af21372c792a77b3f19bcd9eac1e2e193d63a3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 10:50:18 -0400 Subject: [PATCH 2/3] refactor(blob): share the policy-aware image resize between avatar screens `PhotoSelectionViewModel` held the loop that re-encodes a picked image inside the upload policy's dimension, pixel and byte caps. The group-avatar picker needs the same loop, and a second copy of a convergence loop is a second chance to shrink to just over a cap: the failure is a server rejection at upload time, not a visible bug at pick time. `ImageUploadPreparer` in `:apps:flipcash:shared:blob` takes the loop, the four constants and the `uploadMimeFor` mapping, and answers `Prepared`/`Unsupported`/ `TooLarge`/`Unreadable`. The refusal wording stays with the calling screen, which is the only part that was ever screen-specific. `:apps:flipcash:shared:blob` gains `:ui:resources` for `ContentReader`. --- .../internal/photo/PhotoSelectionViewModel.kt | 121 +++----------- apps/flipcash/shared/blob/build.gradle.kts | 1 + .../flipcash/app/blob/ImageUploadPreparer.kt | 155 ++++++++++++++++++ 3 files changed, 180 insertions(+), 97 deletions(-) create mode 100644 apps/flipcash/shared/blob/src/main/kotlin/com/flipcash/app/blob/ImageUploadPreparer.kt diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt index 63bd92d30d..f45841d306 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt @@ -4,11 +4,11 @@ import android.net.Uri import androidx.annotation.StringRes import androidx.lifecycle.viewModelScope import com.flipcash.app.blob.BlobStorageCoordinator +import com.flipcash.app.blob.ImageUploadPreparer import com.flipcash.app.core.data.Loadable import com.flipcash.app.core.data.isLoaded import com.flipcash.app.core.extensions.flatMapResult import com.flipcash.app.core.extensions.onResult -import com.flipcash.services.models.blob.ImageConstraints import com.flipcash.services.models.blob.UploadPolicy import com.flipcash.features.userprofile.R import com.flipcash.libs.coroutines.DispatcherProvider @@ -42,8 +42,6 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject -import kotlin.math.floor -import kotlin.math.sqrt import kotlin.time.Duration.Companion.milliseconds @HiltViewModel @@ -53,6 +51,7 @@ class PhotoSelectionViewModel @Inject constructor( private val moderationController: ModerationController, private val profileController: ProfileController, private val blobStorage: BlobStorageCoordinator, + private val imagePreparer: ImageUploadPreparer, private val resources: ResourceHelper, val contentReader: ContentReader, ) : BaseViewModel( @@ -153,39 +152,30 @@ class PhotoSelectionViewModel @Inject constructor( eventFlow .filterIsInstance() .mapNotNull { event -> - val sourceMime = contentReader.mimeType(event.image) - // The cache re-encodes to JPEG/PNG, so gate on the type we'd actually upload — - // not the source type, which may normalize into an accepted format (e.g. HEIC → PNG). - val uploadMime = uploadMimeFor(sourceMime) - val policy = stateFlow.value.uploadPolicy - val constraints = policy?.constraintsFor(uploadMime) - if (policy != null && constraints == null) { - rejectImage( - title = R.string.error_title_imageNotSupported, - message = R.string.error_description_imageNotSupported, - ) - return@mapNotNull null - } - // Re-encode within the policy's dimension + pixel caps, then keep shrinking the - // longest edge until the bytes fit maxSizeBytes — resize to fit, don't reject. - val maxBytes = constraints?.maxSizeBytes - val cached = cacheWithinPolicy( + // Re-encode within the policy's dimension + pixel caps and shrink to its byte + // ceiling. The wording of a refusal is this screen's; the loop is not. + when (val outcome = imagePreparer.prepare( uri = event.image, - sourceMime = sourceMime, - image = constraints?.image, - maxBytes = maxBytes, - ) ?: return@mapNotNull null - // Last resort: if even the smallest re-encode can't meet the byte ceiling, reject. - if (maxBytes != null && (contentReader.size(cached) ?: 0L) > maxBytes) { - contentReader.removeFromCache(cached) - rejectImage( - title = R.string.error_title_imageTooLarge, - message = R.string.error_description_imageTooLarge, - ) - return@mapNotNull null + policy = stateFlow.value.uploadPolicy, + fileNamePrefix = "user_profile", + )) { + is ImageUploadPreparer.Outcome.Prepared -> outcome.uri to outcome.mimeType + ImageUploadPreparer.Outcome.Unsupported -> { + rejectImage( + title = R.string.error_title_imageNotSupported, + message = R.string.error_description_imageNotSupported, + ) + null + } + ImageUploadPreparer.Outcome.TooLarge -> { + rejectImage( + title = R.string.error_title_imageTooLarge, + message = R.string.error_description_imageTooLarge, + ) + null + } + ImageUploadPreparer.Outcome.Unreadable -> null } - // The cache re-encodes (stripping EXIF); declare the type those bytes actually are. - cached to uploadMime } .flowOn(dispatchers.IO) .onEach { (cached, mime) -> dispatchEvent(Event.OnImageCached(cached, mime)) } @@ -255,57 +245,6 @@ class PhotoSelectionViewModel @Inject constructor( ) } - /** - * Re-encodes [uri] into the cache honoring [image]'s dimension caps, then shrinks the - * longest-edge target until the output fits [maxBytes] — resizing to fit rather than rejecting. - * Returns null only if re-encoding fails outright; otherwise the smallest attempt (which the - * caller re-checks, since a byte ceiling smaller than [MIN_MAX_EDGE] can produce is pathological). - */ - private fun cacheWithinPolicy( - uri: Uri, - sourceMime: String?, - image: ImageConstraints?, - maxBytes: Long?, - ): Uri? { - var edge = maxEdgeFor(image) - var last: Uri? = null - repeat(MAX_RESIZE_ATTEMPTS) { - // Drop the previous over-ceiling attempt before making a smaller one. - last?.let { contentReader.removeFromCache(it) } - val candidate = contentReader.copyToCache( - uri = uri, - fileName = "user_profile_${System.nanoTime()}", - maxSize = edge, - mimeType = sourceMime, - ) ?: return null - last = candidate - val size = contentReader.size(candidate) ?: 0L - if (maxBytes == null || size <= maxBytes) return candidate - // Encoded bytes track pixel area (edge²); scale the edge by √(ceiling/actual) with a - // safety margin to converge, floored at MIN_MAX_EDGE. - val next = floor(edge * sqrt(maxBytes.toDouble() / size) * RESIZE_SAFETY) - .toInt() - .coerceAtLeast(MIN_MAX_EDGE) - if (next >= edge) return candidate // already at the floor — hand back the best effort - edge = next - } - return last - } - - /** - * The longest-edge cap that satisfies every dimension constraint in [image]: the smallest of - * maxWidth, maxHeight, and √maxPixels (bounding the longest edge by √maxPixels keeps total area - * ≤ maxPixels). Falls back to [DEFAULT_MAX_EDGE] when the policy names no image constraints. - */ - private fun maxEdgeFor(image: ImageConstraints?): Int { - val caps = listOfNotNull( - image?.maxWidth, - image?.maxHeight, - image?.maxPixels?.let { floor(sqrt(it.toDouble())).toInt() }, - ).filter { it > 0 } - return caps.minOrNull() ?: DEFAULT_MAX_EDGE - } - private fun handleUploadFailure(cause: Throwable) { when (cause) { is BlobRejectedException -> { @@ -417,18 +356,6 @@ class PhotoSelectionViewModel @Inject constructor( companion object { - // Longest-edge downscale target used when the upload policy specifies no dimension caps. - private const val DEFAULT_MAX_EDGE = 500 - - // Floor for the resize-to-fit loop — below this a profile image is no longer worth keeping. - private const val MIN_MAX_EDGE = 64 - - // How many times to shrink-and-retry before handing back the smallest attempt. - private const val MAX_RESIZE_ATTEMPTS = 5 - - // Under-shoot the estimated fitting edge so re-encode overhead doesn't push us back over. - private const val RESIZE_SAFETY = 0.9 - internal val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { Event.ConfirmImageChange -> { state -> state } diff --git a/apps/flipcash/shared/blob/build.gradle.kts b/apps/flipcash/shared/blob/build.gradle.kts index 2df4e04167..213b86c547 100644 --- a/apps/flipcash/shared/blob/build.gradle.kts +++ b/apps/flipcash/shared/blob/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(project(":libs:coroutines")) implementation(project(":services:flipcash")) + implementation(project(":ui:resources")) testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) diff --git a/apps/flipcash/shared/blob/src/main/kotlin/com/flipcash/app/blob/ImageUploadPreparer.kt b/apps/flipcash/shared/blob/src/main/kotlin/com/flipcash/app/blob/ImageUploadPreparer.kt new file mode 100644 index 0000000000..c46e4e2838 --- /dev/null +++ b/apps/flipcash/shared/blob/src/main/kotlin/com/flipcash/app/blob/ImageUploadPreparer.kt @@ -0,0 +1,155 @@ +package com.flipcash.app.blob + +import android.net.Uri +import com.flipcash.services.models.blob.ImageConstraints +import com.flipcash.services.models.blob.UploadPolicy +import com.getcode.util.resources.ContentReader +import com.getcode.util.resources.uploadMimeFor +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.floor +import kotlin.math.sqrt + +/** + * Turns a picked image into bytes the server will accept, or says why it cannot. + * + * The work is the same wherever the app uploads a picture — a profile photo, a group avatar — and + * it is not a one-liner: the picked file has to be re-encoded to a type the policy names, then + * shrunk until it fits that type's byte ceiling. Two copies of that loop would be two chances to + * get the convergence wrong, and the failure is silent (an upload the server refuses) rather than a + * crash, so it sits here once. + * + * What it does *not* decide is what the user is told. [Outcome.Unsupported] and [Outcome.TooLarge] + * are the two refusals a caller must render, and the wording belongs to the screen the pick came + * from — a group avatar and a profile photo do not say the same thing about the same file. + */ +@Singleton +class ImageUploadPreparer @Inject constructor( + private val contentReader: ContentReader, +) { + sealed interface Outcome { + /** + * [uri] is a cached, re-encoded copy declaring [mimeType], ready for + * [BlobStorageCoordinator.upload]. + * + * The caller owns the file from here: nothing else deletes it, so it must be handed to + * `ContentReader.removeFromCache` once uploaded or abandoned. + */ + data class Prepared(val uri: Uri, val mimeType: String) : Outcome + + /** The policy names no constraints for what this file re-encodes to. */ + data object Unsupported : Outcome + + /** Even the smallest re-encode is over the byte ceiling. Pathological, but reachable. */ + data object TooLarge : Outcome + + /** The file could not be read or re-encoded at all. */ + data object Unreadable : Outcome + } + + /** + * Prepares [uri] for upload under [policy], writing the result into the cache under a name + * derived from [fileNamePrefix]. + * + * Blocking — decode and re-encode — so call it off the main dispatcher. + * + * A null [policy] means the launch preload has not landed yet. The pick is still re-encoded, at + * [DEFAULT_MAX_EDGE], because the server rather than the cache is the authority on what it + * accepts; refusing the pick because the advisory copy is missing would be worse than trying. + */ + fun prepare( + uri: Uri, + policy: UploadPolicy?, + fileNamePrefix: String, + ): Outcome { + val sourceMime = contentReader.mimeType(uri) + // The cache re-encodes to JPEG/PNG, so gate on the type we would actually upload — not the + // source type, which may normalize into an accepted format (e.g. HEIC -> PNG). + val uploadMime = uploadMimeFor(sourceMime) + val constraints = policy?.constraintsFor(uploadMime) + if (policy != null && constraints == null) return Outcome.Unsupported + + val maxBytes = constraints?.maxSizeBytes + val cached = cacheWithinPolicy( + uri = uri, + sourceMime = sourceMime, + fileNamePrefix = fileNamePrefix, + image = constraints?.image, + maxBytes = maxBytes, + ) ?: return Outcome.Unreadable + + if (maxBytes != null && (contentReader.size(cached) ?: 0L) > maxBytes) { + contentReader.removeFromCache(cached) + return Outcome.TooLarge + } + // The cache re-encodes (stripping EXIF); declare the type those bytes actually are. + return Outcome.Prepared(uri = cached, mimeType = uploadMime) + } + + /** + * Re-encodes [uri] into the cache honouring [image]'s dimension caps, then shrinks the + * longest-edge target until the output fits [maxBytes] — resizing to fit rather than rejecting. + * Returns null only if re-encoding fails outright; otherwise the smallest attempt, which the + * caller re-checks because a ceiling smaller than [MIN_MAX_EDGE] can produce is pathological. + */ + private fun cacheWithinPolicy( + uri: Uri, + sourceMime: String?, + fileNamePrefix: String, + image: ImageConstraints?, + maxBytes: Long?, + ): Uri? { + var edge = maxEdgeFor(image) + var last: Uri? = null + repeat(MAX_RESIZE_ATTEMPTS) { + // Drop the previous over-ceiling attempt before making a smaller one. + last?.let { contentReader.removeFromCache(it) } + val candidate = contentReader.copyToCache( + uri = uri, + fileName = "${fileNamePrefix}_${System.nanoTime()}", + maxSize = edge, + mimeType = sourceMime, + ) ?: return null + last = candidate + val size = contentReader.size(candidate) ?: 0L + if (maxBytes == null || size <= maxBytes) return candidate + // Encoded bytes track pixel area (edge squared); scale the edge by sqrt(ceiling/actual) + // with a safety margin to converge, floored at MIN_MAX_EDGE. + val next = floor(edge * sqrt(maxBytes.toDouble() / size) * RESIZE_SAFETY) + .toInt() + .coerceAtLeast(MIN_MAX_EDGE) + if (next >= edge) return candidate // already at the floor — hand back the best effort + edge = next + } + return last + } + + /** + * The longest-edge cap that satisfies every dimension constraint in [image]: the smallest of + * maxWidth, maxHeight, and sqrt(maxPixels) — bounding the longest edge by sqrt(maxPixels) keeps + * total area within maxPixels. Falls back to [DEFAULT_MAX_EDGE] when the policy names no image + * constraints. + */ + private fun maxEdgeFor(image: ImageConstraints?): Int { + val caps = listOfNotNull( + image?.maxWidth, + image?.maxHeight, + image?.maxPixels?.let { floor(sqrt(it.toDouble())).toInt() }, + ).filter { it > 0 } + return caps.minOrNull() ?: DEFAULT_MAX_EDGE + } + + private companion object { + // Longest-edge downscale target used when the upload policy specifies no dimension caps. + const val DEFAULT_MAX_EDGE = 500 + + // Floor for the resize-to-fit loop — below this an avatar is no longer worth keeping. + const val MIN_MAX_EDGE = 64 + + // How many times to shrink-and-retry before handing back the smallest attempt. + const val MAX_RESIZE_ATTEMPTS = 5 + + // Under-shoot the estimated fitting edge so re-encode overhead doesn't push us back over. + const val RESIZE_SAFETY = 0.9 + } +} From ae590418a9011efe6b68c91229a6716589c7a7bf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 10:50:32 -0400 Subject: [PATCH 3/3] feat(chat): create and join balance-gated public groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nodes 10153:22901 and 10153:22902. Creating a group is a form over `CreateGroupViewModel`: name, photo, visibility, and an optional reserve stated in a chosen token, with Create refusing early — and saying why — when the creator cannot meet their own rule. Joining one is the messenger screen a member already sees, with the transcript withheld: a blur over a placeholder, and a gate where the composer goes that states the group's standing requirement and offers Join or Buy More. `GroupAccess` is the one predicate behind both. It moves into `:apps:flipcash:shared:chat` so the create form and the gate evaluate a viewer the same way, against the chat's rules plus the viewer's balances and staff flag. The blur, the placeholder and the gate all read a single flag off the screen's state, so a composer can never appear over a chat that is still blurred, and the gate holds its own join confirmation rather than waiting for the roster to replace it. Invite links are `https://app.flipcash.com/chat/`, resolved by `AppRouter`, and a group push whose chat the user has not joined lands on that same gated screen — one destination for the link and for the tap. Group profile gains Leave, which pops back to the chat list, and Invite, which opens the sheet on demand rather than on creation. The transcript behind the blur is a static placeholder, not fetched messages: there is no RPC that serves a non-member's history, and none is invented here. --- .../flipcash/app/src/main/AndroidManifest.xml | 30 +- .../ui/navigation/AppScreenContent.kt | 6 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 25 +- .../com/flipcash/app/core/chat/ChatStep.kt | 12 + .../flipcash/app/core/chat/NewGroupStep.kt | 50 ++ .../app/core/navigation/DeeplinkType.kt | 11 + .../app/core/tokens/TokenDisplayName.kt | 36 ++ .../com/flipcash/app/core/util/Linkify.kt | 20 + .../core/src/main/res/drawable/ic_at.xml | 14 + .../core/src/main/res/drawable/ic_camera.xml | 22 + .../res/drawable/ic_chevron_down_small.xml | 16 + .../core/src/main/res/drawable/ic_group_3.xml | 37 ++ .../core/src/main/res/values/strings.xml | 49 +- .../features/messenger/build.gradle.kts | 1 + .../flipcash/app/messenger/ChatFlowScreen.kt | 32 +- .../app/messenger/internal/ChatSubject.kt | 39 +- .../app/messenger/internal/ChatViewModel.kt | 158 ++++- .../internal/screens/GroupInviteSheet.kt | 82 +++ .../internal/screens/MessengerScreen.kt | 11 +- .../screens/components/ChatBottomBar.kt | 26 +- .../screens/components/ChatInfoCard.kt | 29 +- .../components/GatedTranscriptPlaceholder.kt | 105 ++++ .../screens/components/GroupGateBar.kt | 114 +++- .../screens/components/MessageList.kt | 40 +- .../screens/profile/GroupProfileMenuItems.kt | 35 +- .../screens/profile/GroupProfileScreen.kt | 41 +- .../internal/ChatRulesSummaryTest.kt | 33 +- .../internal/ChatViewModelStateTest.kt | 179 ++++++ .../internal/GroupChromeScreenshotTest.kt | 167 ++++- .../features/tipping/build.gradle.kts | 2 + .../app/tipping/FindByUsernameScreen.kt | 181 ++++++ .../com/flipcash/app/tipping/NewChatScreen.kt | 166 +---- .../app/tipping/NewGroupFlowScreen.kt | 131 ++++ .../tipping/internal/CreateGroupViewModel.kt | 581 ++++++++++++++++++ ...iewModel.kt => FindByUsernameViewModel.kt} | 4 +- .../tipping/internal/GroupCreateAttempt.kt | 79 +++ .../screens/NewGroupCustomAmountSheet.kt | 63 ++ .../internal/screens/NewGroupFormScreen.kt | 473 ++++++++++++++ .../screens/SelectGroupCurrencySheet.kt | 86 +++ .../tipping/internal/CreateGroupStateTest.kt | 149 +++++ .../internal/CreateGroupViewModelErrorTest.kt | 311 ++++++++++ .../internal/GroupCreateAttemptTest.kt | 85 +++ .../flipcash/shared/chat/models/ChatAction.kt | 9 + .../flipcash/shared/chat/ui/LinkCardView.kt | 15 +- apps/flipcash/shared/chat/build.gradle.kts | 4 +- .../flipcash/shared/chat/ChatCoordinator.kt | 17 + .../com/flipcash/shared/chat}/GroupAccess.kt | 50 +- .../internal/delegates/GroupFeedDelegate.kt | 23 + .../flipcash/shared/chat}/GroupAccessTest.kt | 56 +- .../shared/chat/GroupFeedDelegateTest.kt | 54 ++ .../app/notifications/ChatTapTargetPlanner.kt | 50 +- .../app/notifications/NotificationService.kt | 8 +- .../notifications/ChatTapTargetPlannerTest.kt | 42 +- .../flipcash/app/router/internal/AppRouter.kt | 37 +- .../app/router/internal/AppRouterTest.kt | 70 +++ .../app/shareable/ShareSheetController.kt | 16 + .../InternalShareConfirmationController.kt | 3 + .../internal/InternalShareSheetController.kt | 24 + .../extensions/ChatRulesProtoMappingTest.kt | 95 +++ .../getcode/ui/components/BlurredContent.kt | 29 +- .../com/getcode/ui/components/ChoiceRow.kt | 62 ++ 61 files changed, 4061 insertions(+), 334 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/NewGroupStep.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenDisplayName.kt create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_at.xml create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_camera.xml create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_chevron_down_small.xml create mode 100644 apps/flipcash/core/src/main/res/drawable/ic_group_3.xml create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GatedTranscriptPlaceholder.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/FindByUsernameScreen.kt create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt rename apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/{NewChatViewModel.kt => FindByUsernameViewModel.kt} (97%) create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupCustomAmountSheet.kt create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt create mode 100644 apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/SelectGroupCurrencySheet.kt create mode 100644 apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupStateTest.kt create mode 100644 apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt create mode 100644 apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttemptTest.kt rename apps/flipcash/{features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal => shared/chat/src/main/kotlin/com/flipcash/shared/chat}/GroupAccess.kt (63%) rename apps/flipcash/{features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal => shared/chat/src/test/kotlin/com/flipcash/shared/chat}/GroupAccessTest.kt (76%) create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/ChatRulesProtoMappingTest.kt create mode 100644 ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt diff --git a/apps/flipcash/app/src/main/AndroidManifest.xml b/apps/flipcash/app/src/main/AndroidManifest.xml index f98fb739d0..67662bd553 100644 --- a/apps/flipcash/app/src/main/AndroidManifest.xml +++ b/apps/flipcash/app/src/main/AndroidManifest.xml @@ -187,11 +187,33 @@ + + + + + + + + diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 711f499379..6e5d736567 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -25,7 +25,9 @@ import com.flipcash.app.balance.WalletScreen import com.flipcash.app.cash.CashScreen import com.flipcash.app.contact.verification.VerificationFlowScreen import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen +import com.flipcash.app.tipping.FindByUsernameScreen import com.flipcash.app.tipping.NewChatScreen +import com.flipcash.app.tipping.NewGroupFlowScreen import com.flipcash.app.tipping.TippingFlowScreen import com.flipcash.shared.transactionhistory.ActivityHistoryScreen import com.flipcash.app.core.AppRoute @@ -112,6 +114,10 @@ fun appEntryProvider( ChatFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } annotatedEntry { NewChatScreen() } + annotatedEntry { FindByUsernameScreen() } + annotatedEntry { key -> + NewGroupFlowScreen(route = key, resultStateRegistry = resultStateRegistry) + } // Tokens annotatedEntry(testTag = "token_info_screen") { key -> diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index cabf6d3534..9d57279922 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -4,6 +4,7 @@ import android.os.Parcelable import androidx.navigation3.runtime.NavKey import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.NewGroupStep import com.flipcash.app.core.deposit.DepositResult import com.flipcash.app.core.deposit.DepositStep import com.flipcash.app.core.onboarding.OnboardingStep @@ -329,16 +330,34 @@ sealed interface AppRoute : NavKey, Parcelable { } /** - * Starting a chat by typing someone's `@handle` (node 9442:5825), reached from the "+" on - * the Chats list. + * Node 10127:117987 — the two ways to start a chat, reached from the "+" on the Chats list. * * A top-level route rather than a step of the tipping flow, even though the Chats list it * is reached from is one: the flow is a tab home, so a step pushed inside it keeps the tab - * bar. This covers it, the way [Chat] does. + * bar. This covers it, the way [Chat] does. [FindByUsername] and [NewGroup] are pushed on + * top of it for the same reason. */ @Serializable @Parcelize data object NewChat : Messaging + + /** Node 9442:5825 — starting a chat by typing someone's `@handle`. */ + @Serializable + @Parcelize + data object FindByUsername : Messaging + + /** + * Node 10153:22901 — creating a public group, from the empty form to its invite link. + * + * The three screens share one draft, so they are steps of a flow rather than routes; see + * [NewGroupStep]. + */ + @Serializable + @Parcelize + data object NewGroup : Messaging, FlowRoute { + override val initialStack: List + get() = listOf(NewGroupStep.Form) + } } @Serializable 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 8746b801c0..eb8c5e81e4 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 @@ -42,6 +42,18 @@ sealed interface ChatStep : FlowStep, Parcelable { data object InitPayment : ChatStep, NavigationRetVal, Sheet, WrapContentSheet + /** + * Node 10127:118315 — the two ways to hand out a group's invite link. + * + * A [WrapContentSheet] over the conversation rather than a screen of its own: it is two rows, + * and inviting is something you do *from* the group, not a place you go. Reached from the empty + * transcript's CTA and from the group's profile, which is why it hangs off the chat flow rather + * than the create flow that used to end on it. + */ + @Parcelize + @Serializable + data object InviteToGroup : ChatStep, Sheet, WrapContentSheet + @Parcelize @Serializable data class Profile(val contact: ChatParticipant): ChatStep diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/NewGroupStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/NewGroupStep.kt new file mode 100644 index 0000000000..99cf40f50b --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/NewGroupStep.kt @@ -0,0 +1,50 @@ +package com.flipcash.app.core.chat + +import android.os.Parcelable +import com.getcode.navigation.Sheet +import com.getcode.navigation.flow.FlowStep +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * The steps of creating a public group — section 10153:22901. + * + * A flow rather than three pushed routes because all three read and write one draft: the title, the + * picture, the mint and the amount are entered on [Form], and the mint and the amount are changed + * on [SelectCurrency] and [CustomAmount]. A shared view model + * ([com.getcode.navigation.flow.flowSharedViewModel]) holds that draft for the flow's lifetime, + * which is also what keeps one create attempt's idempotency key alive across a retry. + * + * Creating the chat ends the flow. The invite link is handed out from inside the group + * ([ChatStep.InviteToGroup]), so it is not a step here. + */ +@Serializable +sealed interface NewGroupStep : FlowStep, Parcelable { + + /** Node 10127:118014 — name, picture, and the balance rule. */ + @Parcelize + @Serializable + data object Form : NewGroupStep + + /** + * Node 10127:118100 — which mint the balance requirement is denominated in. + * + * A [Sheet] so the form stays on screen behind it: the picker is a change to one field of the + * draft, and dismissing it leaves the rest of the form exactly as it was. + */ + @Parcelize + @Serializable + data object SelectCurrency : NewGroupStep, Sheet + + /** + * The `…` preset — the balance requirement as a free amount rather than one of the three chips. + * + * The design names the chip but not where it lands, so this is the app's standard amount entry + * ([com.flipcash.shared.amountentry.AmountEntryScreen]), the same keypad the minimum-to-chat fee + * is set on. A [Sheet] for the same reason as [SelectCurrency]: it edits one field of the draft + * the form is still holding. + */ + @Parcelize + @Serializable + data object CustomAmount : NewGroupStep, Sheet +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt index e8662f73a2..ec66eddf6a 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt @@ -20,6 +20,17 @@ sealed interface DeeplinkType: Parcelable { @Serializable data class TipChat(val identifier: ChatIdentifier): DeeplinkType, Navigatable + /** + * A group chat invite — `app.flipcash.com/chat/{uuid}`. + * + * Carries a [ChatId] rather than a [ChatIdentifier] because a group is only ever addressed by + * its own id: there is no counterparty to resolve it through, the way a DM has. + * + * The chat it opens may be one the viewer has not joined. That is the normal case for an + * invite, and the chat screen is already the gated preview for it — see `GroupAccess`. + */ + @Serializable data class GroupChatInvite(val chatId: ChatId): DeeplinkType, Navigatable + /** * A tip card addressed by account id — `flipcash.com/{uuid}`, or the older * `app.flipcash.com/tip/{uuid}` that links already shared still carry. diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenDisplayName.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenDisplayName.kt new file mode 100644 index 0000000000..9432fe69fd --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenDisplayName.kt @@ -0,0 +1,36 @@ +package com.flipcash.app.core.tokens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.flipcash.core.R +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.Mint +import com.getcode.util.resources.ResourceHelper + +/** + * Whether this token is the reserve — the one holding the app counts as plain dollars. + * + * Worth a name because the reserve is written differently wherever it is named, and the mint + * comparison on its own does not say why a screen is asking. + */ +val Token.isReserve: Boolean + get() = address == Mint.usdf + +/** + * What the user calls this token. + * + * The reserve is branded Dollars everywhere they meet it; [Token.name] off the wire is "USDF", which + * is the mint, not the thing they hold. Every other token is called what it is named — the name, not + * the ticker, which is what the balance list and the currency picker show. + */ +fun Token.brandedName(resources: ResourceHelper): String = when { + isReserve -> resources.getString(R.string.displayName_dollars) + else -> name +} + +/** [brandedName] where a composable has the resources to hand rather than an injected [ResourceHelper]. */ +@Composable +fun Token.brandedName(): String = when { + isReserve -> stringResource(R.string.displayName_dollars) + else -> name +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt index 2e4822e64e..a0390d3c27 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt @@ -39,4 +39,24 @@ object Linkify { fun tokenInfo(token: Token): String = tokenInfo(token.address) fun tokenInfo(mint: Mint): String = "https://app.flipcash.com/token/${mint.base58()}" fun tipChatById(chatId: ChatId): String = "https://app.flipcash.com/tip/chat/${chatId.bytes.encodeBase64(urlSafe = true)}" + + /** + * A group chat's invite link — `app.flipcash.com/chat/{uuid}`. + * + * The id is written as a dashed UUID rather than as the base64url [tipChatById] uses, because + * `common.v1.ChatId.value` is a 16-byte UUID for a group and that is its canonical text form. + * Nothing mints this link server-side: there is no invite RPC in flipcash2, so the link is the + * chat id and the client builds and parses both ends of it. + * + * Note the `app.` host, unlike [tipcard]'s bare one. The apex shares its path space with the + * website and can only be claimed by path *shape*, which a UUID would satisfy — an invite on + * the apex would be indistinguishable from a tip card addressed by account id. The `app.` host + * belongs to the app whole, so `/chat/` can be claimed there outright. + * + * Null for a chat that has no UUID form: a DM's id is a 32-byte hash, and no invite exists for + * one. Callers hold a group id by construction, so this is unreachable rather than a case to + * render. + */ + fun groupChatInvite(chatId: ChatId): String? = + chatId.bytes.toList().uuid?.let { "https://app.flipcash.com/chat/$it" } } \ No newline at end of file diff --git a/apps/flipcash/core/src/main/res/drawable/ic_at.xml b/apps/flipcash/core/src/main/res/drawable/ic_at.xml new file mode 100644 index 0000000000..4a69d8fe4f --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_at.xml @@ -0,0 +1,14 @@ + + + + diff --git a/apps/flipcash/core/src/main/res/drawable/ic_camera.xml b/apps/flipcash/core/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000000..ec030d02f3 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,22 @@ + + + + + diff --git a/apps/flipcash/core/src/main/res/drawable/ic_chevron_down_small.xml b/apps/flipcash/core/src/main/res/drawable/ic_chevron_down_small.xml new file mode 100644 index 0000000000..a711ab1e3a --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_chevron_down_small.xml @@ -0,0 +1,16 @@ + + + + diff --git a/apps/flipcash/core/src/main/res/drawable/ic_group_3.xml b/apps/flipcash/core/src/main/res/drawable/ic_group_3.xml new file mode 100644 index 0000000000..a7dc5347f9 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_group_3.xml @@ -0,0 +1,37 @@ + + + + + + + diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 2a875bd837..fca79ba7b5 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -894,12 +894,13 @@ From Your Contacts Add Contact - Balance Requirement: %1$s of $%2$s + Balance Requirement: %1$s of %2$s Balance Requirement: %1$s - Minimum Balance: %1$s of $%2$s + Flipcash Staff Only + Minimum Balance: %1$s of %2$s Minimum Balance: %1$s This chat is open to Flipcash staff only. - Buy More $%1$s + Buy More %1$s Join Chat Leave Chat Leave @@ -907,6 +908,11 @@ You won’t receive new messages from this chat. You can join it again if it still lets you in Something Went Wrong We were unable to leave the chat. Please try again + Couldn\'t Join + We were unable to join the chat. Please try again + Your balance doesn\'t meet this chat\'s requirement yet + This chat isn\'t open to you + This chat no longer exists Are You Sure? Anyone you sent the link to won\'t be able to collect the cash @@ -978,6 +984,42 @@ Please try a different username That\'s Your Username You can\'t start a chat with yourself + + + New Chat + Create a Public Group + Find by Username + + + New Public Group + Group Name (Required) + + Minimum Balance Required + + Balance Requirement + People won\'t be able to join this chat if their balance is less than this amount + Create + + You Need More %1$s + You have to meet the balance requirement you set to create this group + This Group Name is Not Allowed + These Rules Aren\'t Allowed + Try a different balance requirement + Can\'t Create This Group + Your account isn\'t able to create public groups + + Something Went Wrong + We couldn\'t create this group. Please try again. + + + Invite to Join Group + Send Invite Link + Copy Invite Link + Invite People To Join + Join me on Flipcash Receive Tips From Everyone Add your name to receive tips Start Receiving Tips @@ -1104,6 +1146,7 @@ Close Download Tip Card Link + Group Invite Link Download As PNG Social, chats, overlays diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 45bead0af6..606acb52d5 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:router")) implementation(project(":apps:flipcash:shared:session")) + implementation(project(":apps:flipcash:shared:shareable")) implementation(project(":apps:flipcash:shared:tokens")) implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":libs:vibrator:bindings")) 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 8ff43e177e..c97d103d1f 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 @@ -19,6 +19,7 @@ import com.flipcash.app.core.chat.ChatSendResult import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.screens.GroupInviteSheet import com.flipcash.app.messenger.internal.screens.MessengerScreen import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet @@ -60,9 +61,9 @@ fun ChatFlowScreen( // Popping with the IME still up drags the screen behind it out from under the keyboard. onExit = { _, _ -> keyboard.hideIfVisible { navigator.pop() } }, entryProvider = chatEntryProvider(route.identifier, route.openKeyboard), - // ChatStep.AmountEntry and ChatStep.InitPayment are Sheets, so the flow needs the sheet - // strategy to draw them as such; - // without it the step would fall through to SinglePane and cover the thread. Amount entry + // ChatStep.AmountEntry, ChatStep.InitPayment and ChatStep.InviteToGroup are Sheets, so the + // flow needs the sheet strategy to draw them as such; without it the step would fall + // through to SinglePane and cover the thread. Amount entry // returns its result inside the flow (resultBackNavigator), so the strategy's own // dismiss-delivers-Canceled path has nothing to address here — hence the null key. A // swipe-dismiss just leaves the pending callback unclaimed, which is what a cancel means. @@ -87,6 +88,9 @@ private fun chatEntryProvider( annotatedEntry { FlowInitPaymentScreen() } + annotatedEntry { + FlowGroupInviteSheet() + } annotatedEntry { step -> FlowChatProfileScreen(step.contact) } @@ -213,6 +217,28 @@ private fun FlowInitPaymentScreen() { ) } +/** + * The invite sheet, on the conversation's own view model. + * + * Reached from the empty transcript and from the group's profile, and the same step either way — + * the link is the chat's, so there is nothing for a second entry point to hold differently. + */ +@Composable +private fun FlowGroupInviteSheet() { + val viewModel = flowSharedViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + // Same dismissal rule as the other sheets in this flow: exit through the sheet so it animates + // down rather than having its scene deleted mid-frame. + val dismissSheet = LocalBottomSheetDismissDispatcher.current + + GroupInviteSheet( + inviteUrl = state.groupInviteUrl, + groupTitle = state.subject?.title, + onCopy = { viewModel.dispatchEvent(ChatViewModel.Event.CopyInviteLink) }, + onDismiss = dismissSheet, + ) +} + @Composable private fun FlowChatProfileScreen(participant: ChatParticipant) { val viewModel = flowSharedViewModel() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt index 2aa6dc6c5f..9219f0a761 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt @@ -111,11 +111,46 @@ internal fun ChatParticipant?.asSubject(): ChatSubject? = when (this) { * top of listener rules — so the first thing standing between the viewer and the chat is the * listener requirement when there is one. * - * [ChatRuleRequirement.Staff] deliberately produces nothing: it is not a bar a user can clear by - * doing something, and rendering it as a requirement would read as an instruction. + * [ChatRuleRequirement.Staff] is not a balance and has no amount to state — [requiresStaff] answers + * for it separately, because the two can both be set on one chat. */ internal fun ChatRules?.balanceRequirement(): ChatRuleRequirement.MinimumBalance? { val rules = this ?: return null return rules.listener.filterIsInstance().firstOrNull() ?: rules.speaker.filterIsInstance().firstOrNull() } + +/** + * Whether the chat is open to Flipcash staff only. + * + * Worth stating even though it is not a bar the user can go and clear: it is the reason the button + * is dead, and a disabled Join over no explanation is the screen saying nothing. Staff read the same + * line over a Join that works, which is how the balance requirement is already handled. + * + * Either list counts, unlike [balanceRequirement], which picks one requirement to state: this + * answers yes or no, so there is nothing to prefer between them. + */ +internal fun ChatRules?.requiresStaff(): Boolean { + val rules = this ?: return false + return ChatRuleRequirement.Staff in rules.listener || + ChatRuleRequirement.Staff in rules.speaker +} + +/** + * How the token behind a balance requirement is written on screen. + * + * Two answers rather than one, because the reserve is named in a button but not in a sentence: "Buy + * More Dollars" is what the user taps, while "Minimum Balance: $100 of Dollars" says dollars twice — + * the amount is already a dollar figure. Every other token is named in both places, since "$100" + * alone would not say which holding clears the bar. + */ +internal data class RuleCurrency( + /** What the user calls this token — [com.flipcash.app.core.tokens.brandedName]. */ + val name: String, + /** The reserve, which an amount in dollars has already named. */ + val isReserve: Boolean, +) { + /** The token to name in the requirement line, or `null` to state the amount alone. */ + val nameInRequirement: String? + get() = name.takeUnless { isReserve } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 1bf4c84275..ad9e17c91c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -20,7 +20,10 @@ import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.extensions.setText +import com.flipcash.app.core.tokens.brandedName +import com.flipcash.app.core.tokens.isReserve import com.flipcash.app.core.ui.ConfirmationStyle +import com.flipcash.app.core.util.Linkify import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.MessagePolicy import com.flipcash.shared.chat.withinWindows @@ -45,9 +48,12 @@ import com.flipcash.app.messenger.internal.link.LinkCardResolver import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.messenger.R +import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.TipAction import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile +import com.flipcash.shared.chat.GroupAccess +import com.flipcash.shared.chat.groupAccess import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType @@ -242,15 +248,22 @@ internal class ChatViewModel @Inject constructor( */ val messagePolicy: MessagePolicy = MessagePolicy.Default, /** - * The symbol of the token a group's balance requirement names — "BadBoys", not its mint. - * Null until the token cache has it, and null for any chat without such a rule. + * The token a group's balance requirement names, and how to write it — "Jeffy", not its mint + * and not its ticker. Null until the token cache has it, and null for any chat without such + * a rule. */ - val ruleTicker: String? = null, + val ruleCurrency: RuleCurrency? = null, /** * What this viewer may do here. Null for anything that is not a group — a DM has no gate, * and rendering one from a default would blur every contact conversation in the app. */ val groupAccess: GroupAccess? = null, + /** + * The gate's Join button, same shape as [sendProgress]. Membership arrives from the roster + * rather than from the join's own reply, so without this the button would sit unchanged for + * the whole round trip and read as dead — which is what it looked like before it had one. + */ + val joinProgress: LoadingSuccessState = LoadingSuccessState(), ) { /** * The DM counterparty, or `null` for a group. @@ -270,6 +283,42 @@ internal class ChatViewModel @Inject constructor( /** What the selection bar may offer, straight from what the transcript already resolved. */ val selectionCapabilities: Set get() = selection?.capabilities.orEmpty() + + /** + * Whether this conversation is being read from outside the group. + * + * Drives the blur over the transcript, the placeholder standing in for it, and the gate that + * stands where the composer does. + * + * Read off the subject rather than off [groupAccess], even though `Membered` is the same + * fact, because this one has to be right on the frame the group first renders. [groupAccess] + * arrives through the balance and staff flows, so it is null for as long as those take — and + * a withheld transcript that starts unblurred and then blurs has already shown what it was + * withholding. The subject is what the info card and the title bar draw from, so tying the + * blur to it means the three cannot disagree by a frame. + * + * [groupAccess] still decides what the gate's button does, which is the part that genuinely + * depends on the balance. + * + * A succeeded join keeps it true through the button's checkmark: one flag for all three + * renderers means the composer cannot arrive while the transcript is still blurred, and the + * roster — which can confirm membership on the next frame — cannot cut the confirmation + * short. + */ + val isGatedPreview: Boolean + get() = joinProgress.success || (subject as? ChatSubject.Group)?.isMember == false + + /** + * The link that invites someone into this group, or `null` when there is nobody to invite: + * a DM, or a group this viewer has not joined. + * + * Built from the chat's id — there is no invite RPC, and [Linkify] is the one place that + * decides the link's shape, so what the empty state shares is what the create flow shares. + */ + val groupInviteUrl: String? + get() = (subject as? ChatSubject.Group) + ?.takeIf { it.isMember } + ?.let { Linkify.groupChatInvite(it.chatId) } } /** @@ -294,8 +343,8 @@ internal class ChatViewModel @Inject constructor( /** The chat this screen is showing turned out to be a group, with this metadata. */ data class OnGroupResolved(val membership: ChatMembership) : Event - /** The token cache learned the symbol behind the group's balance requirement. */ - data class OnRuleTickerResolved(val ticker: String?) : Event + /** The token cache learned the currency behind the group's balance requirement. */ + data class OnRuleCurrencyResolved(val currency: RuleCurrency?) : Event /** The gate re-decided, because membership, the rules, or the balance moved. */ data class OnGroupAccessResolved(val access: GroupAccess) : Event @@ -303,6 +352,18 @@ internal class ChatViewModel @Inject constructor( /** The gate's "Join Chat" button. */ data object JoinChat : Event + /** + * The Join button's own state. Success is dispatched for the length of the checkmark and + * then cleared, which is what releases the gate to the composer. + */ + data class JoinStateUpdated( + val loading: Boolean = false, + val success: Boolean = false, + ) : Event + + /** The invite sheet's "Copy Invite Link" row. */ + data object CopyInviteLink : Event + /** The group profile's "Leave Chat" row, which puts the confirmation up. */ data object LeaveChat : Event @@ -1013,10 +1074,21 @@ internal class ChatViewModel @Inject constructor( .distinctUntilChanged(), tokenCoordinator.observeTokenCache(), ) { requirement, tokens -> - requirement?.mints?.firstOrNull()?.let { tokens[Mint(it.bytes)]?.symbol } + // The name rather than the symbol, matching the create form's own mint row: the two + // screens name one holding, and the balance list a requirement is satisfied from + // renders the name as well (`TokenWithBalance.displayName`). `brandedName` is the same + // rule the link cards use, so the reserve reads "Dollars" here too rather than "USDF". + requirement?.mints?.firstOrNull() + ?.let { tokens[Mint(it.bytes)] } + ?.let { token -> + RuleCurrency( + name = token.brandedName(resources), + isReserve = token.isReserve, + ) + } } .distinctUntilChanged() - .onEach { dispatchEvent(Event.OnRuleTickerResolved(it)) } + .onEach { dispatchEvent(Event.OnRuleCurrencyResolved(it)) } .launchIn(viewModelScope) // Re-resolved whenever membership or the rules move, and internally whenever the balance @@ -1028,7 +1100,11 @@ internal class ChatViewModel @Inject constructor( if (group == null) { flowOf(null) } else { - tokenCoordinator.groupAccess(isMember = group.isMember, rules = group.rules) + tokenCoordinator.groupAccess( + isMember = group.isMember, + rules = group.rules, + isStaff = userFlags.resolvedFlags.map { it.isStaff.effectiveValue }, + ) } } .filterNotNull() @@ -1194,6 +1270,18 @@ internal class ChatViewModel @Inject constructor( } private fun initMessageActionHandlers() { + // Read off the state rather than carried on the event, so the row that copies the link and + // the row that shares it are handing out the one url [State.groupInviteUrl] builds. + eventFlow.filterIsInstance() + .mapNotNull { stateFlow.value.groupInviteUrl } + .onEach { url -> + clipboardManager.setText( + text = url, + label = resources.getString(R.string.title_clipboardLabelGroupInviteLink), + ) + } + .launchIn(viewModelScope) + eventFlow.filterIsInstance() .onEach { event -> clipboardManager.setText( @@ -1273,12 +1361,46 @@ internal class ChatViewModel @Inject constructor( eventFlow.filterIsInstance() .onEach { - val chatId = stateFlow.value.chatId ?: return@onEach + val chatId = stateFlow.value.chatId ?: run { + dispatchEvent(Event.JoinStateUpdated()) + return@onEach + } // No optimistic flip. `join` caches the chat and the membership flag comes back // through observeMetadata, which is the same path a join from another device takes — // one source for the gate rather than two that can disagree. chatCoordinator.join(chatId) - .onFailure { trace("failed to join chat - ${it.localizedMessage}") } + .onSuccess { + // The gate holds its own confirmation rather than waiting to be replaced: + // membership comes back through the roster, which can land on the next frame + // and swap the checkmark away before it has been drawn. Clearing the success + // after the hold is what releases the gate, so the blur fades and the + // composer arrives when the button is done rather than when the roster is. + dispatchSuccessThen(Event.JoinStateUpdated(success = true)) { + dispatchEvent(Event.JoinStateUpdated()) + } + } + .onFailure { cause -> + trace("failed to join chat - ${cause.localizedMessage}") + // A refusal is the only thing that tells the user anything: the gate itself + // does not change, because the rules and the balance it reads are what the + // server just disagreed with. Saying which refusal it was is the difference + // between "buy more" and "this link is dead". + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToJoin), + message = resources.getString( + when (cause) { + is JoinChatError.RulesNotSatisfied -> + R.string.error_description_joinChat_rulesNotSatisfied + is JoinChatError.Denied -> + R.string.error_description_joinChat_denied + is JoinChatError.NotFound -> + R.string.error_description_joinChat_notFound + else -> R.string.error_description_failedToJoin + } + ), + ) + dispatchEvent(Event.JoinStateUpdated()) + } } .launchIn(viewModelScope) @@ -1738,9 +1860,21 @@ internal class ChatViewModel @Inject constructor( resolveState = ResolveState.Resolved, ) } - is Event.OnRuleTickerResolved -> { state -> state.copy(ruleTicker = event.ticker) } + is Event.OnRuleCurrencyResolved -> + { state -> state.copy(ruleCurrency = event.currency) } is Event.OnGroupAccessResolved -> { state -> state.copy(groupAccess = event.access) } - Event.JoinChat, + Event.JoinChat -> { state -> + state.copy(joinProgress = LoadingSuccessState(loading = true)) + } + is Event.JoinStateUpdated -> { state -> + state.copy( + joinProgress = LoadingSuccessState( + event.loading, + event.success, + ) + ) + } + Event.CopyInviteLink, Event.LeaveChat, Event.LeaveConfirmed, Event.LeftChat -> { state -> state } 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 new file mode 100644 index 0000000000..40305adcd8 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -0,0 +1,82 @@ +package com.flipcash.app.messenger.internal.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.flipcash.app.shareable.LocalShareController +import com.flipcash.app.shareable.Shareable +import com.flipcash.features.messenger.R +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.ChoiceRow +import kotlinx.coroutines.launch + +/** + * Node 10127:118315 — the two ways to hand out the link to a group. + * + * Both actions carry the same URL, built once by + * [com.flipcash.app.core.util.Linkify.groupChatInvite] from the chat's id: there is no invite RPC, + * the id *is* the invite, and one builder is what keeps the shared link and the copied link + * identical. + * + * @param inviteUrl null for anyone who has nobody to invite — a DM, or a group this viewer has not + * joined — in which case the sheet is the title bar alone. Both entry points are gated on the same + * value, so this is a race with a leave rather than a state to design for. + */ +@Composable +internal fun GroupInviteSheet( + inviteUrl: String?, + groupTitle: String?, + onCopy: () -> Unit, + onDismiss: () -> Unit, +) { + val shareController = LocalShareController.current + val scope = rememberCoroutineScope() + + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding(), + ) { + AppBarWithTitle( + title = stringResource(R.string.title_inviteToJoinGroup), + titleAlignment = Alignment.CenterHorizontally, + endContent = { AppBarDefaults.Close(onClick = onDismiss) }, + ) + + if (inviteUrl != null) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x4), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + ChoiceRow( + label = stringResource(R.string.action_sendInviteLink), + icon = R.drawable.ic_at, + onClick = { + scope.launch { + shareController.present( + Shareable.GroupInvite(url = inviteUrl, title = groupTitle) + ) + } + }, + ) + ChoiceRow( + label = stringResource(R.string.action_copyInviteLink), + icon = R.drawable.ic_copy, + onClick = onCopy, + ) + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index ae665eec09..2af58ac0d3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -17,7 +17,6 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel -import com.flipcash.app.messenger.internal.GroupAccess import com.flipcash.app.messenger.internal.screens.components.ChatTopBar import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.softTopEdge @@ -113,6 +112,13 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { ChatAction.JoinChat -> viewModel.dispatchEvent(ChatViewModel.Event.JoinChat) + ChatAction.InviteToGroup -> { + // The sheet, not the share sheet: copying the link is the other way to hand it out, + // and going straight to the system share picker would bury it. The sheet reads the + // url off the same state the CTA that got here is gated on. + keyboard.hideIfVisible { navigator.push(ChatStep.InviteToGroup) } + } + is ChatAction.ViewProfile -> { // The triggers (top-bar tap, info-card chevron) are only clickable for subjects // that have a profile (see State.canViewProfile), so no gating is needed here. @@ -167,6 +173,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { UserControlBottomBar( state = state, hazeState = hazeState, + onAction = chatActionHandler, dispatch = viewModel::dispatchEvent, ) }, @@ -176,7 +183,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { // open. The title bar stays sharp — it is how a non-member knows what they are looking at. BlurredContent( modifier = Modifier.fillMaxSize(), - enabled = state.groupAccess != null && state.groupAccess != GroupAccess.Membered, + enabled = state.isGatedPreview, ) { MessageList( modifier = Modifier diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index a02b95716c..e8721ad38d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -44,8 +44,11 @@ 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 com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel -import com.flipcash.app.messenger.internal.GroupAccess +import com.flipcash.app.messenger.internal.balanceRequirement +import com.flipcash.app.messenger.internal.requiresStaff +import com.flipcash.shared.chat.models.ChatActionHandler import com.flipcash.app.messenger.internal.screens.ChatAnimations import com.flipcash.services.models.chat.ChatType import com.flipcash.features.messenger.R @@ -66,6 +69,7 @@ import dev.chrisbanes.haze.blur.materials.HazeMaterials internal fun UserControlBottomBar( state: ChatViewModel.State, hazeState: HazeState, + onAction: ChatActionHandler, dispatch: (ChatViewModel.Event) -> Unit, ) { if (state.isAnonymous) { @@ -74,11 +78,21 @@ internal fun UserControlBottomBar( } // The same shape as the block above it, and for the same reason: there is no composer to - // configure when the viewer cannot post. Membered is not a gate, so it falls through to the - // composer like any DM. - val access = state.groupAccess - if (access != null && access != GroupAccess.Membered) { - GroupGateBar(access = access, ticker = state.ruleTicker) + // configure when the viewer cannot post. The decision is `isGatedPreview`, the same flag the + // transcript's blur reads, so a composer can never appear over a blurred chat — including for + // the frames before the gate has decided anything, and for as long as the gate holds its join + // confirmation. + if (state.isGatedPreview) { + GroupGateBar( + access = state.groupAccess, + // The chat's own rule, read the same way the info card at the head of the transcript + // reads it, so the two lines about this group cannot state different requirements. + requirement = (state.subject as? ChatSubject.Group)?.rules.balanceRequirement(), + staffOnly = (state.subject as? ChatSubject.Group)?.rules.requiresStaff() == true, + currency = state.ruleCurrency, + onAction = onAction, + joinProgress = state.joinProgress, + ) return } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatInfoCard.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatInfoCard.kt index 93fee31276..5b176f1cf5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatInfoCard.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatInfoCard.kt @@ -36,6 +36,7 @@ import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.balanceRequirement +import com.flipcash.app.messenger.internal.requiresStaff import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.features.messenger.R import com.flipcash.services.models.UserProfile @@ -63,12 +64,15 @@ internal fun ChatInfoCard( onOpenProfile: (() -> Unit)? = null, onRefreshContact: () -> Unit = {}, /** - * The symbol of the token a group's balance rule names, once the token cache has it. + * The name of the token a group's balance rule names, once the token cache has it, or `null` to + * state the amount on its own — which is what the reserve does, since "$100 of Dollars" says + * dollars twice. Callers pass [com.flipcash.app.messenger.internal.RuleCurrency.nameInRequirement] + * rather than deciding that here. * * Resolved in the view model rather than read here: `observeTokenCache()` starts empty and * fills in, so a snapshot read would render the rule with no token and never correct itself. */ - ticker: String? = null, + currencyName: String? = null, ) { val participant = subject?.asParticipant() // Phone number and the add-to-contacts pill only apply to a device contact; a tip DM's @@ -149,15 +153,16 @@ internal fun ChatInfoCard( // Node 10125:19201. Rendered for a member as well as a non-member: it is the chat's // standing requirement, not a gate message, and it is the one thing the card can say // about a group that it cannot say about a person. - val requirement = (subject as? ChatSubject.Group)?.rules.balanceRequirement() + val rules = (subject as? ChatSubject.Group)?.rules + val requirement = rules.balanceRequirement() if (requirement != null) { Text( modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), - text = if (ticker != null) { + text = if (currencyName != null) { stringResource( R.string.label_chat_balanceRequirement, requirement.amount.formatted(), - ticker, + currencyName, ) } else { stringResource( @@ -171,6 +176,18 @@ internal fun ChatInfoCard( ) } + // Stated here as well as on the gate, and for the same reason the balance line is: it is + // what the card can say about this group. Both can be set on one chat, so both get a line. + if (rules.requiresStaff()) { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + text = stringResource(R.string.label_chat_staffRequirement), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { onRefreshContact() } @@ -362,7 +379,7 @@ private fun Preview_ChatInfoCard() { ChatInfoCard(subject = ChatSubject.Contact(unknownContact), modifier = cardWidth) ChatInfoCard(subject = ChatSubject.TipUser(tipUser), modifier = cardWidth) ChatInfoCard(subject = ChatSubject.TipUser(handleOnlyUser), modifier = cardWidth) - ChatInfoCard(subject = group, modifier = cardWidth, ticker = "BADBOYS") + ChatInfoCard(subject = group, modifier = cardWidth, currencyName = "Bad Boys") } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GatedTranscriptPlaceholder.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GatedTranscriptPlaceholder.kt new file mode 100644 index 0000000000..e35fd27c02 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GatedTranscriptPlaceholder.kt @@ -0,0 +1,105 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.unit.Dp +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.theme.White10 +import com.getcode.theme.extraSmall +import com.getcode.ui.components.chat.MessageNodeDefaults + +/** + * What sits behind the blur of a group the viewer has not joined — node 10127:117171. + * + * Shapes, not messages. No RPC serves a transcript to a non-member: `GetChat` gives a group's + * title, picture, roster summary and rules, and nothing else, so there is no conversation to fetch + * and blur. What the design shows through the blur is therefore drawn rather than loaded — bars in + * the proportions of an incoming run, so the gate reads as a conversation withheld rather than as a + * screen that failed to load. + * + * Deliberately unlocalised and free of any text: the closer this got to real message content, the + * more a screenshot with the blur stripped would look like one. Every value here is fixed, so it + * also renders the same on every open. + */ +@Composable +internal fun GatedTranscriptPlaceholder(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(vertical = CodeTheme.dimens.grid.x3), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + // Stands in for the date separator that heads a day's messages. + Box( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(width = SeparatorWidth, height = LabelHeight) + .clip(CodeTheme.shapes.extraSmall) + .background(White05), + ) + + PlaceholderRow(widthFraction = 0.72f, bubbleHeight = ShortBubbleHeight) + PlaceholderRow(widthFraction = 0.86f, bubbleHeight = TallBubbleHeight) + } +} + +/** One incoming row: the sender gutter, their name above the bubble, and the bubble. */ +@Composable +private fun PlaceholderRow(widthFraction: Float, bubbleHeight: Dp) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Box( + modifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x6) + .clip(CircleShape) + .background(White10), + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Box( + modifier = Modifier + .padding(start = CodeTheme.dimens.grid.x1) + .size(width = NameWidth, height = LabelHeight) + .clip(CodeTheme.shapes.extraSmall) + .background(White05), + ) + Box( + modifier = Modifier + .fillMaxWidth(widthFraction) + .height(bubbleHeight) + .clip( + MessageNodeDefaults.messageShape( + isIncoming = true, + isPreviousInGroup = false, + isNextInGroup = false, + ) + ) + .background(White10), + ) + } + } +} + +private val SeparatorWidth = 96.dp +private val NameWidth = 72.dp +private val LabelHeight = 10.dp +private val ShortBubbleHeight = 40.dp +private val TallBubbleHeight = 64.dp diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupGateBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupGateBar.kt index 87ae037981..710ce0558d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupGateBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupGateBar.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.screens.components +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -11,59 +12,98 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import com.flipcash.app.messenger.internal.GroupAccess +import com.flipcash.shared.chat.GroupAccess +import com.flipcash.app.messenger.internal.RuleCurrency import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.shared.chat.models.ChatAction -import com.flipcash.shared.chat.models.LocalChatActionHandler +import com.flipcash.shared.chat.models.ChatActionHandler import com.getcode.solana.keys.Mint import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 import com.getcode.ui.theme.CodeButton +import com.getcode.view.LoadingSuccessState /** * What stands where the composer does for someone who is not in the group. * - * Node 10125:19153. The caption states the bar and the button is the one thing that clears it, so - * both come from the same [GroupAccess.Blocked.unmet] the transcript's blur is decided by — there is - * no second evaluation here that could disagree with it. + * Node 10125:19153. The caption states the group's standing requirement, not what is holding this + * viewer up, so it is shown whether or not they meet it: nodes 10127:117120 and 10127:117171 carry + * the same line above an eligible viewer's Join and a blocked one's Buy More. That leaves + * [GroupAccess] deciding only the button — which is why this bar can stand, captions and all, on + * frames where the access has not landed yet: whether the viewer is outside the group is the + * subject's answer, and only what they can do about it waits on their balance. + * + * A staff-only group states its line the same way. It is not a bar the viewer can go and clear, but + * it is the reason Join is dead, and the alternative is a disabled button with nothing above it. */ @Composable internal fun GroupGateBar( - access: GroupAccess, - ticker: String?, + /** What the viewer may do here, or null while the balance the rules are read against loads. */ + access: GroupAccess?, + /** The group's balance rule, if it has one — the chat's, not this viewer's shortfall. */ + requirement: ChatRuleRequirement.MinimumBalance?, + /** The token that rule names, once the cache resolves it, and how to write it. */ + currency: RuleCurrency?, + /** Whether the group is staff-only — the chat's rule, read the same way [requirement] is. */ + staffOnly: Boolean, + /** + * Where Join and Buy More go. Passed rather than read off `LocalChatActionHandler`: that local + * is provided around the transcript, and the gate stands where the composer does — outside it. + * Reading it here got the static default, which is a handler that does nothing. + */ + onAction: ChatActionHandler, + /** The Join button's loading/success state, straight off the screen's own. */ + joinProgress: LoadingSuccessState = LoadingSuccessState(), modifier: Modifier = Modifier, ) { - val onAction = LocalChatActionHandler.current val unmet = (access as? GroupAccess.Blocked)?.unmet - val balance = unmet as? ChatRuleRequirement.MinimumBalance + val unmetBalance = unmet as? ChatRuleRequirement.MinimumBalance + // The caption and the button sit on one panel (node 10125:19197), not loose over the + // transcript: the transcript behind them is the group's own, blurred, and without a surface to + // stand on the gate reads as part of it rather than as the thing standing in for the composer. Column( modifier = modifier .fillMaxWidth() .navigationBarsPadding() .padding(horizontal = CodeTheme.dimens.inset) - .padding(vertical = CodeTheme.dimens.grid.x3), + .padding(vertical = CodeTheme.dimens.grid.x2) + .background(White05, CodeTheme.shapes.medium) + .padding(horizontal = CodeTheme.dimens.grid.x1) + .padding(top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x1), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { - val caption = when { - balance != null && ticker != null -> stringResource( - R.string.subtitle_chatGate_minimumBalance, - balance.amount.formatted(), - ticker, - ) - balance != null -> stringResource( - R.string.subtitle_chatGate_minimumBalance_anyToken, - balance.amount.formatted(), - ) - unmet == ChatRuleRequirement.Staff -> - stringResource(R.string.subtitle_chatGate_staffOnly) - // Eligible, and Membered for the frame between a successful join and the membership - // flag arriving back through observeMetadata. - else -> null + // Both rules can be set on one chat, so both are stated. Each line is the group's own + // standing requirement, read from the rules rather than from the gate: staff see the + // staff-only line over a Join that works, the same way an eligible viewer already sees the + // balance line. A group with no rules states nothing above a button that is already open. + val captions = buildList { + // The reserve falls to the amount-only line rather than to a named one: "$100 of + // Dollars" says dollars twice. Same reason the token is still named on the button + // below — that is a thing to buy, not a restatement of the figure. + val requirementCurrency = currency?.nameInRequirement + if (requirement != null) { + add( + if (requirementCurrency != null) { + stringResource( + R.string.subtitle_chatGate_minimumBalance, + requirement.amount.formatted(), + requirementCurrency, + ) + } else { + stringResource( + R.string.subtitle_chatGate_minimumBalance_anyToken, + requirement.amount.formatted(), + ) + } + ) + } + if (staffOnly) add(stringResource(R.string.subtitle_chatGate_staffOnly)) } - if (caption != null) { + captions.forEach { caption -> Text( text = caption, style = CodeTheme.typography.textSmall, @@ -72,15 +112,15 @@ internal fun GroupGateBar( ) } - if (balance != null) { + if (unmetBalance != null) { CodeButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.action_buyMoreToken, ticker.orEmpty()), - // Nothing to buy until the symbol resolves: the label would read "Buy More $" and + text = stringResource(R.string.action_buyMoreToken, currency?.name.orEmpty()), + // Nothing to buy until the currency resolves: the label would read "Buy More " and // the token screen has no mint to open. - enabled = ticker != null, + enabled = currency != null, onClick = { - balance.mints.firstOrNull() + unmetBalance.mints.firstOrNull() ?.let { onAction(ChatAction.ViewToken(Mint(it.bytes))) } }, ) @@ -88,10 +128,18 @@ internal fun GroupGateBar( CodeButton( modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.action_joinChat), + // Membership comes back through the roster, not through the join's reply, so the + // spinner is the only thing that moves while the call is out — and then the + // checkmark, which the reply does answer. + isLoading = joinProgress.loading, + isSuccess = joinProgress.success, // A staff rule is not something the user can go and satisfy, so the button says // what it would do and refuses to do it, rather than sending a join the server - // will reject. - enabled = unmet == null, + // will reject. An undecided gate is not that: it is a fact that has not arrived, + // and dimming the button until it does makes the button change under the reader a + // quarter-second after they are looking at it. The server checks the rules on + // every join anyway, so an early tap gets the same refusal by a slower route. + enabled = unmet == null && joinProgress.isIdle, onClick = { onAction(ChatAction.JoinChat) }, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index ed37703134..54016ca917 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.lazy.LazyListState @@ -31,11 +32,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.ChatAnimations +import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessagePointer import com.flipcash.shared.chat.models.ChatAction @@ -46,6 +49,7 @@ import com.flipcash.shared.chat.models.LocalChatActionHandler import com.flipcash.shared.chat.models.LocalLinkCardResolution import com.flipcash.shared.chat.models.SeparatorConfig import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.CodeButton import com.getcode.ui.utils.rememberKeyboardController import com.getcode.util.vibration.LocalVibrator import kotlinx.coroutines.delay @@ -307,6 +311,40 @@ internal fun MessageList( // This prevents these items from being the only content before messages // load, which would cause the list to start at the wrong scroll position. if (messages.itemCount > 0 || refreshSettled) { + // Emitted before the info card, and so drawn below it: this list is reverseLayout, + // where the last item is the topmost one. Both of the items below belong under the + // card the way the designs show them, and neither can appear while there are + // messages — one stands in for a transcript, the other says there isn't one yet. + if (messages.itemCount == 0) { + val inviteUrl = state.groupInviteUrl + when { + // Node 10127:117171. The blur over this list is what makes it a preview; + // see GatedTranscriptPlaceholder for why it is drawn rather than fetched. + state.isGatedPreview -> item(key = "gated-transcript-placeholder") { + GatedTranscriptPlaceholder(modifier = Modifier.fillParentMaxWidth()) + } + + // Node 10127:118280 — the group the creator has just made. Inviting + // someone is the only thing to do with an empty group, so it is offered + // here rather than left to the profile screen. + inviteUrl != null -> item(key = "group-invite-action") { + Box( + modifier = Modifier + .fillParentMaxWidth() + .padding(top = CodeTheme.dimens.grid.x4), + contentAlignment = Alignment.Center, + ) { + CodeButton( + text = stringResource(R.string.action_invitePeopleToJoin), + shape = CircleShape, + enabled = !selecting, + onClick = { onAction(ChatAction.InviteToGroup) }, + ) + } + } + } + } + // Trailing date separator for the oldest loaded message. // This replaces the `after == null` boundary from insertSeparators // which Paging 3 defers until endOfPaginationReached, causing a @@ -348,7 +386,7 @@ internal fun MessageList( } else { null }, - ticker = state.ruleTicker, + currencyName = state.ruleCurrency?.nameInRequirement, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt index ab90b97082..6aadfbf62e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt @@ -5,24 +5,45 @@ import androidx.compose.material.icons.automirrored.outlined.Logout import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.flipcash.app.menu.FullMenuItem -import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.features.messenger.R /** - * The one thing a group's profile can act on. + * What the group's profile can act on. * - * Its action is a [ChatViewModel.Event] rather than an event of the profile's own, because the - * group is the conversation's — the leave has to clear the membership the transcript is reading, - * and there is one view model holding it. + * Its own type rather than [com.flipcash.app.messenger.internal.ChatViewModel.Event], because the + * two rows do different kinds of thing: leaving is a request to the conversation's view model, + * inviting is a navigation. Naming both here lets one [com.flipcash.app.menu.MenuList] carry them + * and the screen decide which is which. */ -internal data object LeaveChat : FullMenuItem() { +internal sealed interface GroupProfileAction { + data object Invite : GroupProfileAction + data object Leave : GroupProfileAction +} + +/** + * The same invite the empty transcript offers, for a group that already has messages in it. + * + * Shown only while there is a link to hand out, which is the same condition as being a member. + */ +internal data object InviteToGroup : FullMenuItem() { + override val icon: Painter + @Composable get() = painterResource(R.drawable.ic_at) + + override val name: String + @Composable get() = stringResource(R.string.action_invitePeopleToJoin) + + override val action: GroupProfileAction = GroupProfileAction.Invite +} + +internal data object LeaveChat : FullMenuItem() { override val icon: Painter @Composable get() = rememberVectorPainter(Icons.AutoMirrored.Outlined.Logout) override val name: String @Composable get() = stringResource(R.string.title_leaveChat) - override val action: ChatViewModel.Event = ChatViewModel.Event.LeaveChat + override val action: GroupProfileAction = GroupProfileAction.Leave } 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 7a52d3db86..f4600f025d 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 @@ -31,15 +31,6 @@ import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.theme.CodeScaffold import kotlinx.coroutines.flow.filterIsInstance -/** - * Whether the group's profile offers a way out of the group. - * - * Off while the rest of group membership settles: a leave is not something to hand someone before - * the join it undoes is finished. Everything behind it — the confirmation, the call, the close — - * is in place, so this is the only line to change when it ships. - */ -private const val SHOW_LEAVE_ACTION = false - /** * The group's own profile, reached from the info card at the head of its transcript. * @@ -54,12 +45,15 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val group = state.subject as? ChatSubject.Group - // A group you have left has nothing left on this screen to act on, so it closes back to the - // transcript — which is now showing the gate in place of the composer. + // Leaving exits the whole chat flow rather than popping this screen, landing back on the chat + // list: a group you have left is not a conversation you are still in, and closing to the + // transcript would leave you reading it from behind the gate you just put yourself outside of. + // Same exit the profile's block action takes, for the same reason — FlowHost.onExit pops the + // Chat route. LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() - .collect { flowNavigator.back() } + .collect { flowNavigator.exitCanceled() } } CodeScaffold( @@ -71,13 +65,12 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { modifier = Modifier .fillMaxSize() .padding(innerPadding), - // Leaving is the only thing on offer, and only to someone who is in the group — but - // it is held back for now, so this is the identity header on its own. The leave - // itself is wired end to end behind [SHOW_LEAVE_ACTION]; flipping it puts the row back. - items = if (SHOW_LEAVE_ACTION && group?.isMember == true) { - listOf>(LeaveChat) - } else { - emptyList() + // Both rows are for members only: a non-member has no link to hand out and nothing to + // leave. Membership comes from the roster rather than from the gate, so the leave goes + // away the moment the leave itself lands, not when the balance rule next re-decides. + items = buildList> { + if (state.groupInviteUrl != null) add(InviteToGroup) + if (group?.isMember == true) add(LeaveChat) }, header = { GroupProfileHeader( @@ -87,7 +80,15 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { .padding(top = CodeTheme.dimens.grid.x7), ) }, - onItemClick = { viewModel.dispatchEvent(it.action) }, + onItemClick = { item -> + when (item.action) { + // The same sheet the transcript's own invite CTA opens, pushed on this flow so + // it sits over the profile the user asked from. + GroupProfileAction.Invite -> flowNavigator.navigateTo(ChatStep.InviteToGroup) + GroupProfileAction.Leave -> + viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat) + } + }, ) } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatRulesSummaryTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatRulesSummaryTest.kt index 4d46575536..8f6a1c1316 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatRulesSummaryTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatRulesSummaryTest.kt @@ -6,19 +6,20 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.PublicKey import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue /** - * The info card's rules line. Only a minimum balance has anything to say on it — staff membership - * is not something a user can go and acquire, so showing it as a requirement would be an - * instruction with no action behind it. + * The info card's rules lines. A minimum balance carries an amount to state; staff membership is a + * yes or no, answered separately, because a chat can set both. */ class ChatRulesSummaryTest { private val mint = PublicKey(ByteArray(32) { 5 }.toList()) @Test - fun `a minimum balance names the amount and the ticker`() { + fun `a minimum balance names the amount and the currency`() { val rules = ChatRules( listener = listOf(ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(mint))), speaker = emptyList(), @@ -51,15 +52,37 @@ class ChatRulesSummaryTest { } @Test - fun `staff-only rules have no line to render`() { + fun `a staff-only rule has no balance to state, but is still stated`() { val rules = ChatRules(listener = listOf(ChatRuleRequirement.Staff), speaker = emptyList()) assertNull(rules.balanceRequirement()) + assertTrue(rules.requiresStaff()) + } + + @Test + fun `a speaker staff rule counts too`() { + val rules = ChatRules(listener = emptyList(), speaker = listOf(ChatRuleRequirement.Staff)) + + assertTrue(rules.requiresStaff()) + } + + @Test + fun `a chat can ask for both, and both are stated`() { + val balance = ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(mint)) + val rules = ChatRules( + listener = listOf(balance, ChatRuleRequirement.Staff), + speaker = emptyList(), + ) + + assertEquals(balance, rules.balanceRequirement()) + assertTrue(rules.requiresStaff()) } @Test fun `no rules at all have no line to render`() { assertNull(ChatRules(emptyList(), emptyList()).balanceRequirement()) assertNull((null as ChatRules?).balanceRequirement()) + assertFalse(ChatRules(emptyList(), emptyList()).requiresStaff()) + assertFalse((null as ChatRules?).requiresStaff()) } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt new file mode 100644 index 0000000000..8bc763fb2d --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt @@ -0,0 +1,179 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.shared.chat.GroupAccess +import com.getcode.opencode.model.core.bytes +import com.getcode.opencode.model.financial.Fiat +import com.getcode.view.LoadingSuccessState +import org.junit.Test +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The two answers the chat screen derives rather than stores: whether it is being read from outside + * the group, and whether there is an invite to hand out. + * + * Both are read by more than one renderer — the blur and the placeholder ask the first, the empty + * state and the transcript's invite action ask the second — so what is pinned here is that each has + * exactly one source and that a DM is never mistaken for a gated group. + */ +class ChatViewModelStateTest { + + private val groupUuid = UUID.fromString("0189d5f1-3c6a-7b4e-8f21-9c3d4e5f6a7b") + private val groupChatId = ChatId(groupUuid.bytes) + private val unmet = ChatRuleRequirement.MinimumBalance(Fiat(100.0), emptyList()) + + private fun group(isMember: Boolean) = ChatSubject.Group( + chatId = groupChatId, + groupTitle = "Ballers", + picture = null, + memberCount = 1L, + rules = null, + isMember = isMember, + ) + + private val dm = ChatSubject.Contact( + ChatParticipant.Contact( + DeviceContact( + e164 = "+15551234567", + androidContactId = 1L, + displayName = "Ada Lovelace", + photoUri = null, + displayNumber = "(555) 123-4567", + ) + ) + ) + + @Test + fun `a group the viewer is outside of is a gated preview`() { + assertTrue( + ChatViewModel.State( + subject = group(isMember = false), + groupAccess = GroupAccess.Blocked(unmet), + ).isGatedPreview + ) + // Eligible is still outside: the transcript stays withheld until the join lands, which is + // what makes "Join Chat" and "Buy More" the same screen with two buttons. + assertTrue( + ChatViewModel.State( + subject = group(isMember = false), + groupAccess = GroupAccess.Eligible, + ).isGatedPreview + ) + } + + @Test + fun `the blur is on before the gate has decided anything`() { + // The reason this reads the subject and not the access: access arrives through the balance + // and staff flows, so it is null for their first frames, while the info card draws as soon + // as the subject resolves. Off the access alone, a withheld transcript rendered sharp and + // then blurred — which is showing the thing it is meant to withhold. + assertTrue( + ChatViewModel.State( + subject = group(isMember = false), + groupAccess = null, + ).isGatedPreview + ) + // The same frame for a member is the other answer, not a pending one: nothing about their + // balance can put them outside a group they are in. + assertFalse( + ChatViewModel.State( + subject = group(isMember = true), + groupAccess = null, + ).isGatedPreview + ) + } + + @Test + fun `a member reads the group unblurred`() { + assertFalse( + ChatViewModel.State( + subject = group(isMember = true), + groupAccess = GroupAccess.Membered, + ).isGatedPreview + ) + } + + @Test + fun `the gate holds while the join button is showing its checkmark`() { + // The roster can confirm membership before the checkmark has been drawn. Holding the gate + // for as long as the success is up keeps the blur and the button in place for the beat, so + // the composer does not arrive over a transcript that is still blurred. + assertTrue( + ChatViewModel.State( + subject = group(isMember = true), + groupAccess = GroupAccess.Membered, + joinProgress = LoadingSuccessState(success = true), + ).isGatedPreview + ) + } + + @Test + fun `clearing the join state hands the member the group like any other`() { + // Success is cleared once the hold elapses, and that is the handover: nothing else about + // this state changed. + assertFalse( + ChatViewModel.State( + subject = group(isMember = true), + groupAccess = GroupAccess.Membered, + joinProgress = LoadingSuccessState(), + ).isGatedPreview + ) + } + + @Test + fun `a DM has no gate to render`() { + // The null access is the whole point: a default would blur every contact conversation. + assertFalse(ChatViewModel.State(subject = dm, groupAccess = null).isGatedPreview) + assertFalse(ChatViewModel.State().isGatedPreview) + } + + @Test + fun `only a group the viewer has joined has an invite to share`() { + assertEquals( + "https://app.flipcash.com/chat/$groupUuid", + ChatViewModel.State(subject = group(isMember = true)).groupInviteUrl, + ) + assertNull(ChatViewModel.State(subject = group(isMember = false)).groupInviteUrl) + assertNull(ChatViewModel.State(subject = dm).groupInviteUrl) + assertNull(ChatViewModel.State().groupInviteUrl) + } + + @Test + fun `a chat id with no uuid form yields no link rather than a malformed one`() { + // A DM's id is a 32-byte hash. Reaching this through a Group subject is unreachable in the + // app, but the property is what the invite action reads, so it answers rather than throws. + val hashed = ChatSubject.Group( + chatId = ChatId(ByteArray(32) { 9 }.toList()), + groupTitle = "Ballers", + picture = null, + memberCount = 1L, + rules = null, + isMember = true, + ) + assertNull(ChatViewModel.State(subject = hashed).groupInviteUrl) + } + + @Test + fun `the reserve is named on a button but not beside an amount`() { + val dollars = RuleCurrency(name = "Dollars", isReserve = true) + // The button still has something to say — "Buy More Dollars" is a thing to go and do. + assertEquals("Dollars", dollars.name) + // The requirement line does not: "$100 of Dollars" says dollars twice. + assertNull(dollars.nameInRequirement) + } + + @Test + fun `every other token is named in both places`() { + val jeffy = RuleCurrency(name = "Jeffy", isReserve = false) + assertEquals("Jeffy", jeffy.name) + // Without the name, "$100" would not say which holding clears the bar. + assertEquals("Jeffy", jeffy.nameInRequirement) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt index dabe7a950b..9a98edc1ae 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt @@ -15,6 +15,7 @@ import com.flipcash.app.messenger.internal.screens.components.ChatInfoCard import com.flipcash.app.messenger.internal.screens.components.ChatTopBar import com.flipcash.app.messenger.internal.screens.components.GroupGateBar import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.shared.chat.GroupAccess import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules @@ -22,6 +23,7 @@ import com.flipcash.services.models.chat.ChatType import com.getcode.navigation.core.CodeNavigator import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint +import com.getcode.view.LoadingSuccessState import io.mockk.mockk import org.junit.Rule import org.junit.Test @@ -72,6 +74,21 @@ class GroupChromeScreenshotTest { // The outsider's view: same group, carrying the balance rule the gate reports. private val gatedGroup = memberedGroup.copy(rules = balanceRule, isMember = false) + // Staff-only, with no balance to state: the card's staff line is the only rule it shows. + private val staffGroup = memberedGroup.copy( + rules = ChatRules(listener = listOf(ChatRuleRequirement.Staff), speaker = emptyList()), + isMember = false, + ) + + // Both rules on one chat, which the proto allows and the card states as two lines. + private val bothRules = ChatRules( + listener = listOf( + ChatRuleRequirement.MinimumBalance(mints = listOf(mint), amount = Fiat(100.0)), + ChatRuleRequirement.Staff, + ), + speaker = emptyList(), + ) + // The degenerate roster: the plural string's "1 person" arm and an untitled group. private val soloGroup = memberedGroup.copy(groupTitle = null, memberCount = 1) @@ -116,10 +133,21 @@ class GroupChromeScreenshotTest { ) { val cardWidth = Modifier.width(300.dp) ChatInfoCard(subject = memberedGroup, modifier = cardWidth) - // The ticker resolved, as it is once the token cache fills in. - ChatInfoCard(subject = gatedGroup, modifier = cardWidth, ticker = "BADBOYS") - // The ticker not yet resolved — the frame the card renders first. + // The currency resolved, as it is once the token cache fills in. + ChatInfoCard( + subject = gatedGroup, + modifier = cardWidth, + currencyName = "Bad Boys", + ) + // The currency not yet resolved — the frame the card renders first. ChatInfoCard(subject = gatedGroup, modifier = cardWidth) + // A staff-only group, and one asking for both: the card states each rule it has. + ChatInfoCard(subject = staffGroup, modifier = cardWidth) + ChatInfoCard( + subject = staffGroup.copy(rules = bothRules), + modifier = cardWidth, + currencyName = "Bad Boys", + ) } } } @@ -137,17 +165,21 @@ class GroupChromeScreenshotTest { modifier = Modifier.width(360.dp), verticalArrangement = Arrangement.spacedBy(24.dp), ) { - // Blocked on a balance, ticker resolved: "Buy More $BADBOYS", enabled. + val balanceRule = ChatRuleRequirement.MinimumBalance( + mints = listOf(mint), + amount = Fiat(100.0), + ) + val badBoys = RuleCurrency(name = "Bad Boys", isReserve = false) + // Blocked on a balance, currency resolved: "Buy More Bad Boys", enabled. GroupGateBar( - access = GroupAccess.Blocked( - unmet = ChatRuleRequirement.MinimumBalance( - mints = listOf(mint), - amount = Fiat(100.0), - ), - ), - ticker = "BADBOYS", + access = GroupAccess.Blocked(unmet = balanceRule), + requirement = balanceRule, + onAction = {}, + staffOnly = false, + currency = badBoys, ) - // The same state before the ticker resolves: the button is disabled. + // The same state before the currency resolves: the button is disabled and the + // caption falls back to the amount alone. GroupGateBar( access = GroupAccess.Blocked( unmet = ChatRuleRequirement.MinimumBalance( @@ -155,15 +187,68 @@ class GroupChromeScreenshotTest { amount = Fiat(100.0), ), ), - ticker = null, + requirement = ChatRuleRequirement.MinimumBalance( + mints = emptyList(), + amount = Fiat(100.0), + ), + onAction = {}, + staffOnly = false, + currency = null, + ) + // Gated on the reserve: the caption drops "of Dollars" because the amount is + // already a dollar figure, while the button still names what to go and buy. + GroupGateBar( + access = GroupAccess.Blocked(unmet = balanceRule), + requirement = balanceRule, + onAction = {}, + staffOnly = false, + currency = RuleCurrency(name = "Dollars", isReserve = true), ) - // Blocked on staff: no action the viewer can take. + // Blocked on staff: no action the viewer can take, and no balance to state — + // but the line says why the button is dead rather than leaving it bare. GroupGateBar( access = GroupAccess.Blocked(unmet = ChatRuleRequirement.Staff), - ticker = null, + requirement = null, + onAction = {}, + staffOnly = true, + currency = null, + ) + // Eligible for a gated group — node 10127:117120. The requirement is stated over + // a Join that works: it is the group's rule, not this viewer's shortfall. + GroupGateBar( + access = GroupAccess.Eligible, + requirement = balanceRule, + onAction = {}, + staffOnly = false, + currency = badBoys, + ) + // Mid-join: the roster has not confirmed membership yet. + GroupGateBar( + access = GroupAccess.Eligible, + requirement = balanceRule, + onAction = {}, + staffOnly = false, + currency = badBoys, + joinProgress = LoadingSuccessState(loading = true), + ) + // The join came back accepted: the checkmark the gate holds while the blur + // lifts, before the composer takes its place. + GroupGateBar( + access = GroupAccess.Membered, + requirement = balanceRule, + onAction = {}, + staffOnly = false, + currency = badBoys, + joinProgress = LoadingSuccessState(success = true), + ) + // A group with no rules at all: nothing to state, one button. + GroupGateBar( + access = GroupAccess.Eligible, + requirement = null, + onAction = {}, + staffOnly = false, + currency = null, ) - // Eligible: one button, "Join Chat". - GroupGateBar(access = GroupAccess.Eligible, ticker = null) } } } @@ -172,6 +257,54 @@ class GroupChromeScreenshotTest { capture("group_gate_bar.png") } + @Test + fun rendersStaffGateBar() { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.width(360.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + val balanceRule = ChatRuleRequirement.MinimumBalance( + mints = listOf(mint), + amount = Fiat(100.0), + ) + // Staff, in a staff-only group they are not in: the same line over a Join that + // works. This is the case that used to arrive as a disabled button, because the + // gate treated the staff rule as unsatisfiable by anyone. + GroupGateBar( + access = GroupAccess.Eligible, + requirement = null, + onAction = {}, + staffOnly = true, + currency = null, + ) + // A group asking for both: two lines, one per rule. + GroupGateBar( + access = GroupAccess.Blocked(unmet = balanceRule), + requirement = balanceRule, + onAction = {}, + staffOnly = true, + currency = RuleCurrency(name = "Bad Boys", isReserve = false), + ) + // Staff who clear the staff rule but not the balance one: the balance line is + // still what is holding them up, so Buy More is what they get. + GroupGateBar( + access = GroupAccess.Blocked(unmet = balanceRule), + requirement = balanceRule, + onAction = {}, + staffOnly = true, + currency = RuleCurrency(name = "Dollars", isReserve = true), + ) + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("group_gate_bar_staff.png") + } + private fun capture(name: String) { val root: View = composeRule.activity.findViewById(android.R.id.content) val width = root.width.takeIf { it > 0 } ?: 1080 diff --git a/apps/flipcash/features/tipping/build.gradle.kts b/apps/flipcash/features/tipping/build.gradle.kts index a44d473254..81e8e072ca 100644 --- a/apps/flipcash/features/tipping/build.gradle.kts +++ b/apps/flipcash/features/tipping/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(testFixtures(project(":ui:resources"))) testImplementation(libs.robolectric) implementation(project(":services:flipcash")) @@ -16,6 +17,7 @@ dependencies { implementation(project(":libs:messaging")) implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:bills")) + implementation(project(":apps:flipcash:shared:blob")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:chat-ui")) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/FindByUsernameScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/FindByUsernameScreen.kt new file mode 100644 index 0000000000..482356c1c1 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/FindByUsernameScreen.kt @@ -0,0 +1,181 @@ +package com.flipcash.app.tipping + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.ui.DisplayTextInput +import com.flipcash.app.tipping.internal.MaxHandleLength +import com.flipcash.app.tipping.internal.FindByUsernameViewModel +import com.flipcash.features.tipping.R +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.rememberKeyboardController +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Node 9442:5825 — start a chat with someone by their public `@handle`. + * + * Reached from "Find by Username" on the New Chat chooser, and left by becoming the chat itself: the + * handle resolves to a user id, which is enough to open the conversation whether or not it exists + * yet ([com.flipcash.app.core.chat.ChatIdentifier.ByUser]). The chat takes the place of this screen + * and of the chooser above the list, so backing out of the chat lands on the Chats list — both have + * done their job by then, and re-showing them would put two screens between the chat and the list it + * belongs to. + */ +@Composable +fun FindByUsernameScreen() { + val navigator = LocalCodeNavigator.current + val keyboard = rememberKeyboardController() + + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + // fillMaxSize + weight, rather than letting the scaffold size itself: the scaffold fills what + // it is given, so stacking it under the app bar in a wrap-height column makes the column taller + // than the screen and clips the "Next" bar off the bottom. + Column(modifier = Modifier.fillMaxSize()) { + AppBarWithTitle( + onBackIconClicked = { keyboard.hideIfVisible { navigator.pop() } }, + ) + FindByUsernameScreenContent(state, viewModel::dispatchEvent) + } + + LaunchedEffect(viewModel, navigator) { + viewModel.eventFlow + .filterIsInstance() + .onEach { resolved -> + // Not `replace`: that drops this screen only, and the chooser underneath it would + // stay between the chat and the Chats list. Both are entry UI for the chat about to + // open, and popping by route type leaves the pop correct if either is absent — + // reaching this screen directly, say from a deeplink. + navigator.popUntil { + it !is AppRoute.Messaging.FindByUsername && it !is AppRoute.Messaging.NewChat + } + navigator.push(AppRoute.Messaging.Chat(resolved.identifier)) + } + .launchIn(this) + } +} + +@Composable +private fun ColumnScope.FindByUsernameScreenContent( + state: FindByUsernameViewModel.State, + dispatchEvent: (FindByUsernameViewModel.Event) -> Unit, +) { + val keyboard = rememberKeyboardController() + CodeScaffold( + modifier = Modifier + .weight(1f) + .padding(horizontal = CodeTheme.dimens.inset), + topBar = { + Text( + modifier = Modifier + .fillMaxWidth(0.7f) + .padding( + top = CodeTheme.dimens.grid.x2, + bottom = CodeTheme.dimens.inset, + ), + text = stringResource(R.string.title_newChat), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + }, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding( + top = CodeTheme.dimens.grid.x6, + bottom = CodeTheme.dimens.grid.x3, + ).imePadding(), + text = stringResource(R.string.action_next), + enabled = state.hasUsername && state.processingState.isIdle, + isLoading = state.processingState.loading, + isSuccess = state.processingState.success, + onClick = { + keyboard.hideIfVisible { + dispatchEvent(FindByUsernameViewModel.Event.LookupUsername) + } + }, + ) + } + ) { padding -> + val focusRequester = remember { FocusRequester() } + // Padding on the wrapper rather than the field — on the field it inflates the box and drops + // the sublabel away from the entered text (see UsernameEntryScreen). + Column(modifier = Modifier.padding(padding)) { + DisplayTextInput( + state = state.usernameFieldState, + placeholder = stringResource(R.string.hint_username), + sublabel = stringResource(R.string.subtitle_newChat), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions( + // A handle is lowercase and unspaced; autocapitalizing it would only produce + // input the transformation below has to undo. + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + ), + onKeyboardAction = { + keyboard.hideIfVisible { + dispatchEvent(FindByUsernameViewModel.Event.LookupUsername) + } + }, + inputTransformation = HandleInputTransformation, + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +} + +/** + * Holds the field to the same `^[a-z0-9_]+$` charset the claim screen enforces, so a handle pasted + * out of a bio or a message — `@fred_wilson`, `Fred_Wilson` — is looked up as the server stores it + * instead of missing. + * + * Length is clamped to the server's 15, unlike the claim screen, which lets the user over-type so + * it can tell them the handle is too long. Nobody is choosing a handle here, so a longer one is + * only a typo or an over-eager paste — and left unclamped it fails request validation, which + * surfaces as "Something Went Wrong" rather than anything the user can act on. + */ +private val HandleInputTransformation = InputTransformation { + val current = asCharSequence().toString() + val sanitized = current.lowercase() + .filter { it in 'a'..'z' || it in '0'..'9' || it == '_' } + .take(MaxHandleLength) + if (sanitized != current) { + replace(0, length, sanitized) + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt index f773ef2937..e65805588c 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt @@ -1,171 +1,55 @@ package com.flipcash.app.tipping +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.InputTransformation -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.ui.DisplayTextInput -import com.flipcash.app.tipping.internal.MaxHandleLength -import com.flipcash.app.tipping.internal.NewChatViewModel +import com.getcode.ui.components.ChoiceRow import com.flipcash.features.tipping.R import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.theme.CodeButton -import com.getcode.ui.theme.CodeScaffold -import com.getcode.ui.utils.rememberKeyboardController -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach /** - * Node 9442:5825 — start a chat with someone by their public `@handle`. + * Node 10127:117987 — the two ways to start a chat, behind the Chats list's "+". * - * Reached from the "+" on the Chats list, and left by becoming the chat itself: the handle resolves - * to a user id, which is enough to open the conversation whether or not it exists yet - * ([com.flipcash.app.core.chat.ChatIdentifier.ByUser]). The chat replaces this screen rather than - * stacking on it, so backing out of the chat lands on the Chats list — the entry field has done its - * job by then, and re-showing it would put a screen between the chat and the list it belongs to. + * A chooser rather than a direct push because there are now two kinds of chat to start and they + * share no entry UI: a group is created here and then invited into, a DM is opened by resolving + * someone's handle. Neither screen has any state of its own to carry, so this one holds no view + * model — it pushes and is popped. */ @Composable fun NewChatScreen() { val navigator = LocalCodeNavigator.current - val keyboard = rememberKeyboardController() - val viewModel = hiltViewModel() - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - - // fillMaxSize + weight, rather than letting the scaffold size itself: the scaffold fills what - // it is given, so stacking it under the app bar in a wrap-height column makes the column taller - // than the screen and clips the "Next" bar off the bottom. Column(modifier = Modifier.fillMaxSize()) { AppBarWithTitle( - onBackIconClicked = { keyboard.hideIfVisible { navigator.pop() } }, + title = stringResource(R.string.title_startNewChat), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { navigator.pop() }, ) - NewChatScreenContent(state, viewModel::dispatchEvent) - } - - LaunchedEffect(viewModel, navigator) { - viewModel.eventFlow - .filterIsInstance() - .onEach { navigator.replace(AppRoute.Messaging.Chat(it.identifier)) } - .launchIn(this) - } -} -@Composable -private fun ColumnScope.NewChatScreenContent( - state: NewChatViewModel.State, - dispatchEvent: (NewChatViewModel.Event) -> Unit, -) { - val keyboard = rememberKeyboardController() - CodeScaffold( - modifier = Modifier - .weight(1f) - .padding(horizontal = CodeTheme.dimens.inset), - topBar = { - Text( - modifier = Modifier - .fillMaxWidth(0.7f) - .padding( - top = CodeTheme.dimens.grid.x2, - bottom = CodeTheme.dimens.inset, - ), - text = stringResource(R.string.title_newChat), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, + Column( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x3), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + ChoiceRow( + label = stringResource(R.string.action_createPublicGroup), + icon = R.drawable.ic_group_3, + onClick = { navigator.push(AppRoute.Messaging.NewGroup) }, ) - }, - bottomBar = { - CodeButton( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding( - top = CodeTheme.dimens.grid.x6, - bottom = CodeTheme.dimens.grid.x3, - ).imePadding(), - text = stringResource(R.string.action_next), - enabled = state.hasUsername && state.processingState.isIdle, - isLoading = state.processingState.loading, - isSuccess = state.processingState.success, - onClick = { - keyboard.hideIfVisible { - dispatchEvent(NewChatViewModel.Event.LookupUsername) - } - }, - ) - } - ) { padding -> - val focusRequester = remember { FocusRequester() } - // Padding on the wrapper rather than the field — on the field it inflates the box and drops - // the sublabel away from the entered text (see UsernameEntryScreen). - Column(modifier = Modifier.padding(padding)) { - DisplayTextInput( - state = state.usernameFieldState, - placeholder = stringResource(R.string.hint_username), - sublabel = stringResource(R.string.subtitle_newChat), - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester), - keyboardOptions = KeyboardOptions( - // A handle is lowercase and unspaced; autocapitalizing it would only produce - // input the transformation below has to undo. - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done, - ), - onKeyboardAction = { - keyboard.hideIfVisible { - dispatchEvent(NewChatViewModel.Event.LookupUsername) - } - }, - inputTransformation = HandleInputTransformation, + ChoiceRow( + label = stringResource(R.string.action_findByUsername), + icon = R.drawable.ic_at, + onClick = { navigator.push(AppRoute.Messaging.FindByUsername) }, ) } - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - } -} - -/** - * Holds the field to the same `^[a-z0-9_]+$` charset the claim screen enforces, so a handle pasted - * out of a bio or a message — `@fred_wilson`, `Fred_Wilson` — is looked up as the server stores it - * instead of missing. - * - * Length is clamped to the server's 15, unlike the claim screen, which lets the user over-type so - * it can tell them the handle is too long. Nobody is choosing a handle here, so a longer one is - * only a typo or an over-eager paste — and left unclamped it fails request validation, which - * surfaces as "Something Went Wrong" rather than anything the user can act on. - */ -private val HandleInputTransformation = InputTransformation { - val current = asCharSequence().toString() - val sanitized = current.lowercase() - .filter { it in 'a'..'z' || it in '0'..'9' || it == '_' } - .take(MaxHandleLength) - if (sanitized != current) { - replace(0, length, sanitized) } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt new file mode 100644 index 0000000000..29bd185e8b --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt @@ -0,0 +1,131 @@ +package com.flipcash.app.tipping + +import android.os.Parcelable +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.setValue +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.scene.SinglePaneSceneStrategy +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.chat.NewGroupStep +import com.flipcash.app.tipping.internal.CreateGroupViewModel +import com.flipcash.app.tipping.internal.screens.NewGroupCustomAmountSheet +import com.flipcash.app.tipping.internal.screens.NewGroupFormScreen +import com.flipcash.app.tipping.internal.screens.SelectGroupCurrencySheet +import com.flipcash.services.models.chat.ChatId +import com.getcode.navigation.annotatedEntry +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.flow.FlowHost +import com.getcode.navigation.flow.flowSharedViewModel +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.navigation.flow.rememberInitialStack +import com.getcode.navigation.results.NavResultStateRegistry +import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy +import com.getcode.ui.utils.rememberKeyboardController +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Creating a public group — section 10153:22901, from the empty form to the created chat. + * + * A flow, and non-linear: the currency sheet and the custom-amount keypad are edits to the form's + * draft that return to it. So the steps navigate themselves rather than advancing through a list, + * and the shared [CreateGroupViewModel] — scoped to the flow, not to any one step — is what they + * are all editing. Holding the draft there is also what gives one create attempt's idempotency key + * a lifetime long enough to survive a retry. + * + * The flow ends at `StartChat` succeeding. Handing out the invite link is offered from inside the + * group ([com.flipcash.app.core.chat.ChatStep.InviteToGroup]) rather than stacked on the form, so + * creating a group and inviting people to it are separate decisions. + */ +@Composable +fun NewGroupFlowScreen( + route: AppRoute.Messaging.NewGroup, + resultStateRegistry: NavResultStateRegistry, +) { + val navigator = LocalCodeNavigator.current + val keyboard = rememberKeyboardController() + + // What the flow produced, held out here rather than read from the draft on the way out: the + // shared view model is scoped *inside* the host, so `onExit` — which is where leaving has to be + // acted on, because a finished create and an abandoned one are the same departure — has no way + // to reach it. + var createdChat by remember { mutableStateOf(null) } + + FlowHost( + initialStack = route.rememberInitialStack(), + resultStateRegistry = resultStateRegistry, + // The name field owns the keyboard for most of the flow, so the same rule the chat flow + // follows applies: put it away before the screen behind this one is uncovered. + onExit = { _, _ -> + keyboard.hideIfVisible { + val chatId = createdChat + if (chatId == null) { + navigator.pop() + } else { + // Leaving a created group opens it rather than returning to the Chats list. + // The chat is in that list already — `create` persisted it — but the user's last + // action was making this group, so that is what they land on. Popping by route + // type, as FindByUsername does, because the chooser that opened this flow is + // entry UI for the same chat and should not sit between it and the list. + navigator.popUntil { + it !is AppRoute.Messaging.NewGroup && it !is AppRoute.Messaging.NewChat + } + navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) + } + } + }, + entryProvider = newGroupEntryProvider(onChatCreated = { createdChat = it }), + // SelectCurrency and CustomAmount are Sheets; without the sheet strategy they would fall + // through to SinglePane and cover the form instead of sitting over it. Neither returns a + // result through the store — both write straight to the shared draft — so the strategy's + // dismiss-delivers-Canceled path has nothing to address, hence the null key. + sceneStrategies = listOf( + ModalBottomSheetSceneStrategy(navigator.resultStore) { null }, + SinglePaneSceneStrategy(), + ), + ) +} + +@Composable +private fun newGroupEntryProvider( + onChatCreated: (ChatId) -> Unit, +): (NavKey) -> NavEntry = entryProvider { + annotatedEntry { + FlowNewGroupFormScreen(onChatCreated = onChatCreated) + } + annotatedEntry { + SelectGroupCurrencySheet(flowSharedViewModel()) + } + annotatedEntry { + NewGroupCustomAmountSheet(flowSharedViewModel()) + } +} + +@Composable +private fun FlowNewGroupFormScreen(onChatCreated: (ChatId) -> Unit) { + val viewModel = flowSharedViewModel() + val flowNavigator = rememberFlowNavigator() + + NewGroupFormScreen(viewModel) + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { event -> + onChatCreated(event.chat.chatId) + // The draft has become a chat, so the flow is over — leaving is what opens it, in + // the host's `onExit` above. Exiting rather than popping the form, because the form + // is the flow's root and there is nothing behind it to return to. + flowNavigator.exitCanceled() + } + .launchIn(this) + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt new file mode 100644 index 0000000000..ff9fafea64 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt @@ -0,0 +1,581 @@ +package com.flipcash.app.tipping.internal + +import android.net.Uri +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.text.input.TextFieldState +import androidx.lifecycle.viewModelScope +import com.flipcash.app.blob.BlobStorageCoordinator +import com.flipcash.app.blob.ImageUploadPreparer +import com.flipcash.app.core.data.Loadable +import com.flipcash.app.core.ui.ConfirmationStyle +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.tipping.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.BlobRejectedException +import com.flipcash.services.models.ModerationResult +import com.flipcash.services.models.StartChatError +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.flipcash.services.models.chat.RejectionReason +import com.flipcash.services.models.chat.StartChatParameters +import com.flipcash.services.models.blob.UploadPolicy +import com.flipcash.shared.amountentry.AmountEntryDelegate +import com.flipcash.shared.amountentry.AmountEntryLabel +import com.flipcash.shared.amountentry.AmountEntryStyle +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.GroupAccess +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.TokenWithBalance +import com.getcode.solana.keys.Mint +import com.getcode.util.resources.ContentReader +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import com.getcode.view.LoadingSuccessState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +/** + * The three preset balance requirements on the form — node 10127:118014's `$10 / $50 / $100` chips. + * + * USD, because that is the currency a token balance is held in + * ([TokenWithBalance.balance]) and therefore the one [GroupAccess] compares a requirement against. + * The fourth chip is the `…`, which opens [NewGroupStep.CustomAmount] instead of carrying a value. + */ +internal val BalancePresets: List = listOf(Fiat(10), Fiat(50), Fiat(100)) + +/** + * The draft behind creating a public group — section 10153:22901 — shared across the flow's steps. + * + * One view model for all four steps rather than one each, because they are four views of a single + * draft: the title and picture are entered on the form, the mint on the currency sheet, the amount + * on a chip or the custom keypad, and the invite step shares what the draft became. Holding it here + * is also what gives an attempt's idempotency key a lifetime longer than a single screen — see + * [GroupCreateAttempt]. + * + * Two rules are enforced before the call rather than after it. The creator has to satisfy the rules + * they are setting, so Create stays inert while the selected mint's balance is under the amount — + * `StartChat` would answer `RULES_NOT_SATISFIED`, which is a worse way to learn it. And the picture + * has to be uploaded and `READY` first, because `GroupChatParameters.picture` is a blob id, not + * bytes. Both server results are still handled: a balance can move between the check and the call. + */ +@HiltViewModel +internal class CreateGroupViewModel @Inject constructor( + exchange: Exchange, + tokenCoordinator: TokenCoordinator, + dispatchers: DispatcherProvider, + private val chatCoordinator: ChatCoordinator, + private val blobStorage: BlobStorageCoordinator, + private val imagePreparer: ImageUploadPreparer, + private val contentReader: ContentReader, + private val resources: ResourceHelper, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + + data class State( + val titleFieldState: TextFieldState = TextFieldState(), + /** The picked picture's re-encoded local copy, while it is being prepared and after. */ + val image: Loadable = Loadable.Loading(), + val imageMimeType: String = "", + val uploadPolicy: UploadPolicy? = null, + /** What the account holds, which is both the currency list and the self-satisfaction check. */ + val balances: List = emptyList(), + /** The preferred rate, for reading a custom keypad entry back into USD. */ + val rate: Rate = Rate.oneToOne, + val mint: Mint? = null, + /** + * Whether the creator has picked the mint, rather than inherited the one the form opened + * on. Not `mint != null`: the form always opens with one named, so nothing about the mint + * itself distinguishes a default from a choice. + */ + val mintChosen: Boolean = false, + val amount: Fiat? = null, + val processingState: LoadingSuccessState = LoadingSuccessState(), + /** The chat `StartChat` returned. Non-null is the whole precondition for the invite step. */ + val created: ChatMetadata? = null, + ) { + val title: String + get() = titleFieldState.text.toString().trim() + + val hasTitle: Boolean + get() = title.isNotEmpty() + + /** The selected mint's metadata, which is where the row's name and image come from. */ + val token: Token? + get() = mint?.let { selected -> balances.firstOrNull { it.token.address == selected }?.token } + + /** + * What the form and its errors call the requirement's currency — `$BadBoys` in node + * 10127:118194, `Jeffy` in 10127:118014. + * + * [Token.name], not [Token.symbol]: the name is what the design shows and what the balance + * list this mint was picked from already renders + * ([com.getcode.opencode.model.financial.TokenWithBalance.displayName]), so the symbol + * would name the same holding two different ways one screen apart. + */ + val currencyName: String? + get() = token?.name + + /** + * The rules the draft describes, once it describes any. + * + * Listener only, and exactly one mint. A listener requirement is what gates *entry*, which + * is what this design sets; `speaker` is left empty rather than null because + * [ChatRules] holds both as lists. + */ + val rules: ChatRules? + get() { + val mint = mint ?: return null + val amount = amount ?: return null + return ChatRules( + listener = listOf(ChatRuleRequirement.MinimumBalance(amount, listOf(mint))), + speaker = emptyList(), + ) + } + + /** + * The creator measured against their own rule, by the same predicate the join gate uses. + * + * `isMember = false` deliberately: they are not a member of a chat that does not exist, and + * the question being asked is exactly the one the gate asks of a stranger. + */ + val access: GroupAccess? + get() = rules?.let { + GroupAccess.evaluate( + isMember = false, + rules = it, + balances = balances, + // [rules] only ever builds a minimum balance — the form has no staff control — + // so nothing here reads this. It is passed rather than defaulted so that adding + // such a control has to come back and answer the question. + isStaff = false, + ) + } + + /** Null rules can't be unsatisfied — an incomplete draft is blocked by [canCreate] instead. */ + val selfSatisfied: Boolean + get() = access.let { it == null || it is GroupAccess.Eligible } + + val canCreate: Boolean + get() = hasTitle && rules != null && selfSatisfied && processingState.isIdle + + /** + * The amount shown in the `…` chip's place, when one was entered there. + * + * A custom amount has nowhere else to appear — the three chips are fixed — and an amount the + * user set that the form does not show reads as the form having dropped it. + */ + val customAmount: Fiat? + get() = amount?.takeIf { it !in BalancePresets } + } + + sealed interface Event { + data class UploadPolicyLoaded(val policy: UploadPolicy) : Event + + /** A picture came back from the photo picker; it still has to be re-encoded. */ + data class OnImageSelected(val image: Uri) : Event + + /** The re-encoded copy is in the cache and is what gets uploaded. */ + data class OnImageCached(val image: Uri, val mimeType: String) : Event + data object OnImageCleared : Event + + data class OnBalancesChanged(val balances: List) : Event + data class OnRateChanged(val rate: Rate) : Event + + /** A mint was chosen on the currency sheet (node 10127:118100). */ + data class OnMintSelected(val mint: Mint) : Event + + /** A preset chip, or a confirmed custom keypad entry. Always USD. */ + data class OnAmountSelected(val amount: Fiat) : Event + + /** The custom keypad's confirm — converts and re-dispatches as [OnAmountSelected]. */ + data object ConfirmCustomAmount : Event + + /** Create was tapped. Retrying resumes the same attempt; see [GroupCreateAttempt]. */ + data object CreateRequested : Event + + data class UpdateProcessingState( + val loading: Boolean = false, + val success: Boolean = false, + ) : Event + + /** The chat exists. Carries it, because nothing else fetches it back. */ + data class ChatCreated(val chat: ChatMetadata) : Event + } + + /** + * The keypad behind the `…` chip. + * + * No ceiling and no floor: a group can ask for any balance, and unlike the minimum-to-chat fee + * there is nothing stored to compare a change against. The creator's own balance is a bound on + * *creating*, not on typing, and the form says so where the requirement is shown. + */ + val amountDelegate = AmountEntryDelegate( + exchange = exchange, + scope = viewModelScope, + // The requirement is one amount, not a payment, so there is nothing to convert between + // here — the currency the keypad enters in is the account's preferred one and is read back + // into USD on confirm. + style = AmountEntryStyle( + actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_done)), + actionStyle = ConfirmationStyle.Button, + canChangeCurrency = false, + ), + ) + + private val attempt = GroupCreateAttempt() + + init { + exchange.observePreferredRate() + .distinctUntilChanged() + .onEach { rate -> + exchange.getCurrency(rate.currency.name) + ?.let { amountDelegate.onCurrencyChanged(it) } + dispatchEvent(Event.OnRateChanged(rate)) + } + .launchIn(viewModelScope) + + tokenCoordinator.tokenBalances + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnBalancesChanged(it)) } + .launchIn(viewModelScope) + + blobStorage.policy + .filterNotNull() + .onEach { dispatchEvent(Event.UploadPolicyLoaded(it)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .mapNotNull { event -> + // Re-encode inside the policy's dimension and byte caps, off the main thread. The + // wording of a refusal is this screen's; the loop belongs to the preparer. + when (val outcome = imagePreparer.prepare( + uri = event.image, + policy = stateFlow.value.uploadPolicy, + fileNamePrefix = "group_picture", + )) { + is ImageUploadPreparer.Outcome.Prepared -> outcome.uri to outcome.mimeType + ImageUploadPreparer.Outcome.Unsupported -> { + rejectImage( + title = R.string.error_title_imageNotSupported, + message = R.string.error_description_imageNotSupported, + ) + null + } + + ImageUploadPreparer.Outcome.TooLarge -> { + rejectImage( + title = R.string.error_title_imageTooLarge, + message = R.string.error_description_imageTooLarge, + ) + null + } + + ImageUploadPreparer.Outcome.Unreadable -> null + } + } + .flowOn(dispatchers.IO) + .onEach { (cached, mime) -> dispatchEvent(Event.OnImageCached(cached, mime)) } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .map { amountDelegate.state.value.enteredAmount } + .onEach { entered -> + if (entered <= 0.0) return@onEach + // The keypad enters in the preferred currency; the rule is compared against a USD + // balance, so it is stored in USD rather than in whatever was typed. + val local = Fiat(entered, stateFlow.value.rate.currency) + dispatchEvent( + Event.OnAmountSelected(local.convertingToUsdIfNeeded(stateFlow.value.rate)) + ) + } + .launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { create() } + .launchIn(viewModelScope) + } + + /** + * A picked picture that never reached storage is only a re-encoded file in the cache, and + * nothing else deletes it. Mirrors the photo-selection screen, including the successful upload + * that makes the local copy redundant. + */ + override fun onCleared() { + discardPendingImage() + super.onCleared() + } + + private suspend fun create() { + val state = stateFlow.value + if (!state.processingState.isIdle) return + + val mint = state.mint ?: return + val amount = state.amount ?: return + if (!state.hasTitle) return + + // The self-satisfaction check the form already gates Create on, re-read here because the + // balance behind it is a live flow and the tap is a separate moment from the render. + if (!state.selfSatisfied) { + announceNotSelfSatisfied(state.currencyName) + return + } + + val draft = GroupDraft( + title = state.title, + picture = state.image.dataOrNull, + mint = mint, + amount = amount, + ) + + // Minted before any network call, and held for as long as the draft is: every retry below — + // the upload's and StartChat's alike — is the same attempt and carries the same key. + val idempotencyKey = attempt.keyFor(draft) + + dispatchEvent(Event.UpdateProcessingState(loading = true)) + + val picture = if (draft.picture == null) { + null + } else { + attempt.picture ?: uploadPicture(draft.picture, state.imageMimeType) + ?.also { attempt.rememberPicture(it) } + ?: run { + dispatchEvent(Event.UpdateProcessingState()) + return + } + } + + chatCoordinator.create( + parameters = StartChatParameters.Group( + title = draft.title, + picture = picture, + rules = state.rules, + ), + idempotencyKey = idempotencyKey, + ).onSuccess { chat -> + // The chat exists, so the key has done its job — a later Create is a second group. + attempt.clear() + // The cached local copy is redundant now that the blob is the chat's picture. + discardPendingImage() + dispatchSuccessThen(Event.UpdateProcessingState(success = true)) { + dispatchEvent(Event.ChatCreated(chat)) + dispatchEvent(Event.UpdateProcessingState()) + } + }.onFailure { cause -> + // Deliberately not cleared: the attempt survives so the retry is the same attempt. If + // the server did create the chat and only the response was lost, resending this key + // returns that chat rather than making a second one. + dispatchEvent(Event.UpdateProcessingState()) + announceCreateFailure(cause, stateFlow.value.currencyName) + } + } + + /** Uploads the prepared picture and returns its READY blob, or null having reported why not. */ + private suspend fun uploadPicture(uri: Uri, mimeType: String): BlobId? { + val bytes = contentReader.readBytes(uri) + if (bytes == null) { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_moderationFailed), + message = resources.getString(R.string.error_description_moderationFailed), + ) + return null + } + + return blobStorage.upload(bytes = bytes, mimeType = mimeType) + .onFailure { cause -> + // A rejected picture is the picture's problem, so the pick is dropped — keeping it + // on screen after refusing it invites the same upload again. + discardPendingImage() + announcePictureRejection(cause) + } + .getOrNull() + } + + private fun discardPendingImage() { + val pending = stateFlow.value.image.dataOrNull ?: return + contentReader.removeFromCache(pending) + dispatchEvent(Event.OnImageCleared) + } + + private fun rejectImage(@StringRes title: Int, @StringRes message: Int) { + dispatchEvent(Event.OnImageCleared) + BottomBarManager.showAlert( + title = resources.getString(title), + message = resources.getString(message), + ) + } + + private fun announceNotSelfSatisfied(currencyName: String?) { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_groupRuleNotSelfSatisfied, currencyName.orEmpty()), + message = resources.getString(R.string.error_description_groupRuleNotSelfSatisfied), + ) + } + + private fun announcePictureRejection(cause: Throwable) { + val rejection = (cause as? BlobRejectedException)?.rejection + when (rejection?.reason) { + RejectionReason.MODERATION -> BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_imageNotAllowed), + message = resources.getString(moderationDescription(rejection.flaggedCategory)), + ) + + // Everything else is either a caller mistake the preparer should have caught + // (UNSUPPORTED_TYPE, MISMATCHED_TYPE, TOO_LARGE, CORRUPT, PRIVACY_METADATA) or the + // server's own (UNKNOWN, INTERNAL). Neither is something the user can act on beyond + // picking a different picture, which is what the generic refusal says. + else -> BottomBarManager.showError( + title = resources.getString(R.string.error_title_moderationFailed), + message = resources.getString(R.string.error_description_moderationFailed), + ) + } + } + + /** + * The six `StartChatResponse.Result` arms, less `OK`. + * + * `RULES_NOT_SATISFIED` is not a failure of the form — it is the client-side check losing a race + * with a balance that moved — so it reads the same as the pre-call refusal. + */ + private fun announceCreateFailure(cause: Throwable, currencyName: String?) { + when (cause) { + is StartChatError.RulesNotSatisfied -> announceNotSelfSatisfied(currencyName) + + is StartChatError.TitleModerated -> BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_groupTitleNotAllowed), + message = resources.getString(moderationDescription(cause.category)), + ) + + is StartChatError.InvalidRules -> BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_groupRulesInvalid), + message = resources.getString(R.string.error_description_groupRulesInvalid), + ) + + is StartChatError.Denied -> BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_groupCreateDenied), + message = resources.getString(R.string.error_description_groupCreateDenied), + ) + + // The blob was accepted by storage and refused by StartChat, so the picture is what has + // to change. Dropping it here also drops the attempt's cached blob id with the draft. + is StartChatError.PictureBlobNotAccepted -> { + discardPendingImage() + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_imageNotAllowed), + message = resources.getString(R.string.error_description_imageNotAllowed), + ) + } + + else -> BottomBarManager.showError( + title = resources.getString(R.string.error_title_groupCreateFailed), + message = resources.getString(R.string.error_description_groupCreateFailed), + ) + } + } + + /** The app's existing moderation-category wording, as the username and photo screens map it. */ + @StringRes + private fun moderationDescription(category: ModerationResult.FlaggedCategory): Int = + when (category) { + ModerationResult.FlaggedCategory.NONE -> + R.string.error_description_imageNotAllowed + + ModerationResult.FlaggedCategory.OTHER -> + R.string.error_description_profileNameNotAllowedFlaggedOther + + ModerationResult.FlaggedCategory.NSFW -> + R.string.error_description_profileNameNotAllowedFlaggedNsfw + + ModerationResult.FlaggedCategory.IMPERSONATION -> + R.string.error_description_profileNameNotAllowedFlaggedImpersonation + + ModerationResult.FlaggedCategory.MISLEADING -> + R.string.error_description_profileNameNotAllowedFlaggedMisleading + + ModerationResult.FlaggedCategory.SPAM -> + R.string.error_description_profileNameNotAllowedFlaggedSpam + } + + companion object { + @VisibleForTesting + internal val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.UploadPolicyLoaded -> { state -> state.copy(uploadPolicy = event.policy) } + is Event.OnImageSelected -> { state -> + // Loading with the source uri behind it: the tile shows the pick immediately + // while the re-encode runs, then swaps to the cached copy. + state.copy(image = Loadable.Loading(event.image)) + } + + is Event.OnImageCached -> { state -> + state.copy( + image = Loadable.Loaded(event.image), + imageMimeType = event.mimeType, + ) + } + + Event.OnImageCleared -> { state -> + state.copy(image = Loadable.Loading(), imageMimeType = "") + } + + is Event.OnBalancesChanged -> { state -> + state.copy( + balances = event.balances, + // The form opens with a mint already named (node 10127:118057), so one is + // seated here rather than left for the sheet. The largest holding is the + // one the creator can actually set a requirement in — picking anything + // else would open the form on a rule its own author fails. + // + // Balances under a cent are skipped because the currency sheet skips them + // too (`SelectTokenViewModel` filters `TokenPurpose.Balance` on + // `hasDisplayableValue`). Seating from a wider list than the picker offers + // would name a mint the picker cannot show, leaving no way back to it. A + // wallet holding only dust therefore seats nothing and shows the sheet's + // own "Select Currency" — an empty picker under a named currency would be + // the worse of the two. + mint = state.mint ?: event.balances + .filter { it.balance.hasDisplayableValue } + .maxByOrNull { it.balance.quarks } + ?.token?.address, + ) + } + is Event.OnRateChanged -> { state -> state.copy(rate = event.rate) } + is Event.OnMintSelected -> + { state -> state.copy(mint = event.mint, mintChosen = true) } + is Event.OnAmountSelected -> { state -> state.copy(amount = event.amount) } + Event.ConfirmCustomAmount -> { state -> state } + Event.CreateRequested -> { state -> state } + is Event.UpdateProcessingState -> { state -> + state.copy( + processingState = state.processingState.copy( + loading = event.loading, + success = event.success, + ) + ) + } + + is Event.ChatCreated -> { state -> state.copy(created = event.chat) } + } + } + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/NewChatViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModel.kt similarity index 97% rename from apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/NewChatViewModel.kt rename to apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModel.kt index 0978454e28..cf9a750cb4 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/NewChatViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModel.kt @@ -37,11 +37,11 @@ internal const val MaxHandleLength = 15 * Only a failed lookup is ours to apologise for. */ @HiltViewModel -internal class NewChatViewModel @Inject constructor( +internal class FindByUsernameViewModel @Inject constructor( private val profileController: ProfileController, private val userManager: UserManager, private val resources: ResourceHelper, -) : BaseViewModel( +) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, ) { diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt new file mode 100644 index 0000000000..264692a2f3 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt @@ -0,0 +1,79 @@ +package com.flipcash.app.tipping.internal + +import android.net.Uri +import com.flipcash.services.models.chat.IdempotencyKey +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import com.flipcash.services.models.chat.BlobId + +/** + * Everything `StartChat` needs, as the form has it at the moment Create is tapped. + * + * Doubles as the identity of a create attempt: two taps carrying the same draft are the same + * attempt, and a tap after an edit is a new one. See [GroupCreateAttempt]. + */ +internal data class GroupDraft( + val title: String, + /** The re-encoded local copy, before upload. Null when the group has no picture. */ + val picture: Uri?, + /** The mint the balance requirement is denominated in. */ + val mint: Mint, + /** The required balance, in USD — the currency [com.flipcash.shared.chat.GroupAccess] compares in. */ + val amount: Fiat, +) + +/** + * The idempotency key for one create attempt, and the work that key has already paid for. + * + * `StartChat` derives a chat's identity from the caller and this key alone — the parameters are not + * part of it — so the key decides what a second call means. Reused, the server hands back the chat + * the first call created and reports `OK`; freshly minted, it creates another one. Retrying a failed + * attempt wants the first; creating a second group wants the second. Neither is something the view + * model can decide at the call site, because by then it cannot tell the two apart. + * + * So the key is minted against the draft it was minted for. [keyFor] returns the held key while the + * draft is unchanged, which covers every retry of one attempt — including the transport failures + * where the server may well have created the chat already and the response is what was lost. An + * edited draft is a different intent and gets a different key, because reusing it would silently + * return the chat built from the *old* parameters and leave the edit with nowhere to go. + * + * [picture] is cached against the same boundary: a blob that is already uploaded and `READY` stays + * good for as long as the draft pointing at it does, so a retry does not re-upload it. + */ +internal class GroupCreateAttempt( + private val mintKey: () -> IdempotencyKey = { IdempotencyKey.random() }, +) { + private var draft: GroupDraft? = null + private var key: IdempotencyKey? = null + + /** The blob id for [draft]'s picture, once uploaded. */ + var picture: BlobId? = null + private set + + /** + * The key to send for [draft] — the held one if this is the same attempt, a fresh one if not. + */ + fun keyFor(draft: GroupDraft): IdempotencyKey { + val held = key + if (held != null && this.draft == draft) return held + + return mintKey().also { + this.draft = draft + this.key = it + // A new attempt cannot inherit the old one's upload: the picture is part of the draft, + // so a draft change may be the picture itself changing. + this.picture = null + } + } + + fun rememberPicture(blobId: BlobId) { + picture = blobId + } + + /** Forget the attempt, so the next Create starts a new one. Called once a chat exists. */ + fun clear() { + draft = null + key = null + picture = null + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupCustomAmountSheet.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupCustomAmountSheet.kt new file mode 100644 index 0000000000..a6dfc36320 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupCustomAmountSheet.kt @@ -0,0 +1,63 @@ +package com.flipcash.app.tipping.internal.screens + +import android.os.Parcelable +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.flipcash.app.core.chat.NewGroupStep +import com.flipcash.app.tipping.internal.CreateGroupViewModel +import com.flipcash.features.tipping.R +import com.flipcash.shared.amountentry.AmountEntryScreen +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * The `…` preset — a balance requirement the three chips don't cover. + * + * The design names the chip but not where it goes, so it opens the keypad the rest of the app enters + * amounts on, in the account's preferred currency. The draft stores the result in USD; the + * conversion is [CreateGroupViewModel]'s, since that is where the rate lives. + * + * Dismissal follows `OnAmountSelected` rather than the confirm tap: an entry of zero is dropped + * without becoming an amount, and closing the sheet on it would read as the form having taken it. + */ +@Composable +internal fun NewGroupCustomAmountSheet(viewModel: CreateGroupViewModel) { + val flowNavigator = rememberFlowNavigator() + + AmountEntryScreen( + controller = viewModel.amountDelegate, + onConfirm = { viewModel.dispatchEvent(CreateGroupViewModel.Event.ConfirmCustomAmount) }, + largeHeader = true, + appBar = { + AppBarWithTitle( + title = stringResource(R.string.title_balanceRequirement), + titleAlignment = Alignment.CenterHorizontally, + endContent = { AppBarDefaults.Close { flowNavigator.back() } }, + ) + }, + headerCaption = { + Text( + text = stringResource(R.string.subtitle_groupBalanceRequirement), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Start, + ) + }, + ) + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.back() } + .launchIn(this) + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt new file mode 100644 index 0000000000..7c7fe7b4a4 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt @@ -0,0 +1,473 @@ +package com.flipcash.app.tipping.internal.screens + +import android.os.Parcelable +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import com.flipcash.app.core.chat.NewGroupStep +import com.flipcash.app.core.data.Loadable +import com.flipcash.app.core.data.isLoaded +import com.flipcash.app.core.ui.DisplayTextInput +import com.flipcash.app.tipping.internal.BalancePresets +import com.flipcash.app.tipping.internal.CreateGroupViewModel +import com.flipcash.features.tipping.R +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.opencode.model.financial.Fiat +import com.getcode.theme.CodeTheme +import com.getcode.theme.White +import com.getcode.theme.White05 +import com.getcode.theme.White10 +import com.getcode.theme.White20 +import com.getcode.theme.White50 +import com.getcode.theme.extraSmall +import com.getcode.theme.rememberDynamicAccent +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeCircularProgressIndicator +import com.getcode.ui.utils.rememberKeyboardController + +/** + * "New Public Group" — node 10127:118014 empty, 10127:118237 ready to create. + * + * Everything on the screen edits one draft held by [CreateGroupViewModel], so the currency sheet and + * the custom-amount keypad can be separate steps without the form losing what was typed. + * + * Create is inert until the draft is complete *and* the creator's own balance satisfies the rule + * they are setting. The last part is not obvious from the button alone, so the caption under the + * card says so while it holds, and the refusal names the currency when Create is tapped anyway — + * which is reachable, because the balance behind the check is a live flow. + */ +@Composable +internal fun NewGroupFormScreen(viewModel: CreateGroupViewModel) { + val flowNavigator = rememberFlowNavigator() + val keyboard = rememberKeyboardController() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val pickPicture = rememberLauncherForActivityResult(PickVisualMedia()) { uri -> + if (uri != null) { + viewModel.dispatchEvent(CreateGroupViewModel.Event.OnImageSelected(uri)) + } + } + + Column(modifier = Modifier.fillMaxSize()) { + AppBarWithTitle( + title = stringResource(R.string.title_newPublicGroup), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { keyboard.hideIfVisible { flowNavigator.exitCanceled() } }, + endContent = { + CreateAction( + enabled = state.canCreate, + isLoading = state.processingState.loading, + isSuccess = state.processingState.success, + onClick = { + keyboard.hideIfVisible { + viewModel.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + } + }, + ) + }, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x3) + .imePadding(), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x9), + ) { + IdentityCard( + image = state.image, + titleField = { + DisplayTextInput( + state = state.titleFieldState, + placeholder = stringResource(R.string.hint_groupName), + modifier = Modifier.fillMaxWidth(), + style = CodeTheme.typography.screenTitle.copy( + color = CodeTheme.colors.textMain, + ), + placeholderStyle = CodeTheme.typography.screenTitle.copy( + color = CodeTheme.colors.textMain.copy(alpha = 0.2f), + ), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Done, + ), + onKeyboardAction = { keyboard.hideIfVisible { } }, + ) + }, + onPick = { pickPicture.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) }, + ) + + RequirementSection( + state = state, + onChangeCurrency = { + keyboard.hideIfVisible { + flowNavigator.navigateTo(NewGroupStep.SelectCurrency) + } + }, + onPresetSelected = { + viewModel.dispatchEvent(CreateGroupViewModel.Event.OnAmountSelected(it)) + }, + onCustomAmount = { + keyboard.hideIfVisible { + flowNavigator.navigateTo(NewGroupStep.CustomAmount) + } + }, + ) + } + } +} + +/** + * The Create capsule in the nav bar — node 10127:118237. + * + * Painted with the wallpaper accent, the way the currency creator's bill step paints its Next + * capsule: both commit a thing the user has just been choosing the look of, and both sit in the nav + * bar over that preview, so they take the system accent rather than the app's white fill. + * [rememberDynamicAccent] falls back to the theme's own action colors below API 31. + * + * Unlike Next, Create commits over the network, so the capsule reports progress and success in place + * of its label. The label stays in the layout while that happens — the pill would otherwise shrink + * to the width of a spinner and jump back. + */ +@Composable +private fun CreateAction( + enabled: Boolean, + isLoading: Boolean, + isSuccess: Boolean, + onClick: () -> Unit, +) { + val (accent, onAccent) = rememberDynamicAccent( + fallbackAccent = CodeTheme.colors.secondary, + fallbackOnAccent = CodeTheme.colors.onAction, + ) + + val clickable = enabled && !isLoading && !isSuccess + // An incomplete draft dims the capsule rather than removing it: it is the only thing on screen + // that says the form has an end, so it stays visible while it is inert. + val container = if (enabled) accent else White10 + val content = if (enabled) onAccent else White50 + + Box( + modifier = Modifier + .clip(CircleShape) + .background(container) + .clickable(enabled = clickable, onClick = onClick) + .padding( + horizontal = CodeTheme.dimens.grid.x2, + vertical = CodeTheme.dimens.grid.x1, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.action_create), + style = CodeTheme.typography.textMedium, + color = content, + modifier = Modifier.alpha(if (isLoading || isSuccess) 0f else 1f), + ) + + if (isLoading) { + CodeCircularProgressIndicator( + strokeWidth = CodeTheme.dimens.thickBorder, + color = content, + modifier = Modifier.size(CodeTheme.dimens.grid.x3), + ) + } + + if (isSuccess) { + Icon( + painter = painterResource(R.drawable.ic_check), + contentDescription = null, + tint = content, + modifier = Modifier.size(CodeTheme.dimens.grid.x3), + ) + } + } +} + +/** + * The picture-and-name card at the top of the form. + * + * The tile shows the pick as soon as it is made and keeps showing it while the re-encode runs — + * [Loadable.Loading] carries the source uri for exactly that — so the only spinner is over an image + * the user already recognises. + */ +@Composable +private fun IdentityCard( + image: Loadable, + titleField: @Composable () -> Unit, + onPick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(CodeTheme.shapes.extraSmall) + .background(White05) + .padding(CodeTheme.dimens.grid.x5), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(AvatarSize) + .clip(CircleShape) + .background(White05) + .border(CodeTheme.dimens.border, White20, CircleShape) + .clickable(onClick = onPick), + contentAlignment = Alignment.Center, + ) { + Crossfade(targetState = image, label = "group picture") { picture -> + when { + picture.isLoaded() -> AsyncImage( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(picture.data) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + + picture.dataOrNull != null -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(picture.dataOrNull) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + CodeCircularProgressIndicator() + } + + else -> Icon( + painter = painterResource(R.drawable.ic_camera), + contentDescription = null, + tint = White50, + modifier = Modifier.size(CameraIconSize), + ) + } + } + } + + Box(modifier = Modifier.weight(1f)) { titleField() } + } +} + +/** + * The balance rule: which mint, and how much of it. + * + * The heading changes once the creator picks the mint, not once an amount is: node 10127:118057 + * still asks for a "Minimum Balance Required" with the title already filled in and the form's + * opening mint untouched, and node 10127:118194 reads "Balance Requirement" with the mint changed + * and no amount yet. + */ +@Composable +private fun RequirementSection( + state: CreateGroupViewModel.State, + onChangeCurrency: () -> Unit, + onPresetSelected: (Fiat) -> Unit, + onCustomAmount: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2), + ) { + Text( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.grid.x3), + text = stringResource( + if (!state.mintChosen) { + R.string.title_minimumBalanceRequired + } else { + R.string.title_balanceRequirement + } + ), + style = LabelStyle, + color = White50, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(CodeTheme.shapes.extraSmall) + .background(White05) + .padding(CodeTheme.dimens.grid.x3), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2), + ) { + MintRow(state = state, onClick = onChangeCurrency) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + BalancePresets.forEach { preset -> + AmountChip( + modifier = Modifier.weight(1f), + selected = state.amount == preset, + onClick = { onPresetSelected(preset) }, + ) { textColor -> + Text( + text = preset.formatted(rule = Fiat.FormattingRule.Truncated), + style = ChipStyle, + color = textColor, + ) + } + } + + // The fourth slot is the keypad's. A custom amount has nowhere else to show, so it + // takes this chip's face and the ellipsis is what an unused slot looks like. + val custom = state.customAmount + AmountChip( + modifier = Modifier.weight(1f), + selected = custom != null, + onClick = onCustomAmount, + ) { textColor -> + Text( + text = custom?.formatted(rule = Fiat.FormattingRule.Truncated) ?: "•••", + style = ChipStyle, + color = textColor, + ) + } + } + } + + // Create dims for three reasons, and two of them — no name, no amount — are visible in the + // form itself. The third is not: a rule the creator cannot meet looks exactly like a + // complete draft. So the caption stops explaining what the requirement does to anyone else + // and says what is holding this creator up instead. + val unsatisfied = state.rules != null && !state.selfSatisfied + + Text( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.grid.x3), + text = stringResource( + if (unsatisfied) { + R.string.error_description_groupRuleNotSelfSatisfied + } else { + R.string.subtitle_groupBalanceRequirement + } + ), + style = CodeTheme.typography.caption.copy(fontSize = 13.sp, lineHeight = 17.sp), + color = if (unsatisfied) CodeTheme.colors.errorText else White50, + ) + } +} + +@Composable +private fun MintRow(state: CreateGroupViewModel.State, onClick: () -> Unit) { + Row( + modifier = Modifier.clickable(onClick = onClick), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + verticalAlignment = Alignment.CenterVertically, + ) { + val token = state.token + AsyncImage( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(token?.imageUrl) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(MintIconSize) + .clip(CircleShape) + .background(White10), + ) + + // The form seats a mint before it is drawn, so node 10127:118014 names one in the empty + // state and this row normally has a name to show. A wallet holding nothing has no mint to + // seat and no frame in the design either, so the row falls back to the sheet's own title + // rather than rendering a blank, tappable line. + Text( + text = state.currencyName ?: stringResource(R.string.title_selectCurrency), + style = CodeTheme.typography.textMedium.copy( + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ), + color = if (token == null) White50 else CodeTheme.colors.textMain, + ) + + Icon( + painter = painterResource(R.drawable.ic_chevron_down_small), + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), + ) + } +} + +/** A preset slot. Selected inverts to a solid white face — node 10127:118237's `$100`. */ +@Composable +private fun AmountChip( + modifier: Modifier = Modifier, + selected: Boolean, + onClick: () -> Unit, + content: @Composable (textColor: Color) -> Unit, +) { + Box( + modifier = modifier + .height(ChipHeight) + .clip(CodeTheme.shapes.extraSmall) + .background(if (selected) White else White10) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + content(if (selected) CodeTheme.colors.background else CodeTheme.colors.textMain) + } +} + +private val AvatarSize = 74.dp +private val CameraIconSize = 40.dp +private val MintIconSize = 20.dp +private val ChipHeight = 60.dp + +private val LabelStyle + @Composable get() = CodeTheme.typography.textMedium.copy( + fontSize = 17.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.SemiBold, + ) + +private val ChipStyle + @Composable get() = CodeTheme.typography.textLarge.copy( + fontSize = 22.sp, + lineHeight = 26.sp, + fontWeight = FontWeight.SemiBold, + ) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/SelectGroupCurrencySheet.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/SelectGroupCurrencySheet.kt new file mode 100644 index 0000000000..db8556def1 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/SelectGroupCurrencySheet.kt @@ -0,0 +1,86 @@ +package com.flipcash.app.tipping.internal.screens + +import android.os.Parcelable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.chat.NewGroupStep +import com.flipcash.app.core.tokens.TokenPurpose +import com.flipcash.app.core.ui.TokenBalanceStyle +import com.flipcash.app.core.ui.TokenSelectionStyle +import com.flipcash.app.core.ui.rememberTokenBalanceRowStyling +import com.flipcash.app.tipping.internal.CreateGroupViewModel +import com.flipcash.app.tokens.ui.SelectTokenViewModel +import com.flipcash.app.tokens.ui.TokenList +import com.flipcash.app.tokens.ui.TokenListPresentation +import com.flipcash.features.tipping.R +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeScaffold + +/** + * Node 10127:118100 — which currency the group's balance requirement is denominated in. + * + * The list comes from [SelectTokenViewModel] but the *selection* does not: that view model's own + * selected mint is the wallet's global one, and choosing a currency for a group being drafted must + * not move the wallet off the token the user is holding. So the chosen mint is read from and written + * to [CreateGroupViewModel]'s draft, and the list is driven with [TokenPurpose.Balance] — the one + * purpose that isn't a `TriggersChange` and so leaves the global selection alone. + */ +@Composable +internal fun SelectGroupCurrencySheet(viewModel: CreateGroupViewModel) { + val flowNavigator = rememberFlowNavigator() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val tokenViewModel = hiltViewModel() + val tokenState by tokenViewModel.stateFlow.collectAsStateWithLifecycle() + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_selectCurrency), + titleAlignment = Alignment.CenterHorizontally, + endContent = { AppBarDefaults.Close { flowNavigator.back() } }, + ) + }, + ) { padding -> + TokenList( + modifier = Modifier + .fillMaxWidth() + .padding(padding) + .padding(horizontal = CodeTheme.dimens.inset), + tokens = tokenState.tokens, + styling = rememberTokenBalanceRowStyling( + // Pill balances, dividers and a radio mark per row, which is + // TokenListPresentation.Default's treatment — the compact Sheet presentation drops + // all three, and this sheet shows all three. + balanceDisplayStyle = TokenBalanceStyle.Pill(), + selectionStyle = TokenSelectionStyle.Checkbox, + ), + selectedToken = state.mint, + showSelections = true, + presentation = TokenListPresentation.Default, + onTokenSelected = { token -> + viewModel.dispatchEvent(CreateGroupViewModel.Event.OnMintSelected(token.address)) + flowNavigator.back() + }, + ) + } + + // The purpose is what builds the list at all — SelectTokenViewModel assembles its balances + // inside a flatMapLatest over this event, so the default in its initial state never emits. + LaunchedEffect(tokenViewModel) { + tokenViewModel.dispatchEvent( + SelectTokenViewModel.Event.OnPurposeChanged(TokenPurpose.Balance) + ) + } +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupStateTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupStateTest.kt new file mode 100644 index 0000000000..1761e7f0e5 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupStateTest.kt @@ -0,0 +1,149 @@ +package com.flipcash.app.tipping.internal + +import androidx.compose.foundation.text.input.TextFieldState +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.shared.chat.GroupAccess +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.HolderMetrics +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.TokenWithBalance +import com.getcode.opencode.model.financial.VmMetadata +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What the form decides before it calls `StartChat` — nodes 10127:118014 through 10127:118237. + * + * Two of those decisions are contractual rather than cosmetic. The rules the draft describes must be + * a single *listener* minimum balance with exactly one mint, which is all the server accepts today; + * and the creator has to satisfy the rule they are setting, measured by the same predicate that will + * gate everyone else, or the server answers `RULES_NOT_SATISFIED` on a group they just made. + */ +class CreateGroupStateTest { + + private fun mint(seed: Byte) = Mint(ByteArray(32) { seed }.toList()) + + private fun token(seed: Byte, symbol: String) = MintMetadata( + address = mint(seed), + decimals = 6, + name = symbol, + symbol = symbol, + createdAt = null, + description = "", + imageUrl = "", + vmMetadata = VmMetadata( + vm = PublicKey.fromBase58("11111111111111111111111111111111"), + authority = PublicKey.fromBase58("11111111111111111111111111111111"), + lockDurationInDays = 21, + ), + launchpadMetadata = null, + billCustomizations = null, + socialLinks = emptyList(), + holderMetrics = HolderMetrics.None, + ) + + private fun held(seed: Byte, symbol: String, dollars: Double) = + TokenWithBalance(token = token(seed, symbol), balance = Fiat(dollars)) + + private val badBoys = mint(1) + + private fun state( + title: String = "Ballers", + mint: Mint? = badBoys, + amount: Fiat? = Fiat(100), + balances: List = listOf(held(1, "BadBoys", 250.0)), + ) = CreateGroupViewModel.State( + titleFieldState = TextFieldState(title), + balances = balances, + mint = mint, + amount = amount, + ) + + @Test + fun `the mint the form opens on is not a mint the creator chose`() { + val opened = CreateGroupViewModel.updateStateForEvent( + CreateGroupViewModel.Event.OnBalancesChanged(listOf(held(1, "BadBoys", 250.0))) + )(CreateGroupViewModel.State()) + + // The heading reads "Minimum Balance Required" until the creator picks the currency, and + // the form always opens with one named — so the mint alone cannot tell the two apart. + // Node 10127:118057 still asks, with the title typed and the opening mint untouched; + // node 10127:118194 reads "Balance Requirement" on the mint, before any amount. + assertEquals(badBoys, opened.mint) + assertFalse(opened.mintChosen) + + val picked = CreateGroupViewModel.updateStateForEvent( + CreateGroupViewModel.Event.OnMintSelected(badBoys) + )(opened) + + assertTrue(picked.mintChosen) + } + + @Test + fun `a wallet holding only dust seats no mint`() { + // The currency sheet filters `TokenPurpose.Balance` on `hasDisplayableValue`, so seating + // from a wider list would name a mint the picker cannot show and the creator could not get + // back to. Nothing seated means the row reads "Select Currency" instead. + val opened = CreateGroupViewModel.updateStateForEvent( + CreateGroupViewModel.Event.OnBalancesChanged(listOf(held(1, "BadBoys", 0.001))) + )(CreateGroupViewModel.State()) + + assertNull(opened.mint) + assertFalse(opened.mintChosen) + } + + @Test + fun `the draft becomes one listener minimum balance in one mint`() { + val rules = state().rules + + assertEquals(emptyList(), rules?.speaker) + assertEquals(1, rules?.listener?.size) + + val requirement = assertIs(rules?.listener?.first()) + assertEquals(Fiat(100), requirement.amount) + assertEquals(listOf(badBoys), requirement.mints) + } + + @Test + fun `an incomplete draft describes no rules and cannot be created`() { + assertNull(state(mint = null).rules) + assertNull(state(amount = null).rules) + assertFalse(state(mint = null).canCreate) + assertFalse(state(amount = null).canCreate) + assertFalse(state(title = " ").canCreate) + } + + @Test + fun `a creator who holds the requirement can create`() { + val state = state(balances = listOf(held(1, "BadBoys", 100.0))) + + assertIs(state.access) + assertTrue(state.selfSatisfied) + assertTrue(state.canCreate) + } + + @Test + fun `a creator short of their own requirement cannot`() { + val state = state(balances = listOf(held(1, "BadBoys", 99.0))) + + assertIs(state.access) + assertFalse(state.selfSatisfied) + assertFalse(state.canCreate) + } + + @Test + fun `balance in another mint does not satisfy a rule naming this one`() { + val state = state( + balances = listOf(held(1, "BadBoys", 10.0), held(2, "Other", 5_000.0)), + ) + + assertFalse(state.selfSatisfied) + assertFalse(state.canCreate) + } +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt new file mode 100644 index 0000000000..da55e51811 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt @@ -0,0 +1,311 @@ +package com.flipcash.app.tipping.internal + +import android.net.Uri +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.flipcash.app.blob.BlobStorageCoordinator +import com.flipcash.app.blob.ImageUploadPreparer +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.tipping.R +import com.flipcash.services.models.ModerationResult +import com.flipcash.services.models.StartChatError +import com.flipcash.services.models.chat.BlobId +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.IdempotencyKey +import com.flipcash.services.models.chat.StartChatParameters +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.HolderMetrics +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.TokenWithBalance +import com.getcode.opencode.model.financial.VmMetadata +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import com.getcode.util.resources.ContentReader +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * What the create call does with the answer it gets — the five non-OK arms of + * `StartChatResponse.Result`, and the attempt that has to outlive a failure. + * + * [CreateGroupStateTest] pins what the form decides before the call and [GroupCreateAttemptTest] + * pins the attempt in isolation; this is the seam between them, where the key the attempt holds has + * to reach the coordinator and a retry has to be recognisable as the same attempt rather than a + * second group. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CreateGroupViewModelErrorTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val resources = FakeResourceHelper(R::class.java) + private val exchange = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val chatCoordinator = mockk(relaxed = true) + private val blobStorage = mockk(relaxed = true) + private val imagePreparer = mockk(relaxed = true) + private val contentReader = mockk(relaxed = true) + + private lateinit var dispatchers: TestDispatchers + + private val badBoys = Mint(ByteArray(32) { 1 }.toList()) + + @Before + fun setUp() { + BottomBarManager.clear() + every { exchange.observePreferredRate() } returns emptyFlow() + every { tokenCoordinator.tokenBalances } returns flowOf(emptyList()) + every { blobStorage.policy } returns flowOf(null) + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + private fun createViewModel() = CreateGroupViewModel( + exchange = exchange, + tokenCoordinator = tokenCoordinator, + dispatchers = dispatchers, + chatCoordinator = chatCoordinator, + blobStorage = blobStorage, + imagePreparer = imagePreparer, + contentReader = contentReader, + resources = resources, + ) + + private fun token(seed: Byte, symbol: String) = MintMetadata( + address = Mint(ByteArray(32) { seed }.toList()), + decimals = 6, + name = symbol, + symbol = symbol, + createdAt = null, + description = "", + imageUrl = "", + vmMetadata = VmMetadata( + vm = PublicKey.fromBase58("11111111111111111111111111111111"), + authority = PublicKey.fromBase58("11111111111111111111111111111111"), + lockDurationInDays = 21, + ), + launchpadMetadata = null, + billCustomizations = null, + socialLinks = emptyList(), + holderMetrics = HolderMetrics.None, + ) + + /** A draft that passes every pre-call check, so the only thing left to decide is the answer. */ + private fun CreateGroupViewModel.completeDraft() { + stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Ballers") + dispatchEvent( + CreateGroupViewModel.Event.OnBalancesChanged( + listOf(TokenWithBalance(token = token(1, "BadBoys"), balance = Fiat(250.0))) + ) + ) + dispatchEvent(CreateGroupViewModel.Event.OnMintSelected(badBoys)) + dispatchEvent(CreateGroupViewModel.Event.OnAmountSelected(Fiat(100))) + } + + private fun created() = ChatMetadata( + chatId = ChatId(ByteArray(16) { 3 }.toList()), + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1_000), + title = "Ballers", + latestEventSequence = 0, + ) + + private fun failing(cause: Throwable) { + coEvery { chatCoordinator.create(any(), any()) } returns Result.failure(cause) + } + + private fun titles() = BottomBarManager.messages.value.map { it.title } + + /** + * The client checked the balance before the call, so a server refusal on the same ground is a + * race the check lost — not a mistake in the form. It reads as the pre-call refusal does. + */ + @Test + fun `a server rules refusal reads the same as the pre-call check`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel() + vm.completeDraft() + failing(StartChatError.RulesNotSatisfied()) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + + assertTrue(titles().contains("error_title_groupRuleNotSelfSatisfied")) + // The form is usable again: a refusal is not a spinner the user has to back out of. + assertTrue(vm.stateFlow.value.processingState.isIdle) + assertNull(vm.stateFlow.value.created) + } + + @Test + fun `a moderated title is reported with the category the server flagged`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel() + vm.completeDraft() + failing( + StartChatError.TitleModerated(ModerationResult.FlaggedCategory.NSFW) + ) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + + assertTrue(titles().contains("error_title_groupTitleNotAllowed")) + assertTrue( + BottomBarManager.messages.value.any { + it.subtitle == "error_description_profileNameNotAllowedFlaggedNsfw" + } + ) + } + + @Test + fun `invalid rules and a denial each say which it was`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel() + vm.completeDraft() + + failing(StartChatError.InvalidRules()) + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + assertTrue(titles().contains("error_title_groupRulesInvalid")) + + BottomBarManager.clear() + failing(StartChatError.Denied()) + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + assertTrue(titles().contains("error_title_groupCreateDenied")) + } + + @Test + fun `an unrecognised failure falls back to the generic refusal`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel() + vm.completeDraft() + failing(Throwable("unreachable")) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + + assertTrue(titles().contains("error_title_groupCreateFailed")) + } + + /** + * Storage accepted the blob and `StartChat` refused it, so the picture is what has to change — + * and dropping it is also what lets the next tap upload a different one rather than resend the + * blob the server just rejected. + */ + @Test + fun `a rejected picture is dropped from the draft`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel() + vm.completeDraft() + + val uri = mockk(relaxed = true) + every { contentReader.readBytes(uri) } returns ByteArray(8) + coEvery { blobStorage.upload(any(), any()) } returns + Result.success(BlobId(ByteArray(32) { 5 })) + vm.dispatchEvent(CreateGroupViewModel.Event.OnImageCached(uri, "image/jpeg")) + failing(StartChatError.PictureBlobNotAccepted()) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + + assertTrue(titles().contains("error_title_imageNotAllowed")) + assertNull(vm.stateFlow.value.image.dataOrNull) + coVerify { contentReader.removeFromCache(uri) } + } + + /** + * The contract's guarantee, and the reason the key is minted at the tap: resending it returns + * the chat the first attempt created instead of making a second one. So a retry has to carry + * the key the first call carried, and the picture it already uploaded. + */ + @Test + fun `a retry carries the first attempt's key and its uploaded picture`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel() + vm.completeDraft() + + val uri = mockk(relaxed = true) + val blob = BlobId(ByteArray(32) { 5 }) + every { contentReader.readBytes(uri) } returns ByteArray(8) + coEvery { blobStorage.upload(any(), any()) } returns Result.success(blob) + vm.dispatchEvent(CreateGroupViewModel.Event.OnImageCached(uri, "image/jpeg")) + + val keys = mutableListOf() + val parameters = mutableListOf() + coEvery { + chatCoordinator.create(capture(parameters), capture(keys)) + } returns Result.failure(Throwable("response lost")) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + + assertEquals(2, keys.size) + assertContentEquals(keys[0].bytes, keys[1].bytes) + // Uploaded once and remembered: a second upload would produce a second blob, and the + // retry would then be a different request wearing the same key. + coVerify(exactly = 1) { blobStorage.upload(any(), any()) } + assertEquals(blob, (parameters[1] as StartChatParameters.Group).picture) + } + + /** + * The chat exists, so the key has done its job. The next Create is a different group and must + * not be answered with this one. + */ + @Test + fun `a created chat ends the attempt`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel() + vm.completeDraft() + + val keys = mutableListOf() + coEvery { chatCoordinator.create(any(), capture(keys)) } returns Result.success(created()) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + assertEquals(created(), vm.stateFlow.value.created) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + + assertEquals(2, keys.size) + assertTrue(!keys[0].bytes.contentEquals(keys[1].bytes)) + } +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttemptTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttemptTest.kt new file mode 100644 index 0000000000..812880344f --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttemptTest.kt @@ -0,0 +1,85 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.IdempotencyKey +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +/** + * How long one create attempt's idempotency key lives. + * + * `StartChat` derives the chat's identity from the caller and the key alone, so a reused key returns + * the chat the first call made and reports `OK`, while a fresh one makes another group. That makes + * the key's lifetime the difference between a retry and a duplicate, and the boundary this class + * draws is the draft: every retry of the same draft sends the same key, and an edit starts over. + */ +class GroupCreateAttemptTest { + + private fun mint(seed: Byte) = Mint(ByteArray(32) { seed }.toList()) + + private fun draft( + title: String = "Ballers", + mint: Mint = mint(1), + amount: Fiat = Fiat(100), + ) = GroupDraft(title = title, picture = null, mint = mint, amount = amount) + + /** Hands out predictable keys, so a second key is visibly a second key. */ + private class Keys { + private var next = 0 + val attempt = GroupCreateAttempt { IdempotencyKey(ByteArray(16) { (next).toByte() }).also { next++ } } + } + + @Test + fun `every retry of one draft sends the key the first attempt minted`() { + val attempt = Keys().attempt + val draft = draft() + + val first = attempt.keyFor(draft) + + assertEquals(first, attempt.keyFor(draft)) + assertEquals(first, attempt.keyFor(draft)) + // A draft rebuilt from the same values is the same attempt — the form hands over a fresh + // instance on every tap, so identity has to be by value or no retry would ever reuse a key. + assertEquals(first, attempt.keyFor(draft())) + } + + @Test + fun `an edited draft is a new attempt, so it gets a new key`() { + val attempt = Keys().attempt + val first = attempt.keyFor(draft()) + + assertNotEquals(first, attempt.keyFor(draft(title = "Ballers 2"))) + assertNotEquals(first, attempt.keyFor(draft(amount = Fiat(50)))) + assertNotEquals(first, attempt.keyFor(draft(mint = mint(2)))) + } + + @Test + fun `a created chat ends the attempt, so the next group is a new one`() { + val attempt = Keys().attempt + val first = attempt.keyFor(draft()) + + attempt.clear() + + assertNotEquals(first, attempt.keyFor(draft())) + } + + @Test + fun `an uploaded picture survives a retry and is dropped by an edit`() { + val attempt = Keys().attempt + val draft = draft() + // One instance, reused: BlobId wraps a ByteArray, so two equal-looking ids are not equal. + val blob = BlobId(ByteArray(32) { 9 }) + attempt.keyFor(draft) + attempt.rememberPicture(blob) + + attempt.keyFor(draft) + assertEquals(blob, attempt.picture) + + attempt.keyFor(draft(title = "Ballers 2")) + assertNull(attempt.picture) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 214438d2f4..f2333c64f9 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -24,6 +24,15 @@ sealed interface ChatAction { /** Joins the group this screen is showing. */ data object JoinChat : ChatAction + /** + * Shares the link that invites someone into this group. + * + * The link itself is not carried: the screen state already builds it from the chat id, and the + * handler reads it from there, so the action stays a verb rather than a payload the transcript + * would have to keep in sync. + */ + data object InviteToGroup : ChatAction + /** * Adds [bubble] to the selection, or removes it if it is already selected. * diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 239e4d6567..0262474f4a 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.flipcash.app.core.tokens.brandedName import com.flipcash.app.core.ui.TokenCard import com.flipcash.app.core.ui.TokenIcon import com.flipcash.app.core.ui.rememberShimmerAlpha @@ -163,7 +164,7 @@ private fun CashLinkCard( CashVoucher( height = height, loading = loading, - tokenName = state?.token?.let { displayNameOf(it) } + tokenName = state?.token?.brandedName() ?: stringResource(R.string.label_linkCard_cash), tokenImage = state?.token?.imageUrl, amount = state?.amount, @@ -255,7 +256,7 @@ private fun TokenLinkCard( // Empty rather than absent: the header lays the balance out at the end of the row, so // an empty string leaves the name alone on the row with nothing to collide with. balanceText = "", - displayName = displayNameOf(state.token), + displayName = state.token.brandedName(), height = height, onClick = onClick, ) @@ -329,16 +330,6 @@ private fun Mint.abbreviated(): String = description.let { address -> private const val ABBREVIATED_MINT_CHARS = 4 -/** - * The reserve is branded Dollars everywhere the user meets it; `token.name` off the wire is "USDF", - * which is the mint, not the thing they hold. - */ -@Composable -private fun displayNameOf(token: Token): String = when (token.address) { - Mint.usdf -> stringResource(R.string.displayName_dollars) - else -> token.name -} - /** * A ticket: the token along the top, the amount under it, and a perforated stub holding [stub]. * diff --git a/apps/flipcash/shared/chat/build.gradle.kts b/apps/flipcash/shared/chat/build.gradle.kts index 1dedaba8c6..b593dfc6df 100644 --- a/apps/flipcash/shared/chat/build.gradle.kts +++ b/apps/flipcash/shared/chat/build.gradle.kts @@ -19,7 +19,9 @@ dependencies { implementation(project(":apps:flipcash:shared:persistence:sources")) implementation(project(":apps:flipcash:shared:persistence:db")) implementation(project(":apps:flipcash:shared:contacts")) - implementation(project(":apps:flipcash:shared:tokens")) + // `api`, not `implementation`: GroupAccess.groupAccess is an extension on TokenCoordinator, + // so this module's public surface names a type from it. + api(project(":apps:flipcash:shared:tokens")) implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":services:flipcash")) implementation(project(":libs:network:connectivity:public")) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 043ba6fd0a..cf378a408d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -9,10 +9,13 @@ import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.ReactionSummary +import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.TypingState import com.getcode.opencode.model.core.ID import kotlinx.coroutines.flow.Flow @@ -275,6 +278,20 @@ interface MessagingOperations { * Implemented by [com.flipcash.shared.chat.internal.delegates.GroupFeedDelegate]. */ interface GroupOperations { + /** + * Creates a group from [parameters], caching it so it is in the list before the next sync, and + * returns the chat the server created. + * + * [idempotencyKey] is the caller's to mint, once, where the intent to create originates — the + * server derives the chat's identity from the caller and this key, so the same key retried + * returns the chat the first attempt created instead of a second chat. Minting it here would + * make every retry a new chat. + */ + suspend fun create( + parameters: StartChatParameters, + idempotencyKey: IdempotencyKey, + ): Result + /** Joins [chatId], caching the chat so it is in the list before the next sync. */ suspend fun join(chatId: ChatId): Result diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupAccess.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt similarity index 63% rename from apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupAccess.kt rename to apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt index 9c3327effe..c498e6ed8a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupAccess.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.messenger.internal +package com.flipcash.shared.chat import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.models.chat.ChatRuleRequirement @@ -6,18 +6,22 @@ import com.flipcash.services.models.chat.ChatRules import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.TokenWithBalance import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map /** * What this viewer may do in this group. * * One value for the whole screen, because the transcript's blur, the composer's replacement and the - * button's label are three views of the same answer and must never disagree. Resolved in the feature - * rather than in `shared/chat`: the chat layer stores the rules and the membership flag, and what - * they *mean* depends on a token balance that lives on the other side of that module boundary. + * button's label are three views of the same answer and must never disagree. + * + * It answers the creator's question too. "Would I be eligible to join this?" is what a creator must + * be able to say yes to before the server will accept the rules they are setting — `StartChat` + * returns `RULES_NOT_SATISFIED` to a caller who does not meet their own bar. That makes the create + * form's Create-button check and the join screen's gate the same predicate rather than two that have + * to be kept in step, which is why this sits in `shared/chat` and not in either feature. */ -internal sealed interface GroupAccess { +sealed interface GroupAccess { /** The viewer is in the chat. Everything is open. */ data object Membered : GroupAccess @@ -39,6 +43,10 @@ internal sealed interface GroupAccess { * Only *listener* rules are checked. Speaker rules gate sending inside a chat the viewer can * already read, which is a different screen state and not this one. * + * [ChatRuleRequirement.Staff] is satisfied by `UserFlags.is_staff`, which is the field the + * rule names and one the client is already told. Treating it as never satisfiable locked + * staff out of their own chats once they left one. + * * A [ChatRuleRequirement.MinimumBalance] is satisfied by the largest single balance among * the mints it names, not by their sum: the requirement is "hold $100 of this", and two * unrelated $60 positions are not that. An empty `mints` names no token in particular, so @@ -48,6 +56,7 @@ internal sealed interface GroupAccess { isMember: Boolean, rules: ChatRules?, balances: List, + isStaff: Boolean, ): GroupAccess { if (isMember) return Membered val listener = rules?.listener.orEmpty() @@ -70,10 +79,11 @@ internal sealed interface GroupAccess { val best = candidates.maxOrNull() best == null || best < requirement.amount } - // Nothing the client can check and nothing the user can go and acquire. A - // non-member facing a staff chat is blocked, and Task 9 gives that arm a - // disabled button rather than a purchase the user cannot make. - ChatRuleRequirement.Staff -> true + // `UserFlags.is_staff` is the same field the rule is written against, and the + // client already has it — so staff are eligible for a staff chat and can rejoin + // one they left. Everyone else is blocked with nothing to buy, which is the arm + // that gets a disabled button rather than a purchase they cannot make. + ChatRuleRequirement.Staff -> !isStaff } } @@ -83,15 +93,27 @@ internal sealed interface GroupAccess { } /** - * [GroupAccess.evaluate] over the live balance. + * [GroupAccess.evaluate] over the live balance and the live staff flag. + * + * [isStaff] arrives as a flow rather than a value because it resolves after the screen does — the + * flags are fetched, so a snapshot read taken while the gate is first decided would say no and + * never correct itself. A plain `Flow` rather than the flags coordinator keeps this module + * off `shared:userflags`; callers pass `resolvedFlags.map { it.isStaff.effectiveValue }`. * * `distinctUntilChanged` because `tokenBalances` re-emits on every price tick, and the gate only * ever has three answers — without it the transcript's blur would recompose several times a second * to arrive at the value it already had. */ -internal fun TokenCoordinator.groupAccess( +fun TokenCoordinator.groupAccess( isMember: Boolean, rules: ChatRules?, -): Flow = tokenBalances - .map { GroupAccess.evaluate(isMember = isMember, rules = rules, balances = it) } + isStaff: Flow, +): Flow = combine(tokenBalances, isStaff) { balances, staff -> + GroupAccess.evaluate( + isMember = isMember, + rules = rules, + balances = balances, + isStaff = staff, + ) +} .distinctUntilChanged() diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt index abb125ffcf..55e6c6e2e7 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt @@ -6,7 +6,9 @@ import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.services.controllers.ChatController import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.RosterChange +import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.GroupOperations import com.flipcash.shared.chat.internal.RosterStateHolder @@ -108,6 +110,27 @@ class GroupFeedDelegate @Inject constructor( } } + /** + * Creates the group [parameters] describe, caching it the same way [join] caches a chat the + * user just entered: the creator is a member of what they created, so the row belongs in the + * list on the response rather than after the next feed sync. + * + * No [Event.LoadMessages] — a chat that was created this instant has no transcript to fetch, + * and asking for one would be a round trip that can only come back empty. The membership flag + * is still written explicitly: `StartChat` returns the chat, not the caller's relationship to + * it, so nothing in [persist] would set it. + */ + override suspend fun create( + parameters: StartChatParameters, + idempotencyKey: IdempotencyKey, + ): Result { + return chatController.startChat(parameters, idempotencyKey) + .onSuccess { metadata -> + persist(listOf(metadata)) + metadataDataSource.setMembership(metadata.chatId, isMember = true) + } + } + /** * Joins [chatId], caching the chat the server returns so the list has it before any sync runs. */ diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupAccessTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt similarity index 76% rename from apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupAccessTest.kt rename to apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt index 16cbb29351..e94eb79ddb 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupAccessTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.messenger.internal +package com.flipcash.shared.chat import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules @@ -56,6 +56,7 @@ class GroupAccessTest { isMember = true, rules = rules(ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys))), balances = emptyList(), + isStaff = false, ) assertEquals(GroupAccess.Membered, access) @@ -65,11 +66,21 @@ class GroupAccessTest { fun `no rules means anyone may join`() { assertEquals( GroupAccess.Eligible, - GroupAccess.evaluate(isMember = false, rules = null, balances = emptyList()), + GroupAccess.evaluate( + isMember = false, + rules = null, + balances = emptyList(), + isStaff = false, + ), ) assertEquals( GroupAccess.Eligible, - GroupAccess.evaluate(isMember = false, rules = rules(), balances = emptyList()), + GroupAccess.evaluate( + isMember = false, + rules = rules(), + balances = emptyList(), + isStaff = false, + ), ) } @@ -79,6 +90,7 @@ class GroupAccessTest { isMember = false, rules = rules(ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys))), balances = listOf(held(1, "BadBoys", 100.0)), + isStaff = false, ) assertEquals(GroupAccess.Eligible, access) @@ -92,6 +104,7 @@ class GroupAccessTest { isMember = false, rules = rules(requirement), balances = listOf(held(1, "BadBoys", 99.99)), + isStaff = false, ) assertEquals(GroupAccess.Blocked(requirement), access) @@ -105,6 +118,7 @@ class GroupAccessTest { isMember = false, rules = rules(requirement), balances = listOf(held(2, "Other", 1_000.0)), + isStaff = false, ) assertEquals(GroupAccess.Blocked(requirement), access) @@ -116,6 +130,7 @@ class GroupAccessTest { isMember = false, rules = rules(ChatRuleRequirement.MinimumBalance(Fiat(100.0), emptyList())), balances = listOf(held(2, "Other", 150.0)), + isStaff = false, ) assertEquals(GroupAccess.Eligible, access) @@ -132,6 +147,7 @@ class GroupAccessTest { isMember = false, rules = rules(requirement), balances = listOf(held(1, "BadBoys", 60.0), held(2, "Other", 60.0)), + isStaff = false, ), ) assertEquals( @@ -140,21 +156,51 @@ class GroupAccessTest { isMember = false, rules = rules(requirement), balances = listOf(held(1, "BadBoys", 40.0), held(2, "Other", 120.0)), + isStaff = false, ), ) } @Test - fun `a staff rule blocks with nothing to buy`() { + fun `a staff rule blocks a non-staff viewer, with nothing to buy`() { val access = GroupAccess.evaluate( isMember = false, rules = rules(ChatRuleRequirement.Staff), balances = listOf(held(1, "BadBoys", 1_000.0)), + isStaff = false, ) assertEquals(GroupAccess.Blocked(ChatRuleRequirement.Staff), access) } + @Test + fun `staff are eligible for a staff chat, holding nothing`() { + // `UserFlags.is_staff` is the field the rule is written against, so the client can answer + // it. Treating it as never satisfiable left staff unable to rejoin a chat they had left. + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(ChatRuleRequirement.Staff), + balances = emptyList(), + isStaff = true, + ) + + assertEquals(GroupAccess.Eligible, access) + } + + @Test + fun `staff still have to hold the balance a chat asks for`() { + val requirement = ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys)) + + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(requirement, ChatRuleRequirement.Staff), + balances = emptyList(), + isStaff = true, + ) + + assertEquals(GroupAccess.Blocked(requirement), access) + } + @Test fun `the first unmet requirement is the one reported`() { val balance = ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys)) @@ -166,6 +212,7 @@ class GroupAccessTest { speaker = emptyList(), ), balances = emptyList(), + isStaff = false, ) assertEquals(GroupAccess.Blocked(balance), access) @@ -180,6 +227,7 @@ class GroupAccessTest { speaker = listOf(ChatRuleRequirement.MinimumBalance(Fiat(500.0), listOf(badBoys))), ), balances = emptyList(), + isStaff = false, ) assertEquals(GroupAccess.Eligible, access) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt index 6c8757ce28..08fa68b97e 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt @@ -10,8 +10,10 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.RosterChange import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.internal.RosterStateHolder import com.flipcash.shared.chat.internal.delegates.GroupFeedDelegate @@ -75,6 +77,9 @@ class GroupFeedDelegateTest { latestEventSequence = latestEventSequence, ) + private val newGroup = StartChatParameters.Group(title = "Ballers") + private val key = IdempotencyKey(ByteArray(16) { 7 }) + private fun feedReturns(vararg chats: ChatMetadata) { coEvery { controller.getGroupChatFeed(any()) } returns Result.success( ChatFeedPage(chats = chats.toList(), pagingToken = null, hasMore = false) @@ -175,6 +180,55 @@ class GroupFeedDelegateTest { coVerify(exactly = 0) { metadataDataSource.setMembership(any(), any()) } } + /** + * Creating is a join with the chat's first write attached, minus the fetch: the creator already + * holds everything the transcript would return, so asking for messages would be a round trip + * for an empty page. + */ + @Test + fun `creating persists the chat and marks membership without asking for messages`() = runTest { + coEvery { controller.startChat(any(), any()) } returns Result.success(group()) + + val result = subject.create(newGroup, key) + + assertEquals(group(), result.getOrNull()) + coVerify { metadataDataSource.upsert(listOf(group())) } + coVerify { memberDataSource.upsert(chatId, group().members) } + coVerify { metadataDataSource.setMembership(chatId, isMember = true) } + assertNull(withTimeoutOrNull(1_000) { subject.events.first() }) + } + + /** + * A rejected create must leave no trace: the form is still up and the user is about to retry it + * with the same idempotency key, and a membership row written for a chat the server refused + * would put a group in the list that does not exist. + */ + @Test + fun `a rejected create writes nothing`() = runTest { + coEvery { controller.startChat(any(), any()) } returns + Result.failure(Throwable("rules not satisfied")) + + val result = subject.create(newGroup, key) + + assertTrue(result.isFailure) + coVerify(exactly = 0) { metadataDataSource.upsert(any>()) } + coVerify(exactly = 0) { metadataDataSource.setMembership(any(), any()) } + } + + /** + * The key is the server's handle on one create attempt, so the delegate has to pass the caller's + * through rather than mint its own — a retry that arrived with a fresh key would create a second + * group instead of returning the first. + */ + @Test + fun `the caller's idempotency key is what reaches the controller`() = runTest { + coEvery { controller.startChat(any(), any()) } returns Result.success(group()) + + subject.create(newGroup, key) + + coVerify { controller.startChat(newGroup, key) } + } + /** * The row is cleared before the call, so the chat leaves the list on the tap rather than a * round trip later. diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/ChatTapTargetPlanner.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/ChatTapTargetPlanner.kt index 9158a11376..5605aae7e2 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/ChatTapTargetPlanner.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/ChatTapTargetPlanner.kt @@ -1,11 +1,18 @@ package com.flipcash.app.notifications +import com.flipcash.app.core.util.Linkify +import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType /** Where tapping a chat notification lands. */ -enum class ChatTapTarget { - /** The conversation itself, through the app's chat-by-id link. */ - Conversation, +sealed interface ChatTapTarget { + /** + * The conversation itself, opened through [link]. + * + * The link is carried rather than rebuilt at the call site so that the decision of *which* + * link form a chat gets is made in one place, next to the type that decides it. + */ + data class Conversation(val link: String) : ChatTapTarget /** * A plain launch, which opens the app on the camera. @@ -13,24 +20,39 @@ enum class ChatTapTarget { * The fallback for a chat with no screen to open, rather than firing a link nothing routes: * an unrouted link still leaves the app on whatever it was showing, having taken the tap. */ - AppLauncher, + data object AppLauncher : ChatTapTarget } /** - * Whether a chat push can open its conversation. + * Whether a chat push can open its conversation, and by which link. + * + * The Chats tab lists tip DMs and groups (`TipFlowViewModel`: `feed(TIP_DM, GROUP)`), and both open + * by chat id from there. A `CONTACT_DM` is not: the Send tab and direct-send flow that used to open + * one were removed, and `AppRouter` deliberately routes nothing for them. * - * The Chats tab lists tip DMs and groups (`TipFlowViewModel`: `feed(TIP_DM, GROUP)`), and both - * open by chat id from there, so both are reachable through the `/tip/chat/{id}` link the router - * resolves to that tab. A `CONTACT_DM` is not: the Send tab and direct-send flow that used to - * open one were removed, and `AppRouter` deliberately routes nothing for them. + * A group takes its invite link, `app.flipcash.com/chat/{uuid}` — the same link a member shares — + * rather than `/tip/chat/{id}`. Both resolve to the chat screen today, so this is not a fix for a + * broken tap; it is what keeps the two entry points from being two things. **A push for a group the + * viewer has not joined has to land on the gated preview**, which is the chat screen deciding what + * to render from `GroupAccess`, and pointing both entry points at one link is what makes that a + * property of the destination instead of an agreement between two callers. + * + * The invite form needs a 16-byte id, which every group id is (`common.v1.ChatId.value` is a UUID + * for a group, a 32-byte hash for a DM). A group whose id is not UUID-shaped should not exist; if + * one arrives, `/tip/chat/{id}` still reaches the same screen, so the tap degrades to the older + * link rather than to the camera. * * [chatType] is the type resolved by [planConversationStyling], not what the payload declared. * `chat_metadata` is optional on the wire, and a group push that omits it would otherwise land on - * the camera. Null and [ChatType.UNKNOWN] read the same: neither the push nor the device could - * say what the chat is, and a chat screen opened for a type the app has no surface for would - * dead-end. + * the camera. Null and [ChatType.UNKNOWN] read the same: neither the push nor the device could say + * what the chat is, and a chat screen opened for a type the app has no surface for would dead-end. */ -fun planChatTapTarget(chatType: ChatType?): ChatTapTarget = when (chatType) { - ChatType.TIP_DM, ChatType.GROUP -> ChatTapTarget.Conversation +fun planChatTapTarget(chatType: ChatType?, chatId: ChatId): ChatTapTarget = when (chatType) { + ChatType.GROUP -> ChatTapTarget.Conversation( + Linkify.groupChatInvite(chatId) ?: Linkify.tipChatById(chatId) + ) + + ChatType.TIP_DM -> ChatTapTarget.Conversation(Linkify.tipChatById(chatId)) + ChatType.CONTACT_DM, ChatType.UNKNOWN, null -> ChatTapTarget.AppLauncher } diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index 2ade7603d6..2458a1c60d 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -597,11 +597,9 @@ class NotificationService : FirebaseMessagingService(), } to navigation.mint.hashCode() is NavigationTrigger.Chat.ById -> { - // /tip/chat/… is named for where it was first used, but it resolves to the Chats - // tab and the conversation by id, which is the destination for a group too. - val intent = when (planChatTapTarget(chatType)) { - ChatTapTarget.Conversation -> Intent(Intent.ACTION_VIEW).apply { - data = Linkify.tipChatById(navigation.chatId).toUri() + val intent = when (val target = planChatTapTarget(chatType, navigation.chatId)) { + is ChatTapTarget.Conversation -> Intent(Intent.ACTION_VIEW).apply { + data = target.link.toUri() } ChatTapTarget.AppLauncher -> packageManager.getLaunchIntentForPackage(packageName) diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/ChatTapTargetPlannerTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/ChatTapTargetPlannerTest.kt index 9c03060c61..349649a008 100644 --- a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/ChatTapTargetPlannerTest.kt +++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/ChatTapTargetPlannerTest.kt @@ -1,21 +1,51 @@ package com.flipcash.app.notifications +import com.flipcash.app.core.util.Linkify +import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType +import com.getcode.opencode.model.core.bytes +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) class ChatTapTargetPlannerTest { + private val groupUuid = UUID.fromString("0189d5f1-3c6a-7b4e-8f21-9c3d4e5f6a7b") + private val groupChatId = ChatId(groupUuid.bytes) + private val dmChatId = ChatId(ByteArray(32) { it.toByte() }) + // region Chats the app can open @Test - fun `a group push opens the conversation`() { - assertEquals(ChatTapTarget.Conversation, planChatTapTarget(ChatType.GROUP)) + fun `a group push opens the conversation through its invite link`() { + // The link an invite carries, so a push tap and an invite tap are one entry, not two that + // have to agree about where an unjoined group lands. + val target = planChatTapTarget(ChatType.GROUP, groupChatId) + assertIs(target) + assertEquals("https://app.flipcash.com/chat/$groupUuid", target.link) + assertEquals(Linkify.groupChatInvite(groupChatId), target.link) + } + + @Test + fun `a group whose id has no uuid form still reaches the chat screen`() { + // Should not happen — a group id is a 16-byte UUID — but the older link resolves to the + // same screen, so the tap degrades to it rather than to the camera. + val target = planChatTapTarget(ChatType.GROUP, dmChatId) + assertIs(target) + assertEquals(Linkify.tipChatById(dmChatId), target.link) } @Test fun `a tip dm push opens the conversation`() { - assertEquals(ChatTapTarget.Conversation, planChatTapTarget(ChatType.TIP_DM)) + val target = planChatTapTarget(ChatType.TIP_DM, dmChatId) + assertIs(target) + assertEquals(Linkify.tipChatById(dmChatId), target.link) } // endregion @@ -25,13 +55,13 @@ class ChatTapTargetPlannerTest { @Test fun `a contact dm push launches the app`() { // The Send tab it used to open was removed, and AppRouter routes nothing for it. - assertEquals(ChatTapTarget.AppLauncher, planChatTapTarget(ChatType.CONTACT_DM)) + assertEquals(ChatTapTarget.AppLauncher, planChatTapTarget(ChatType.CONTACT_DM, dmChatId)) } @Test fun `an unresolved type launches the app`() { - assertEquals(ChatTapTarget.AppLauncher, planChatTapTarget(ChatType.UNKNOWN)) - assertEquals(ChatTapTarget.AppLauncher, planChatTapTarget(null)) + assertEquals(ChatTapTarget.AppLauncher, planChatTapTarget(ChatType.UNKNOWN, dmChatId)) + assertEquals(ChatTapTarget.AppLauncher, planChatTapTarget(null, dmChatId)) } // endregion diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index 3ee56ad027..6379135b91 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -129,6 +129,16 @@ internal class AppRouter( listOf(AppRoute.Sheets.Tips(), AppRoute.Messaging.Chat(type.identifier)) ) + // The same destination a group push tap resolves to (see ChatTapTargetPlanner): the + // chat screen is the gated preview when the viewer is not a member, so an invite and + // a push land on one screen rather than on two that would have to agree. + is DeeplinkType.GroupChatInvite -> DeeplinkAction.Navigate( + listOf( + AppRoute.Sheets.Tips(), + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(type.chatId)), + ) + ) + is DeeplinkType.Tipcard -> tipCard(TipCardOwner.ById(type.userId)) is DeeplinkType.TipcardByUsername -> tipCard(TipCardOwner.ByUsername(type.username)) @@ -202,12 +212,10 @@ internal class AppRouter( deepLink.isEmailVerification() -> deepLink.handleEmailVerification() deepLink.isTipChat() -> deepLink.handleTipChat() deepLink.isTipCard() -> deepLink.handleTipCard() + // Checked before the profile link, which is host-gated to the apex and so cannot + // match this one, but the ordering is what keeps that true if either widens. + deepLink.isGroupChatInvite() -> deepLink.handleGroupChatInvite() deepLink.isProfileLink() -> deepLink.handleProfileLink() - // `/chat/{id}` links are intentionally NOT handled: the Send tab / direct-send - // flow they opened was removed. The manifest no longer claims that path either, so - // such a link opens in the browser rather than dead-ending here. Re-add routing and - // the App Link filter together, or not at all. - // (Tip DMs use `/tip/chat/{id}` — handled above via isTipChat.) else -> null } } @@ -384,6 +392,25 @@ private fun DeepLink.handleTipChat(): DeeplinkType.TipChat? { return DeeplinkType.TipChat(ChatIdentifier.ByChatId(chatId)) } +/** + * A group chat invite: `app.flipcash.com/chat/{uuid}`. + * + * Shape-checked here rather than only in [handleGroupChatInvite] so that a `/chat/` path which is + * not an invite falls through to [DeepLink.unrouted] instead of being claimed and dropped. + * + * `/tip/chat/{id}` cannot reach this: [DeepLink.isTipChat] matches `tip` at segment 0 and is tested + * first, and this requires `chat` at segment 0. + */ +private fun DeepLink.isGroupChatInvite(): Boolean = + chat.contains(pathSegments.getOrNull(0)) && + pathSegments.getOrNull(1)?.lowercase()?.isUuidShaped() == true + +private fun DeepLink.handleGroupChatInvite(): DeeplinkType.GroupChatInvite? { + val segment = pathSegments.getOrNull(1)?.lowercase() ?: return null + val chatId = runCatching { ChatId(UUID.fromString(segment).bytes) }.getOrNull() ?: return null + return DeeplinkType.GroupChatInvite(chatId) +} + private fun DeepLink.handleTipCard(): DeeplinkType.Tipcard? { val uri = data.toUri() val userId = uri.pathSegments.getOrNull(1) diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index a46f6a11c3..2c0887f17d 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -424,6 +424,76 @@ class AppRouterTest { // endregion + // region classify + dispatch — GroupChatInvite + + private val groupUuid = UUID.fromString("0189d5f1-3c6a-7b4e-8f21-9c3d4e5f6a7b") + private val groupChatId = ChatId(groupUuid.bytes) + + @Test + fun `an invite link round-trips from Linkify back to the chat id it was built from`() { + val link = Linkify.groupChatInvite(groupChatId) + assertEquals("https://app.flipcash.com/chat/$groupUuid", link) + + val type = router.classify(DeepLink(link!!)) + assertIs(type) + assertEquals(groupChatId, type.chatId) + } + + @Test + fun `an invite link classifies regardless of the case the uuid was shared in`() { + val type = router.classify( + DeepLink("https://app.flipcash.com/chat/${groupUuid.toString().uppercase()}") + ) + assertIs(type) + assertEquals(groupChatId, type.chatId) + } + + @Test + fun `a chat path that is not a uuid is left unclassified rather than claimed`() { + // The manifest filter is `/chat/.*` — `pathPattern` cannot express the UUID shape — so + // the router is what keeps a non-invite `/chat/` link from being claimed and dropped. + assertNull(router.classify(DeepLink("https://app.flipcash.com/chat/not-a-uuid"))) + assertNull(router.classify(DeepLink("https://app.flipcash.com/chat"))) + } + + @Test + fun `a tip chat link is not swallowed by the invite matcher`() { + // `/tip/chat/{base64}` and `/chat/{uuid}` both contain a `chat` segment; ordering and the + // segment index are what separate them. + val type = router.classify(DeepLink(Linkify.tipChatById(sampleChatId))) + assertIs(type) + } + + @Test + fun `a DM has no invite link because its id is not a uuid`() { + assertNull(Linkify.groupChatInvite(sampleChatId)) + } + + @Test + fun `dispatch opens an invite on the chat screen under the tips sheet`() { + loggedIn() + val action = router.dispatch(DeepLink(Linkify.groupChatInvite(groupChatId)!!)) + + assertIs(action) + assertEquals(2, action.routes.size) + assertIs(action.routes[0]) + val chat = action.routes[1] + assertIs(chat) + val identifier = chat.identifier + assertIs(identifier) + assertEquals(groupChatId, identifier.chatId) + } + + @Test + fun `dispatch sends an invite through onboarding when logged out`() { + loggedOut() + val action = router.dispatch(DeepLink(Linkify.groupChatInvite(groupChatId)!!)) + assertIs(action) + assertEquals(listOf(AppRoute.OnboardingFlow()), action.routes) + } + + // endregion + // region classify + dispatch — tip card links on the bare host @Test 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 86e9d26d33..2871188402 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 @@ -44,6 +44,22 @@ sealed interface Shareable { override val pendingData: ShareablePendingData? = null } + /** + * An invite to a group chat — node 10127:118315's "Send Invite Link". + * + * Carries the built [url] rather than the chat it points at: this module sees `:libs:messaging` + * and nothing of `:services:flipcash`, so a `ChatId` is not a type it can name. Building the + * link is [com.flipcash.app.core.util.Linkify.groupChatInvite]'s job anyway — the caller has the + * chat, and one place deciding the link's shape is what keeps the share and the copy identical. + */ + data class GroupInvite( + val url: String, + // The group's title, for the Sharesheet heading. Null shares the link with no heading. + val title: String? = null, + ) : Shareable { + override val pendingData: ShareablePendingData? = null + } + data class TipCard( val userId: ID, // Optional pre-rendered Sharesheet preview. Null degrades to sharing the URL alone. diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt index b521a369fc..4dc5e307f8 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt @@ -28,6 +28,9 @@ internal class InternalShareConfirmationController( is Shareable.DownloadLink -> ShareConfirmationResult.Confirmed(shareResult) is Shareable.TokenInfo -> ShareConfirmationResult.Confirmed(shareResult) is Shareable.Invite -> ShareConfirmationResult.Confirmed(shareResult) + // Nothing to take back if the link never leaves — the chat exists either way, and + // the same link can be shared again. Only a cash link needs confirming. + is Shareable.GroupInvite -> ShareConfirmationResult.Confirmed(shareResult) is Shareable.TipCard -> ShareConfirmationResult.Confirmed(shareResult) is Shareable.TipCodeImage -> ShareConfirmationResult.Confirmed(shareResult) } diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt index 7388b3469a..e545f1b25e 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt @@ -103,6 +103,7 @@ internal class InternalShareSheetController( Shareable.DownloadLink -> Unit is Shareable.TokenInfo -> Unit is Shareable.Invite -> Unit + is Shareable.GroupInvite -> Unit is Shareable.TipCard -> Unit is Shareable.TipCodeImage -> Unit } @@ -150,6 +151,10 @@ internal class InternalShareSheetController( shareInviteLink() } + is Shareable.GroupInvite -> { + shareGroupInvite(shareable) + } + is Shareable.TipCard -> shareTipCard(shareable) is Shareable.TipCodeImage -> shareTipCodeImage(shareable) @@ -292,6 +297,25 @@ internal class InternalShareSheetController( context.startActivity(share) } + private fun shareGroupInvite(shareable: Shareable.GroupInvite) { + val intent = Intent().apply { + action = Intent.ACTION_SEND + // No heading when the group has no title yet — an empty one reads as a broken share. + shareable.title?.let { + putExtra(Intent.EXTRA_TITLE, it) + putExtra(Intent.EXTRA_SUBJECT, it) + } + putExtra(Intent.EXTRA_TEXT, shareable.url) + type = "text/plain" + } + + val share = Intent.createChooser(intent, null).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + context.startActivity(share) + } + private fun shareTipCard(shareable: Shareable.TipCard) { // Addressed the same way the You tab's link row addresses it, so what gets shared is what // the card says it is. diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/ChatRulesProtoMappingTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/ChatRulesProtoMappingTest.kt new file mode 100644 index 0000000000..b714382985 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/ChatRulesProtoMappingTest.kt @@ -0,0 +1,95 @@ +package com.flipcash.services.internal.network.extensions + +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.flipcash.services.models.chat.IdempotencyKey +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel + +/** + * The balance gate, from the form's draft to the `StartChat` request and back. + * + * What the group creation flow sets is one listener [ChatRuleRequirement.MinimumBalance] and nothing + * else, so these cover that shape specifically: the speaker list stays empty, the amount keeps its + * currency, and the single-mint list survives. The round trip matters because the requirement the + * creator typed and the requirement the join gate later evaluates come from opposite directions — + * the first through `asProtoRules`, the second through `toChatRules` off `GetChat`. + */ +class ChatRulesProtoMappingTest { + + @Test + fun `a listener minimum balance maps to the listener list and leaves speaker empty`() { + val proto = ChatRules( + listener = listOf(ChatRuleRequirement.MinimumBalance(Fiat(100), listOf(mint(7)))), + speaker = emptyList(), + ).asProtoRules() + + assertEquals(1, proto.listenerCount) + assertEquals(0, proto.speakerCount) + + val requirement = proto.getListener(0) + assertEquals(ChatModel.ListenerRules.KindCase.MINIMUM_BALANCE, requirement.kindCase) + assertEquals(100.0, requirement.minimumBalance.amount.nativeAmount) + assertEquals("usd", requirement.minimumBalance.amount.currency) + assertEquals(1, requirement.minimumBalance.mintsCount) + assertEquals( + mint(7).bytes, + requirement.minimumBalance.getMints(0).value.toByteArray().toList(), + ) + } + + @Test + fun `an empty mint list stays empty, meaning any mint`() { + val proto = ChatRules( + listener = listOf(ChatRuleRequirement.MinimumBalance(Fiat(50), emptyList())), + speaker = emptyList(), + ).asProtoRules() + + assertEquals(0, proto.getListener(0).minimumBalance.mintsCount) + } + + @Test + fun `the requirement the creator set is the requirement the gate reads back`() { + val rules = ChatRules( + listener = listOf( + ChatRuleRequirement.MinimumBalance( + amount = Fiat(100, CurrencyCode.USD), + mints = listOf(mint(3)), + ) + ), + speaker = emptyList(), + ) + + assertEquals(rules, rules.asProtoRules().toChatRules()) + } + + @Test + fun `a staff requirement maps without an amount`() { + val proto = ChatRules( + listener = listOf(ChatRuleRequirement.Staff), + speaker = emptyList(), + ).asProtoRules() + + assertEquals(ChatModel.ListenerRules.KindCase.STAFF, proto.getListener(0).kindCase) + assertTrue(proto.getListener(0).hasStaff()) + } + + @Test + fun `an idempotency key crosses the wire as its 16 bytes`() { + // Spelled out rather than read from the model: the size is the contract's, and a test that + // takes it from the same constant it is checking would pass on a wrong one. + val key = IdempotencyKey(ByteArray(16) { it.toByte() }) + + val proto = key.asProtoIdempotencyKey() + + assertEquals(16, proto.value.size()) + assertEquals(key.bytes.toList(), proto.value.toByteArray().toList()) + } + + private fun mint(seed: Byte) = Mint(ByteArray(32) { seed }.toList()) +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/BlurredContent.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/BlurredContent.kt index cb836707fe..fd135106f9 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/BlurredContent.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/BlurredContent.kt @@ -1,12 +1,15 @@ package com.getcode.ui.components import androidx.annotation.IntRange -import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.getcode.theme.CodeTheme @@ -16,6 +19,9 @@ import dev.chrisbanes.haze.blur.materials.HazeMaterials import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState +/** How long the blur takes to come on or off. Long enough to read as a lift, short enough to wait. */ +private const val BlurFadeDurationMillis = 400 + @Composable fun BlurredContent( modifier: Modifier = Modifier, @@ -24,9 +30,16 @@ fun BlurredContent( containerColor: Color = CodeTheme.colors.background, content: @Composable BoxScope.() -> Unit ) { - val blurRadius by animateDpAsState( - targetValue = if (enabled) radius.dp else 0.dp, - label = "blur radius" + // One value drives the radius and the layer's own opacity, because the material carries a tint + // as well as a blur: shrinking the radius alone leaves a flat scrim over sharp content and then + // pops it away at the end of the animation. + val progress by animateFloatAsState( + targetValue = if (enabled) 1f else 0f, + // On at once, off over time. A blur is a withholding, so the content under it must never be + // legible first — a screen that works out what to hide a frame after it draws would + // otherwise animate a readable surface into a covered one. Lifting it is the transition. + animationSpec = if (enabled) snap() else tween(durationMillis = BlurFadeDurationMillis), + label = "blur progress", ) val hazeState = rememberHazeState() @@ -36,13 +49,17 @@ fun BlurredContent( Box(Modifier.hazeSource(hazeState)) { content() } - if (enabled) { + // Mounted until the fade has actually run. Tying this to [enabled] removed the layer on the + // same frame the flag flipped, so the animation had nothing left to draw and the blur came + // off in one step. + if (progress > 0f) { Box( Modifier .matchParentSize() + .alpha(progress) .hazeBlur( input = HazeInput.Sources(hazeState), - style = material.then { blurRadius(blurRadius) }, + style = material.then { blurRadius(radius.dp * progress) }, ) ) } 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 new file mode 100644 index 0000000000..c24fa9a81c --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt @@ -0,0 +1,62 @@ +package com.getcode.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +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.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.theme.extraSmall + +private val IconSize = 24.dp + +/** + * A rounded card that reads as one tappable choice: icon, label, nothing else. + * + * Not [ListItem] — that draws a settings list, with a divider baked in and no card behind it. The + * group-chat screens (nodes 10127:117987, 10127:118315) stack separated cards instead, so the row + * carries its own background and the column spaces them apart. + */ +@Composable +fun ChoiceRow( + label: String, + icon: Int, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(CodeTheme.shapes.extraSmall) + .background(White05) + .clickable(onClick = onClick) + .padding(CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Icon( + modifier = Modifier.size(IconSize), + painter = painterResource(icon), + contentDescription = null, + tint = CodeTheme.colors.textMain, + ) + 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, + ) + } +}