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))