diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index c162722169..aee06a45f6 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -914,6 +914,10 @@
Minimum Balance: %1$s of %2$s
Minimum Balance: %1$s
This chat is open to Flipcash staff only.
+ Only Flipcash can send messages
+ Minimum Balance to Send Messages: %1$s of %2$s
+ Minimum Balance to Send Messages: %1$s
+ Only Flipcash staff can send messages here
Buy More %1$s
Join Chat
Leave Chat
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 c3596f26d0..51eec541d5 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
@@ -80,8 +80,9 @@ import com.flipcash.shared.chat.ChatDraftSnapshot
import com.flipcash.shared.chat.ChatDraftStore
import com.flipcash.shared.chat.ChatHydration
import com.flipcash.shared.chat.ChatMembership
+import com.flipcash.services.models.chat.ChatRuleRequirement
import com.flipcash.shared.chat.GroupAccess
-import com.flipcash.shared.chat.speakerAccess
+import com.flipcash.shared.chat.speakerBlock
import com.flipcash.shared.chat.MessageCapability
import com.flipcash.shared.chat.MessagePolicy
import com.flipcash.shared.chat.MessageReactions
@@ -358,10 +359,11 @@ internal class ChatViewModel @Inject constructor(
*/
val groupAccess: GroupAccess? = null,
/**
- * Whether the chat's speaker rules let this viewer speak. True for a DM, which has none,
- * and until the balance and staff flag arrive, so a group does not flash Reply away.
+ * The speaker requirement standing between this viewer and speaking, or null when there is
+ * none. Null for a DM, which has no rules, and until the balance and staff flag arrive, so
+ * a group does not flash Reply away.
*/
- val canSpeak: Boolean = true,
+ val speakerBlock: ChatRuleRequirement? = 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
@@ -449,6 +451,20 @@ internal class ChatViewModel @Inject constructor(
val obscuresTranscript: Boolean
get() = isOutsideGroup && !readsFromOutside
+ /** Whether the chat's speaker rules let this viewer speak. See [speakerBlock]. */
+ val canSpeak: Boolean
+ get() = speakerBlock == null
+
+ /**
+ * Whether a member who may not speak sees the read-only panel in place of the composer.
+ * Distinct from [replacesComposer], which is about the viewer being outside the group: a
+ * viewer outside has the Join gate, and speaking is a question asked only of a member.
+ *
+ * Mirrors iOS `ConversationGatePresentation.readOnly`.
+ */
+ val isReadOnlySpeaker: Boolean
+ get() = subject is ChatSubject.Group && !isOutsideGroup && !replacesComposer && speakerBlock != null
+
/**
* Whether the gate stands where the composer does. Every viewer outside the group, eligible
* or not: reading a group from outside is not posting in it.
@@ -531,7 +547,7 @@ internal class ChatViewModel @Inject constructor(
/** The gate re-decided, because membership, the rules, or the balance moved. */
data class OnGroupAccessResolved(val access: GroupAccess) : Event
- data class OnCanSpeakResolved(val canSpeak: Boolean) : Event
+ data class OnSpeakerBlockResolved(val block: ChatRuleRequirement?) : Event
/** The gate's "Join Chat" button. */
data object JoinChat : Event
@@ -766,7 +782,7 @@ internal class ChatViewModel @Inject constructor(
/**
* Whether the chat's speaker rules let the viewer speak, alongside [viewerCanPost] (which is
- * membership). Only a widget's Reply and reactions read it.
+ * membership). Reply and reactions read it on every message.
*/
private val viewerCanSpeak = stateFlow.map { it.canSpeak }.distinctUntilChanged()
@@ -1660,15 +1676,15 @@ internal class ChatViewModel @Inject constructor(
.distinctUntilChanged()
.flatMapLatest { group ->
if (group == null) {
- flowOf(true)
+ flowOf(null)
} else {
- tokenCoordinator.speakerAccess(
+ tokenCoordinator.speakerBlock(
rules = group.rules,
isStaff = userFlags.resolvedFlags.map { it.isStaff.effectiveValue },
)
}
}
- .onEach { dispatchEvent(Event.OnCanSpeakResolved(it)) }
+ .onEach { dispatchEvent(Event.OnSpeakerBlockResolved(it)) }
.launchIn(viewModelScope)
// Observe member identity — if the other member loses identity (e.g. unlinked
@@ -2719,7 +2735,7 @@ internal class ChatViewModel @Inject constructor(
is Event.OnRuleCurrencyResolved ->
{ state -> state.copy(ruleCurrency = event.currency) }
is Event.OnGroupAccessResolved -> { state -> state.copy(groupAccess = event.access) }
- is Event.OnCanSpeakResolved -> { state -> state.copy(canSpeak = event.canSpeak) }
+ is Event.OnSpeakerBlockResolved -> { state -> state.copy(speakerBlock = event.block) }
Event.JoinChat -> { state ->
state.copy(joinProgress = LoadingSuccessState(loading = true))
}
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 6a4a3c0d6d..c28ac3dd01 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
@@ -57,6 +57,7 @@ import com.flipcash.app.messenger.internal.requiresStaff
import com.flipcash.features.messenger.R
import com.flipcash.services.models.chat.ChatType
import com.flipcash.shared.chat.models.ChatActionHandler
+import com.flipcash.services.models.chat.ChatRuleRequirement
import com.flipcash.shared.chat.ui.ChatAnimations
import com.flipcash.shared.chat.ui.ComposerReplyStrip
import com.getcode.theme.CodeTheme
@@ -121,6 +122,23 @@ internal fun UserControlBottomBar(
return
}
+ // A member the speaker rules keep from speaking: nothing to type into, so the panel stands
+ // where the composer would. Checked after the join gate, which owns every viewer outside.
+ state.speakerBlock?.takeIf { state.isReadOnlySpeaker }?.let { block ->
+ val rules = (state.subject as? ChatSubject.Group)?.rules
+ val ruleMint = rules.balanceRequirement()?.mints?.firstOrNull()
+ val blockMint = (block as? ChatRuleRequirement.MinimumBalance)?.mints?.firstOrNull()
+ SpeakerGateBar(
+ requirement = block,
+ hazeState = hazeState,
+ // The resolved currency describes the chat's stated balance rule; only name it when
+ // that is the mint this requirement is about.
+ currencyName = state.ruleCurrency?.nameInRequirement
+ ?.takeIf { blockMint != null && ruleMint?.bytes == blockMint.bytes },
+ )
+ return
+ }
+
val keyboard = rememberKeyboardController()
val focusRequester = remember { FocusRequester() }
var buttonHeight by remember { mutableStateOf(0.dp) }
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SpeakerGateBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SpeakerGateBar.kt
new file mode 100644
index 0000000000..b54b5508e1
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SpeakerGateBar.kt
@@ -0,0 +1,102 @@
+package com.flipcash.app.messenger.internal.screens.components
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.flipcash.app.theme.FlipcashPreview
+import com.flipcash.features.messenger.R
+import com.flipcash.services.models.chat.ChatRuleRequirement
+import com.getcode.opencode.model.financial.Fiat
+import com.getcode.theme.CodeTheme
+import dev.chrisbanes.haze.HazeInput
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.blur.hazeBlur
+import dev.chrisbanes.haze.blur.materials.HazeMaterials
+import dev.chrisbanes.haze.rememberHazeState
+
+/**
+ * Stands where the composer does for a member the chat's speaker rules keep from speaking (node
+ * 10588:1969 for the `never` copy). Mirrors iOS `ConversationGatePresentation.readOnly`.
+ *
+ * The surface is the composer field's own: the same shape, outline and blur over the same
+ * [hazeState], so the transcript scrolling under it reads as it does under the field. Disabled,
+ * with no input, attach or send button and no call to action: none of the three requirements has an
+ * action the viewer could take from here.
+ *
+ * Copy follows iOS `ConversationGatePanel`: `never` names the sender, a balance states the amount
+ * as a sending requirement, staff names who may send.
+ */
+@Composable
+internal fun SpeakerGateBar(
+ /** The requirement standing in the viewer's way. */
+ requirement: ChatRuleRequirement,
+ hazeState: HazeState,
+ /** The token a balance rule names, or null to state the amount alone (the reserve, or unresolved). */
+ currencyName: String?,
+ modifier: Modifier = Modifier,
+ /** Off only for a Robolectric render, whose native runtime cannot compile the blur shader. */
+ blurEnabled: Boolean = true,
+) {
+ val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background)
+ val text = when (requirement) {
+ ChatRuleRequirement.Never -> stringResource(R.string.label_chatGate_speakerNever)
+ ChatRuleRequirement.Staff -> stringResource(R.string.subtitle_chatGate_speakerStaffOnly)
+ is ChatRuleRequirement.MinimumBalance ->
+ if (currencyName != null) {
+ stringResource(
+ R.string.subtitle_chatGate_speakerMinimumBalance,
+ requirement.amount.formatted(),
+ currencyName,
+ )
+ } else {
+ stringResource(
+ R.string.subtitle_chatGate_speakerMinimumBalance_anyToken,
+ requirement.amount.formatted(),
+ )
+ }
+ }
+
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = CodeTheme.dimens.grid.x6.coerceAtLeast(CodeTheme.dimens.inset))
+ .padding(vertical = CodeTheme.dimens.grid.x3)
+ .height(50.dp)
+ .border(CodeTheme.dimens.border, CodeTheme.colors.divider, CodeTheme.shapes.medium)
+ .then(if (blurEnabled) Modifier.hazeBlur(HazeInput.Sources(hazeState), material) else Modifier)
+ .padding(horizontal = CodeTheme.dimens.grid.x3),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = text,
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textMain.copy(alpha = 0.4f),
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun SpeakerGateBarPreview() {
+ FlipcashPreview(showBackground = true) {
+ SpeakerGateBar(
+ requirement = ChatRuleRequirement.Never,
+ hazeState = rememberHazeState(),
+ currencyName = null,
+ )
+ }
+}
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
index 12ed559db9..6002939dcb 100644
--- 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
@@ -534,4 +534,40 @@ class ChatViewModelStateTest {
replyTarget = replyingTo?.toDraftReply(),
editStash = editing?.stashedDraft,
)
+
+ @Test
+ fun `a member the speaker rules silence sees the read-only panel and cannot speak`() {
+ val state = ChatViewModel.State(
+ subject = group(isMember = true),
+ speakerBlock = ChatRuleRequirement.Never,
+ )
+ assertTrue(state.isReadOnlySpeaker)
+ assertFalse(state.canSpeak)
+ // The Join gate is not the one standing in: this viewer is in the group.
+ assertFalse(state.replacesComposer)
+ }
+
+ @Test
+ fun `a member who may speak keeps the composer`() {
+ val state = ChatViewModel.State(subject = group(isMember = true), speakerBlock = null)
+ assertFalse(state.isReadOnlySpeaker)
+ assertTrue(state.canSpeak)
+ }
+
+ @Test
+ fun `an outsider gets the join gate rather than the read-only panel`() {
+ val state = ChatViewModel.State(
+ subject = group(isMember = false),
+ speakerBlock = ChatRuleRequirement.Never,
+ )
+ assertTrue(state.replacesComposer)
+ assertFalse(state.isReadOnlySpeaker)
+ }
+
+ @Test
+ fun `a direct message has no rules and is never read-only`() {
+ val state = ChatViewModel.State(subject = dm)
+ assertTrue(state.canSpeak)
+ assertFalse(state.isReadOnlySpeaker)
+ }
}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SpeakerGateBarScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SpeakerGateBarScreenshotTest.kt
new file mode 100644
index 0000000000..6a14ed2314
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SpeakerGateBarScreenshotTest.kt
@@ -0,0 +1,59 @@
+package com.flipcash.app.messenger.internal
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.view.View
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.layout.width
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.unit.dp
+import com.flipcash.app.messenger.internal.screens.components.SpeakerGateBar
+import com.flipcash.app.theme.FlipcashPreview
+import com.flipcash.services.models.chat.ChatRuleRequirement
+import dev.chrisbanes.haze.rememberHazeState
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+import java.io.File
+
+/**
+ * Renders the read-only composer panel to `build/screenshots/`. Not an assertion test.
+ *
+ * The blur is off: Robolectric's native runtime cannot compile the Haze shader. This shows layout,
+ * outline and copy, not the blur.
+ */
+@RunWith(RobolectricTestRunner::class)
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@Config(sdk = [34], qualifiers = "w402dp-h120dp-xhdpi")
+class SpeakerGateBarScreenshotTest {
+
+ @get:Rule
+ val composeRule = createAndroidComposeRule()
+
+ @Test
+ fun rendersNeverPanel() {
+ composeRule.mainClock.autoAdvance = false
+ composeRule.setContent {
+ FlipcashPreview(showBackground = true) {
+ SpeakerGateBar(
+ requirement = ChatRuleRequirement.Never,
+ hazeState = rememberHazeState(),
+ currencyName = null,
+ modifier = Modifier.width(402.dp),
+ blurEnabled = false,
+ )
+ }
+ }
+ repeat(10) { composeRule.mainClock.advanceTimeByFrame() }
+ val root: View = composeRule.activity.findViewById(android.R.id.content)
+ val bitmap = Bitmap.createBitmap(root.width.coerceAtLeast(1), root.height.coerceAtLeast(1), Bitmap.Config.ARGB_8888)
+ root.draw(Canvas(bitmap))
+ val file = File("build/screenshots").apply { mkdirs() }.resolve("speaker_gate_never.png")
+ file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
+ println("SCREENSHOT_WRITTEN: ${file.absolutePath}")
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt
index 0fb8729e5a..d2df77fccb 100644
--- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt
@@ -114,14 +114,30 @@ fun canSpeak(
rules: ChatRules?,
balances: List,
isStaff: Boolean,
-): Boolean = rules?.speaker.orEmpty().none { it.isUnmet(balances, isStaff) }
+): Boolean = unmetSpeakerRequirement(rules, balances, isStaff) == null
-/** [canSpeak] over the live balance and the live staff flag, with the same de-duplication as [groupAccess]. */
-fun TokenCoordinator.speakerAccess(
+/**
+ * The speaker requirement to name when the viewer may not speak, or null when they may.
+ *
+ * Picks the way iOS's `ConversationGate` does: the first unmet minimum balance, since it is the
+ * only requirement a viewer can act on, falling back to the first unmet rule. A met rule is never
+ * named.
+ */
+fun unmetSpeakerRequirement(
+ rules: ChatRules?,
+ balances: List,
+ isStaff: Boolean,
+): ChatRuleRequirement? {
+ val unmet = rules?.speaker.orEmpty().filter { it.isUnmet(balances, isStaff) }
+ return unmet.firstOrNull { it is ChatRuleRequirement.MinimumBalance } ?: unmet.firstOrNull()
+}
+
+/** [unmetSpeakerRequirement] over the live balance and the live staff flag, de-duplicated like [groupAccess]. */
+fun TokenCoordinator.speakerBlock(
rules: ChatRules?,
isStaff: Flow,
-): Flow = combine(tokenBalances, isStaff) { balances, staff ->
- canSpeak(rules = rules, balances = balances, isStaff = staff)
+): Flow = combine(tokenBalances, isStaff) { balances, staff ->
+ unmetSpeakerRequirement(rules = rules, balances = balances, isStaff = staff)
}
.distinctUntilChanged()
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt
index 4eef219c1e..48ae2bdc58 100644
--- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt
@@ -106,10 +106,14 @@ data class MessagePolicy(
* | A widget (server-sent), viewer may speak | Reply |
* | A widget (server-sent), viewer may not speak | none |
*
+ * Reply is speaking, so on every message it follows the chat's speaker rules ([canSpeak]): a
+ * `never` rule, an unmet minimum balance or an unmet staff requirement takes it away, exactly as
+ * it takes away the composer. The rows above assume the viewer may speak; where they may not, drop
+ * Reply from each and keep the rest (own text keeps Copy, Edit and Delete; another's keeps Copy and
+ * Report; cash keeps Report). Edit and Delete act on the viewer's own message and are not speaking.
+ *
* A widget is the server's, so it has no text to copy, no sender to report, and is never the
- * viewer's to edit or delete. It can still be replied to, which is speaking, so Reply follows the
- * chat's speaker rules ([canSpeak]): a `never` rule, an unmet minimum balance or an unmet staff
- * requirement takes it away, exactly as it would take away the composer.
+ * viewer's to edit or delete: Reply is all it has to lose.
*
* Report follows one rule: anything a participant sent can be reported, and anything the server
* wrote, or that no longer exists, cannot. Your own messages are left out because reporting one is
@@ -121,8 +125,8 @@ data class MessagePolicy(
*
* @param canPost whether the viewer may post in this chat. False for a viewer outside a group,
* including one who has left it and still has messages of their own in the transcript.
- * @param canSpeak whether the chat's speaker rules let the viewer speak (see [canSpeak]). Applied to
- * a widget only, the one message whose Reply depends on it.
+ * @param canSpeak whether the chat's speaker rules let the viewer speak (see [canSpeak]). Gates
+ * Reply on every message. A direct message has no rules and always speaks.
*/
fun resolveCapabilities(
message: ChatMessage,
@@ -165,7 +169,7 @@ private fun resolveForParticipant(
// transcript that records it.
if (contents.any { it is MessageContent.Cash }) {
return buildSet {
- add(MessageCapability.Reply)
+ if (canSpeak) add(MessageCapability.Reply)
if (!message.isFromSelf) add(MessageCapability.Report)
}
}
@@ -185,7 +189,7 @@ private fun resolveForParticipant(
// Media carries no text, and this change edits text only. Not covered by the shared table;
// revisit when media messages actually ship.
if (hasText) add(MessageCapability.Copy)
- add(MessageCapability.Reply)
+ if (canSpeak) add(MessageCapability.Reply)
if (message.isFromSelf) {
if (hasText) add(MessageCapability.Edit)
add(MessageCapability.Delete)
@@ -226,7 +230,8 @@ private fun Duration?.stillOpen(sentAt: Instant, now: Instant): Boolean =
/**
* Whether [message] may be reacted to.
*
- * [canSpeak] applies to a widget alone, like the same parameter of [resolveCapabilities].
+ * A reaction is speaking, so [canSpeak] gates it on every message, like Reply in
+ * [resolveCapabilities]. A direct message has no rules and always speaks.
*
* Reactions have no edit/delete-style windows and no report-only carve-out: anyone's message is
* reactable, own or another participant's, text or cash. Only two things rule a message out —
@@ -241,7 +246,5 @@ fun canReact(message: ChatMessage, canSpeak: Boolean = true): Boolean {
if (contents.any { it is MessageContent.Deleted }) return false
if (message.eventSequence == 0L) return false
if (contents.all { it is MessageContent.System }) return false
- // A widget is reactable only where the viewer may speak; see [canSpeak].
- if (contents.all { it is MessageContent.Widget }) return canSpeak
- return true
+ return canSpeak
}
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt
index 16cf1019ad..b53e9f58be 100644
--- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt
@@ -371,4 +371,29 @@ class GroupAccessTest {
assertEquals(false, canSpeak(both, emptyList(), isStaff = true))
assertEquals(true, canSpeak(both, listOf(held(1, "BadBoys", 600.0)), isStaff = true))
}
+
+ @Test
+ fun `the unmet speaker requirement names a balance before a staff or never rule`() {
+ val bar = ChatRuleRequirement.MinimumBalance(Fiat(500.0), listOf(badBoys))
+ val rules = speaker(ChatRuleRequirement.Never, ChatRuleRequirement.Staff, bar)
+ assertEquals(bar, unmetSpeakerRequirement(rules, emptyList(), isStaff = false))
+ assertEquals(
+ ChatRuleRequirement.Never,
+ unmetSpeakerRequirement(rules, listOf(held(1, "BadBoys", 600.0)), isStaff = true),
+ )
+ }
+
+ @Test
+ fun `a met speaker rule is never named`() {
+ val bar = ChatRuleRequirement.MinimumBalance(Fiat(500.0), listOf(badBoys))
+ val rules = speaker(bar, ChatRuleRequirement.Staff)
+ assertEquals(
+ ChatRuleRequirement.Staff,
+ unmetSpeakerRequirement(rules, listOf(held(1, "BadBoys", 600.0)), isStaff = false),
+ )
+ assertEquals(
+ null,
+ unmetSpeakerRequirement(rules, listOf(held(1, "BadBoys", 600.0)), isStaff = true),
+ )
+ }
}
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt
index bfe456ec50..ff65f6edf8 100644
--- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt
@@ -143,6 +143,62 @@ class MessageCapabilityTest {
assertEquals(emptySet(), resolveCapabilities(widgetMessage, canSpeak = false))
}
+ @Test
+ fun `where the viewer may speak, text and cash keep Reply`() {
+ assertEquals(
+ setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report),
+ resolveCapabilities(text(isFromSelf = false), canSpeak = true),
+ )
+ assertEquals(
+ setOf(MessageCapability.Reply, MessageCapability.Report),
+ resolveCapabilities(cash(isFromSelf = false), canSpeak = true),
+ )
+ }
+
+ @Test
+ fun `where the viewer may not speak, another's text loses Reply and keeps Copy and Report`() {
+ assertEquals(
+ setOf(MessageCapability.Copy, MessageCapability.Report),
+ resolveCapabilities(text(isFromSelf = false), canSpeak = false),
+ )
+ }
+
+ @Test
+ fun `where the viewer may not speak, own text keeps Copy, Edit and Delete but not Reply`() {
+ assertEquals(
+ setOf(MessageCapability.Copy, MessageCapability.Edit, MessageCapability.Delete),
+ resolveCapabilities(text(), now = sentAt, canSpeak = false),
+ )
+ }
+
+ @Test
+ fun `where the viewer may not speak, cash loses Reply and keeps Report`() {
+ assertEquals(
+ setOf(MessageCapability.Report),
+ resolveCapabilities(cash(isFromSelf = false), canSpeak = false),
+ )
+ assertEquals(emptySet(), resolveCapabilities(cash(), canSpeak = false))
+ }
+
+ @Test
+ fun `a viewer with no speaker rules, as in a direct message, is unaffected`() {
+ // canSpeak defaults true: a DM has no rules to fail.
+ assertEquals(
+ setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report),
+ resolveCapabilities(text(isFromSelf = false)),
+ )
+ assertEquals(true, canReact(text(isFromSelf = false)))
+ assertEquals(true, canReact(cash(isFromSelf = false)))
+ }
+
+ @Test
+ fun `text and cash are not reactable where the viewer may not speak`() {
+ assertEquals(false, canReact(text(), canSpeak = false))
+ assertEquals(false, canReact(text(isFromSelf = false), canSpeak = false))
+ assertEquals(false, canReact(cash(), canSpeak = false))
+ assertEquals(false, canReact(cash(isFromSelf = false), canSpeak = false))
+ }
+
@Test
fun `a widget offers nothing to a viewer who cannot post`() {
assertEquals(emptySet(), resolveCapabilities(widgetMessage, canPost = false))