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