diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 7bd13b3c05..7f26433e07 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -918,6 +918,8 @@ Minimum Balance to Send Messages: %1$s of %2$s Minimum Balance to Send Messages: %1$s Only Flipcash staff can send messages here + Only the creator can send messages + Update Flipcash to send messages Buy More %1$s Join Chat Leave Chat 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 931154b208..28bd1c5fad 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 @@ -6,6 +6,7 @@ import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.MediaItem import com.flipcash.shared.chat.models.LinkCard +import com.getcode.opencode.model.core.ID /** * What the messenger screen is a conversation *with*. @@ -83,6 +84,8 @@ sealed interface ChatSubject { val memberCount: Long, val rules: ChatRules?, val isMember: Boolean?, + /** The group's creator (`ChatMetadata.creator`); null when the metadata did not carry one. */ + val creator: ID? = null, ) : ChatSubject { override val title: String get() = groupTitle.orEmpty() override val subtitle: String? get() = null 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 9826c41940..8a9c60ceb9 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 @@ -82,6 +82,7 @@ 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.SpeakerBlock import com.flipcash.shared.chat.speakerBlock import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.MessagePolicy @@ -364,6 +365,11 @@ internal class ChatViewModel @Inject constructor( * a group does not flash Reply away. */ val speakerBlock: ChatRuleRequirement? = null, + /** + * Whether an unmet speaker rule also takes reactions away. False for `creator` alone, which + * withholds posting (composer, Reply) and nothing else. See [SpeakerBlock]. + */ + val speakerBlocksReactions: Boolean = false, /** * 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 @@ -547,7 +553,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 OnSpeakerBlockResolved(val block: ChatRuleRequirement?) : Event + data class OnSpeakerBlockResolved(val block: SpeakerBlock?) : Event /** The gate's "Join Chat" button. */ data object JoinChat : Event @@ -786,6 +792,9 @@ internal class ChatViewModel @Inject constructor( */ private val viewerCanSpeak = stateFlow.map { it.canSpeak }.distinctUntilChanged() + /** Whether the speaker rules leave reactions open; wider than [viewerCanSpeak], see `speakerBlocksReactions`. */ + private val viewerCanReact = stateFlow.map { !it.speakerBlocksReactions }.distinctUntilChanged() + /** * Live reaction overrides for the open chat — see [ReactionOperations.observeChatReactions]. * A message missing here falls back to `MessageReactions.from(message.reactions)` in @@ -877,8 +886,8 @@ internal class ChatViewModel @Inject constructor( pendingMutations, messagePolicy, senderProfiles, - combine(viewerCanPost, viewerCanSpeak, ::Pair), - ) { pagingData, mutations, policy, profiles, (canPost, canSpeak) -> + combine(viewerCanPost, viewerCanSpeak, viewerCanReact, ::Triple), + ) { pagingData, mutations, policy, profiles, (canPost, canSpeak, canReactToMessages) -> pagingData.flatMap { stored -> val message = stored.applying(mutations[stored.messageId]) message.content.flatMapIndexed { index, content -> @@ -995,7 +1004,7 @@ internal class ChatViewModel @Inject constructor( // `splitAroundLinkCard` keeps these pills on the last row only. reactionPills = storedReactions.pills, selfReactions = storedReactions.selfReactions, - canReact = canReact(message, canSpeak = canPost && canSpeak), + canReact = canReact(message, canSpeak = canPost && canReactToMessages), undecryptableHint = undecryptableHint( encryption = message.encryption, isFromSelf = message.isFromSelf, @@ -1676,15 +1685,20 @@ internal class ChatViewModel @Inject constructor( stateFlow.mapNotNull { it.chatId } .distinctUntilChanged() .flatMapLatest { chatCoordinator.observeMetadata(it) } - .map { it?.metadata?.rules } + // The creator rides along with the rules: a `creator` rule is met by comparing the viewer + // to it, and the same metadata carries both. + .map { it?.metadata?.let { metadata -> metadata.rules to metadata.creator } } .distinctUntilChanged() - .flatMapLatest { rules -> + .flatMapLatest { rulesAndCreator -> + val rules = rulesAndCreator?.first if (rules == null) { flowOf(null) } else { tokenCoordinator.speakerBlock( rules = rules, isStaff = userFlags.resolvedFlags.map { it.isStaff.effectiveValue }, + viewerId = userManager.accountId, + creatorId = rulesAndCreator.second, ) } } @@ -2731,6 +2745,7 @@ internal class ChatViewModel @Inject constructor( memberCount = metadata.rosterSummary.memberCount, rules = metadata.rules, isMember = event.membership.isMember, + creator = metadata.creator, ), chatType = ChatType.GROUP, resolveState = ResolveState.Resolved, @@ -2739,7 +2754,10 @@ 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.OnSpeakerBlockResolved -> { state -> state.copy(speakerBlock = event.block) } + is Event.OnSpeakerBlockResolved -> { state -> state.copy( + speakerBlock = event.block?.requirement, + speakerBlocksReactions = event.block?.reactionsBlocked == true, + ) } 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/SpeakerGateBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SpeakerGateBar.kt index e54fe506fe..48ce9e466c 100644 --- 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 @@ -34,7 +34,7 @@ import dev.chrisbanes.haze.rememberHazeState * * 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 + * with no input, attach or send button and no call to action: none of the requirements has an * action the viewer could take from here. * * Copy follows iOS `ConversationGatePanel`: `never` names the sender, a balance states the amount @@ -59,6 +59,8 @@ internal fun SpeakerGateBar( val text = when (requirement) { ChatRuleRequirement.Never -> stringResource(R.string.label_chatGate_speakerNever) ChatRuleRequirement.Staff -> stringResource(R.string.subtitle_chatGate_speakerStaffOnly) + ChatRuleRequirement.Creator -> stringResource(R.string.subtitle_chatGate_speakerCreatorOnly) + ChatRuleRequirement.UnsupportedSpeakerRule -> stringResource(R.string.subtitle_chatGate_speakerUnsupported) is ChatRuleRequirement.MinimumBalance -> if (currencyName != null) { stringResource( 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 4b8759adfb..6b7cacbb25 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 @@ -13,6 +13,7 @@ import com.flipcash.shared.chat.ChatDraftReply import com.flipcash.shared.chat.ChatDraftSnapshot import com.flipcash.shared.chat.ChatDraftSnippet import com.flipcash.shared.chat.GroupAccess +import com.flipcash.shared.chat.resolveSpeakerBlock import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.chatDraftOf @@ -586,4 +587,61 @@ class ChatViewModelStateTest { assertFalse(state.isReadOnlySpeaker) assertTrue(state.canSpeak) } + + // -- creator / unsupported: posting is gated, reactions follow the unmet rule -- + + private val creatorId = List(32) { 7 } + private val viewerId = List(32) { 9 } + + /** The state the reducer builds from a resolved [SpeakerBlock], as `OnSpeakerBlockResolved` does. */ + private fun stateFor(vararg speaker: ChatRuleRequirement, isStaff: Boolean = false): ChatViewModel.State { + val block = resolveSpeakerBlock( + rules = ChatRules(listener = emptyList(), speaker = speaker.toList()), + balances = emptyList(), + isStaff = isStaff, + viewerId = viewerId, + creatorId = creatorId, + ) + return ChatViewModel.State( + subject = group(isMember = true, rules = null), + speakerBlock = block?.requirement, + speakerBlocksReactions = block?.reactionsBlocked == true, + ) + } + + @Test + fun `a creator rule replaces the composer and withholds Reply but leaves reactions on`() { + val state = stateFor(ChatRuleRequirement.Creator) + + assertTrue(state.isReadOnlySpeaker) + assertFalse(state.canSpeak) + assertFalse(state.speakerBlocksReactions) + assertEquals(ChatRuleRequirement.Creator, state.speakerBlock) + } + + @Test + fun `an unsupported rule replaces the composer even for staff and leaves reactions on`() { + val state = stateFor(ChatRuleRequirement.UnsupportedSpeakerRule, isStaff = true) + + assertTrue(state.isReadOnlySpeaker) + assertFalse(state.canSpeak) + assertFalse(state.speakerBlocksReactions) + } + + @Test + fun `creator plus staff withholds reactions as well as posting`() { + val state = stateFor(ChatRuleRequirement.Creator, ChatRuleRequirement.Staff) + + assertFalse(state.canSpeak) + assertTrue(state.speakerBlocksReactions) + } + + @Test + fun `no unmet speaker rule leaves the composer and reactions alone`() { + val state = stateFor() + + assertFalse(state.isReadOnlySpeaker) + assertTrue(state.canSpeak) + assertFalse(state.speakerBlocksReactions) + } } 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 index 6a14ed2314..9b939a9e53 100644 --- 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 @@ -35,12 +35,21 @@ class SpeakerGateBarScreenshotTest { val composeRule = createAndroidComposeRule() @Test - fun rendersNeverPanel() { + fun rendersNeverPanel() = render(ChatRuleRequirement.Never, "speaker_gate_never.png") + + @Test + fun rendersCreatorPanel() = render(ChatRuleRequirement.Creator, "android-creator-composer.png") + + @Test + fun rendersUnsupportedPanel() = + render(ChatRuleRequirement.UnsupportedSpeakerRule, "android-unsupported-composer.png") + + private fun render(requirement: ChatRuleRequirement, fileName: String) { composeRule.mainClock.autoAdvance = false composeRule.setContent { FlipcashPreview(showBackground = true) { SpeakerGateBar( - requirement = ChatRuleRequirement.Never, + requirement = requirement, hazeState = rememberHazeState(), currencyName = null, modifier = Modifier.width(402.dp), @@ -52,7 +61,7 @@ class SpeakerGateBarScreenshotTest { 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") + val file = File("build/screenshots").apply { mkdirs() }.resolve(fileName) 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 d2df77fccb..f2ed66815f 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 @@ -3,6 +3,8 @@ package com.flipcash.shared.chat import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules +import com.flipcash.services.models.chat.blocksReactions +import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.TokenWithBalance import com.getcode.opencode.model.financial.sum @@ -114,7 +116,9 @@ fun canSpeak( rules: ChatRules?, balances: List, isStaff: Boolean, -): Boolean = unmetSpeakerRequirement(rules, balances, isStaff) == null + viewerId: ID? = null, + creatorId: ID? = null, +): Boolean = unmetSpeakerRequirement(rules, balances, isStaff, viewerId, creatorId) == null /** * The speaker requirement to name when the viewer may not speak, or null when they may. @@ -127,17 +131,52 @@ 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() + viewerId: ID? = null, + creatorId: ID? = null, +): ChatRuleRequirement? = resolveSpeakerBlock(rules, balances, isStaff, viewerId, creatorId)?.requirement + +/** + * What stands between the viewer and speaking: the requirement to name, and whether any unmet + * rule also takes reactions away. + * + * Posting (the composer and Reply) is withheld whenever a block exists. Reactions are withheld + * only when an unmet rule has [blocksReactions], so `creator` alone leaves them on while + * `creator` + `staff` turns them off. [reactionsBlocked] looks at every unmet rule, not just the + * named one, because the named one is chosen for what the viewer can act on. + */ +data class SpeakerBlock( + val requirement: ChatRuleRequirement, + val reactionsBlocked: Boolean, +) + +/** The [SpeakerBlock] for the viewer, or null when every speaker rule holds. */ +fun resolveSpeakerBlock( + rules: ChatRules?, + balances: List, + isStaff: Boolean, + viewerId: ID? = null, + creatorId: ID? = null, +): SpeakerBlock? { + val unmet = rules?.speaker.orEmpty().filter { it.isUnmet(balances, isStaff, viewerId, creatorId) } + val named = unmet.firstOrNull { it is ChatRuleRequirement.MinimumBalance } ?: unmet.firstOrNull() + ?: return null + return SpeakerBlock(requirement = named, reactionsBlocked = unmet.any { it.blocksReactions }) } -/** [unmetSpeakerRequirement] over the live balance and the live staff flag, de-duplicated like [groupAccess]. */ +/** + * [resolveSpeakerBlock] over the live balance and the live staff flag, de-duplicated like [groupAccess]. + * + * [viewerId] and [creatorId] are plain values: who the viewer is and who made the chat do not + * change while the chat is open. A null [creatorId] (the chat's metadata did not carry one) leaves + * a `creator` rule unmet. + */ fun TokenCoordinator.speakerBlock( rules: ChatRules?, isStaff: Flow, -): Flow = combine(tokenBalances, isStaff) { balances, staff -> - unmetSpeakerRequirement(rules = rules, balances = balances, isStaff = staff) + viewerId: ID? = null, + creatorId: ID? = null, +): Flow = combine(tokenBalances, isStaff) { balances, staff -> + resolveSpeakerBlock(rules = rules, balances = balances, isStaff = staff, viewerId = viewerId, creatorId = creatorId) } .distinctUntilChanged() @@ -148,6 +187,8 @@ fun TokenCoordinator.speakerBlock( private fun ChatRuleRequirement.isUnmet( balances: List, isStaff: Boolean, + viewerId: ID? = null, + creatorId: ID? = null, ): Boolean { // Keyed by bytes, not by the key object: `class Mint(bytes) : PublicKey(bytes)` // (libs/encryption/keys/.../Mint.kt), so the `PublicKey`s in `mints` are not `Mint`s and @@ -180,5 +221,11 @@ private fun ChatRuleRequirement.isUnmet( // Nobody satisfies it. The server sends it as a speaker rule only, where it is what // makes a chat read-only for everyone; as a listener rule it would lock everyone out. ChatRuleRequirement.Never -> true + // Met only by the chat's creator. Staff get no bypass. With no creator on the metadata, or + // no viewer, nobody can be shown to be the creator, so it stays closed. As a listener rule + // (the server never sends one) the ids are not passed and it is unmet. + ChatRuleRequirement.Creator -> creatorId == null || viewerId == null || viewerId != creatorId + // A rule this build cannot read: unmet for everyone, staff included. + ChatRuleRequirement.UnsupportedSpeakerRule -> true } } 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 48ae2bdc58..f8bd247111 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 @@ -230,8 +230,10 @@ private fun Duration?.stillOpen(sentAt: Instant, now: Instant): Boolean = /** * Whether [message] may be reacted to. * - * A reaction is speaking, so [canSpeak] gates it on every message, like Reply in - * [resolveCapabilities]. A direct message has no rules and always speaks. + * Reply is posting and follows any unmet speaker rule ([resolveCapabilities]). A reaction is not + * always: [canSpeak] is whether the speaker rules leave reactions open, which is false only when an + * unmet rule has `blocksReactions` (balance, staff, never). A `creator` or unsupported rule alone + * keeps reactions on. A direct message has no rules and always reacts. * * 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 — 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 b53e9f58be..d7bda107c6 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 @@ -100,6 +100,18 @@ class GroupAccessTest { assertEquals(GroupAccess.Eligible, access) } + @Test + fun `a creator requirement among listener rules is never satisfied`() { + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(ChatRuleRequirement.Creator), + balances = emptyList(), + isStaff = true, + ) + + assertEquals(GroupAccess.Blocked(ChatRuleRequirement.Creator), access) + } + @Test fun `too little of the named token is blocked by that requirement`() { val requirement = ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys)) @@ -396,4 +408,82 @@ class GroupAccessTest { unmetSpeakerRequirement(rules, listOf(held(1, "BadBoys", 600.0)), isStaff = true), ) } + + // -- Speaker rules: creator and unsupported -- + + private val creatorId: List = List(32) { 7 } + private val otherId: List = List(32) { 9 } + + private fun block( + rules: ChatRules, + viewerId: List? = otherId, + creator: List? = creatorId, + isStaff: Boolean = false, + ) = resolveSpeakerBlock(rules, emptyList(), isStaff, viewerId, creator) + + @Test + fun `the creator meets a creator rule and may post`() { + val rules = speaker(ChatRuleRequirement.Creator) + + assertEquals(null, block(rules, viewerId = creatorId)) + assertEquals(true, canSpeak(rules, emptyList(), false, creatorId, creatorId)) + } + + @Test + fun `a member who is not the creator is blocked by a creator rule, staff included`() { + val rules = speaker(ChatRuleRequirement.Creator) + + assertEquals(ChatRuleRequirement.Creator, block(rules)?.requirement) + assertEquals(ChatRuleRequirement.Creator, block(rules, isStaff = true)?.requirement) + } + + @Test + fun `a creator rule is unmet when the chat carries no creator`() { + val rules = speaker(ChatRuleRequirement.Creator) + + assertEquals(ChatRuleRequirement.Creator, block(rules, creator = null)?.requirement) + // Not even a viewer who would have matched: there is nothing to match against. + assertEquals(ChatRuleRequirement.Creator, block(rules, viewerId = creatorId, creator = null)?.requirement) + assertEquals(ChatRuleRequirement.Creator, block(rules, viewerId = null)?.requirement) + } + + @Test + fun `an unsupported speaker rule is unmet for everyone, staff and creator included`() { + val rules = speaker(ChatRuleRequirement.UnsupportedSpeakerRule) + + assertEquals(ChatRuleRequirement.UnsupportedSpeakerRule, block(rules)?.requirement) + assertEquals(ChatRuleRequirement.UnsupportedSpeakerRule, block(rules, isStaff = true)?.requirement) + assertEquals(ChatRuleRequirement.UnsupportedSpeakerRule, block(rules, viewerId = creatorId)?.requirement) + } + + @Test + fun `creator and unsupported withhold posting but leave reactions on`() { + assertEquals(false, block(speaker(ChatRuleRequirement.Creator))?.reactionsBlocked) + assertEquals(false, block(speaker(ChatRuleRequirement.UnsupportedSpeakerRule))?.reactionsBlocked) + } + + @Test + fun `creator plus staff turns reactions off for a non-creator non-staff viewer`() { + val rules = speaker(ChatRuleRequirement.Creator, ChatRuleRequirement.Staff) + + val blocked = block(rules) + assertEquals(ChatRuleRequirement.Creator, blocked?.requirement) + assertEquals(true, blocked?.reactionsBlocked) + } + + @Test + fun `creator plus staff leaves reactions on once the staff rule is met`() { + val rules = speaker(ChatRuleRequirement.Creator, ChatRuleRequirement.Staff) + + val blocked = block(rules, isStaff = true) + assertEquals(ChatRuleRequirement.Creator, blocked?.requirement) + assertEquals(false, blocked?.reactionsBlocked) + } + + @Test + fun `never and an unmet balance withhold reactions`() { + assertEquals(true, block(speaker(ChatRuleRequirement.Never))?.reactionsBlocked) + val balance = ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys)) + assertEquals(true, block(speaker(balance))?.reactionsBlocked) + } } 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 ff65f6edf8..4e2cf7be75 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 @@ -180,6 +180,27 @@ class MessageCapabilityTest { assertEquals(emptySet(), resolveCapabilities(cash(), canSpeak = false)) } + @Test + fun `a creator-gated viewer loses Reply but keeps Copy, Report and own Edit and Delete`() { + // The panel passes canSpeak = false for any unmet speaker rule, creator included. + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Report), + resolveCapabilities(text(isFromSelf = false), canSpeak = false), + ) + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Edit, MessageCapability.Delete), + resolveCapabilities(text(), now = sentAt, canSpeak = false), + ) + } + + @Test + fun `reactions follow the rules' reaction flag, not the posting gate`() { + // canReact's gate is whether the rules leave reactions open (true for a creator-only block), + // so a message stays reactable while Reply is withheld. + assertEquals(true, canReact(text(isFromSelf = false), canSpeak = true)) + assertEquals(false, canReact(text(isFromSelf = false), 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. diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index 245cf244cd..9248d8fb3c 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -352,4 +352,12 @@ sealed interface ChatRuleRequirementSerialized { @Serializable @SerialName("never") data object Never : ChatRuleRequirementSerialized + + @Serializable + @SerialName("creator") + data object Creator : ChatRuleRequirementSerialized + + @Serializable + @SerialName("unsupported_speaker_rule") + data object UnsupportedSpeakerRule : ChatRuleRequirementSerialized } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 72fe465d4b..212742203b 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -474,6 +474,8 @@ private fun ChatRuleRequirement.toSerialized(): ChatRuleRequirementSerialized = ) ChatRuleRequirement.Staff -> ChatRuleRequirementSerialized.Staff ChatRuleRequirement.Never -> ChatRuleRequirementSerialized.Never + ChatRuleRequirement.Creator -> ChatRuleRequirementSerialized.Creator + ChatRuleRequirement.UnsupportedSpeakerRule -> ChatRuleRequirementSerialized.UnsupportedSpeakerRule } private fun ChatRulesSerialized.toDomain(): ChatRules = ChatRules( @@ -491,6 +493,8 @@ private fun ChatRuleRequirementSerialized.toDomain(): ChatRuleRequirement = when ) ChatRuleRequirementSerialized.Staff -> ChatRuleRequirement.Staff ChatRuleRequirementSerialized.Never -> ChatRuleRequirement.Never + ChatRuleRequirementSerialized.Creator -> ChatRuleRequirement.Creator + ChatRuleRequirementSerialized.UnsupportedSpeakerRule -> ChatRuleRequirement.UnsupportedSpeakerRule } private fun SocialAccount.toSerialized(): SocialAccountSerialized = when (this) { diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index 6b854ac127..ea4acf46e8 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -207,6 +207,31 @@ class ChatEntityMapperTest { assertEquals(CurrencyCode.USD, speaker.amount.currencyCode) } + @Test + fun `creator, never and unsupported speaker rules survive the round trip`() { + val metadata = groupMetadata().copy( + rules = ChatRules( + listener = emptyList(), + speaker = listOf( + ChatRuleRequirement.Creator, + ChatRuleRequirement.Never, + ChatRuleRequirement.UnsupportedSpeakerRule, + ), + ), + ) + + val restored = mapper.toMetadata(mapper.toEntity(metadata), members = emptyList(), lastMessage = null) + + assertEquals( + listOf( + ChatRuleRequirement.Creator, + ChatRuleRequirement.Never, + ChatRuleRequirement.UnsupportedSpeakerRule, + ), + restored.rules?.speaker, + ) + } + @Test fun `group identity and roster survive the round trip`() { val entity = mapper.toEntity(groupMetadata()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 772d70b7a8..8341e9c707 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,7 @@ protovalidate-kt = "0.1.3" # 0.3.0 is the first release of either package to ship R8 keep rules for its generated # messages, which is what lets proguard-rules.pro drop its own. ocp-client-protocol = "0.6.0" -flipcash2-client-protocol = "0.14.0" +flipcash2-client-protocol = "0.14.1" # The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's # `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index 00ea3d07a4..631a687d9c 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -260,6 +260,8 @@ internal fun ChatRuleRequirement.asProtoListenerRules(): ChatModel.ListenerRules val builder = ChatModel.ListenerRules.newBuilder() return when (this) { ChatRuleRequirement.Never -> error("ListenerRules has no `never` arm; Never is speaker-only") + ChatRuleRequirement.Creator -> error("ListenerRules has no `creator` arm; Creator is speaker-only") + ChatRuleRequirement.UnsupportedSpeakerRule -> error("UnsupportedSpeakerRule is a client-only speaker rule") is ChatRuleRequirement.MinimumBalance -> builder.setMinimumBalance(asProtoMinimumBalanceRequirement()).build() ChatRuleRequirement.Staff -> builder.setStaff(ChatModel.StaffRequirement.getDefaultInstance()).build() } @@ -271,6 +273,9 @@ internal fun ChatRuleRequirement.asProtoSpeakerRules(): ChatModel.SpeakerRules { is ChatRuleRequirement.MinimumBalance -> builder.setMinimumBalance(asProtoMinimumBalanceRequirement()).build() ChatRuleRequirement.Staff -> builder.setStaff(ChatModel.StaffRequirement.getDefaultInstance()).build() ChatRuleRequirement.Never -> builder.setNever(ChatModel.Never.getDefaultInstance()).build() + ChatRuleRequirement.Creator -> builder.setCreator(ChatModel.CreatorRequirement.getDefaultInstance()).build() + // Rejected, not skipped: there is no wire shape for a rule this build cannot decode. + ChatRuleRequirement.UnsupportedSpeakerRule -> error("UnsupportedSpeakerRule is decode-only; it has no wire form") } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 47e5fc22a2..3f17b074be 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -514,7 +514,7 @@ internal fun ChatModel.RosterUpdate.toRosterChangeOrNull( internal fun ChatModel.Rules.toChatRules(): ChatRules { return ChatRules( listener = listenerList.mapNotNull { it.toRuleRequirementOrNull() }, - speaker = speakerList.mapNotNull { it.toRuleRequirementOrNull() }, + speaker = speakerList.map { it.toSpeakerRequirement() }, ) } @@ -535,10 +535,16 @@ internal fun ChatModel.SpeakerRules.toRuleRequirementOrNull(): ChatRuleRequireme ChatModel.SpeakerRules.KindCase.MINIMUM_BALANCE -> minimumBalance.toRuleRequirement() ChatModel.SpeakerRules.KindCase.STAFF -> ChatRuleRequirement.Staff ChatModel.SpeakerRules.KindCase.NEVER -> ChatRuleRequirement.Never + ChatModel.SpeakerRules.KindCase.CREATOR -> ChatRuleRequirement.Creator else -> null } } +// Unlike listener rules, an unrecognised speaker rule is kept: RULE_NOT_SET is also what a case from +// a newer contract decodes to, and dropping it would read as "no requirement" and open posting. +internal fun ChatModel.SpeakerRules.toSpeakerRequirement(): ChatRuleRequirement = + toRuleRequirementOrNull() ?: ChatRuleRequirement.UnsupportedSpeakerRule + internal fun ChatModel.MinimumBalanceRequirement.toRuleRequirement(): ChatRuleRequirement.MinimumBalance { return ChatRuleRequirement.MinimumBalance( amount = amount.toFiat(), diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt index ede78b27e8..fa9bc79bb3 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt @@ -38,4 +38,35 @@ sealed interface ChatRuleRequirement { /** Nobody may take the action (`chat.v1.Never`). Only the server sends it, and only as a speaker rule. */ data object Never : ChatRuleRequirement + + /** + * Only the chat's creator (`ChatMetadata.creator`, `chat.v1.Metadata.creator`) may take the + * action (`chat.v1.CreatorRequirement`). Speaker-only: `ListenerRules` has no creator arm. The + * requirement carries no id; it is met when the viewer's user id equals the chat's creator. + */ + data object Creator : ChatRuleRequirement + + /** + * A speaker rule this build cannot decode: the oneof was unset, or carries a case added by a + * newer contract. Decoding it as a requirement rather than dropping it keeps the chat closed; + * dropping it would read as "no requirement" and let everyone post. Nobody satisfies it, staff + * included. Client-only: never encoded back to the wire, and never a listener rule. + */ + data object UnsupportedSpeakerRule : ChatRuleRequirement } + +/** + * Whether leaving this requirement unmet also withholds reactions, not only posting. + * + * [ChatRuleRequirement.Creator] and [ChatRuleRequirement.UnsupportedSpeakerRule] gate posting (the + * composer and Reply) and nothing else: any member can still react, copy and report. The rest also + * withhold reactions. + */ +val ChatRuleRequirement.blocksReactions: Boolean + get() = when (this) { + is ChatRuleRequirement.MinimumBalance, + ChatRuleRequirement.Staff, + ChatRuleRequirement.Never -> true + ChatRuleRequirement.Creator, + ChatRuleRequirement.UnsupportedSpeakerRule -> false + } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/WidgetAndNeverMappingTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/WidgetAndNeverMappingTest.kt index 8356ffe785..fa0194e247 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/WidgetAndNeverMappingTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/WidgetAndNeverMappingTest.kt @@ -4,11 +4,14 @@ import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel import com.codeinc.flipcash.gen.common.v1.Common import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.blocksReactions import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.WidgetContent import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue class WidgetAndNeverMappingTest { @@ -52,4 +55,44 @@ class WidgetAndNeverMappingTest { assertEquals(ChatModel.SpeakerRules.KindCase.NEVER, proto.kindCase) assertEquals(ChatRuleRequirement.Never, proto.toRuleRequirementOrNull()) } + + @Test + fun `speaker creator maps to Creator and back`() { + val proto = ChatRuleRequirement.Creator.asProtoSpeakerRules() + + assertEquals(ChatModel.SpeakerRules.KindCase.CREATOR, proto.kindCase) + assertEquals(ChatRuleRequirement.Creator, proto.toRuleRequirementOrNull()) + } + + @Test + fun `an unset speaker rule is kept as unsupported, not dropped`() { + val rules = ChatModel.Rules.newBuilder() + .addSpeaker(ChatModel.SpeakerRules.getDefaultInstance()) + .addSpeaker(ChatModel.SpeakerRules.newBuilder().setStaff(ChatModel.StaffRequirement.getDefaultInstance())) + .build() + + assertEquals( + listOf(ChatRuleRequirement.UnsupportedSpeakerRule, ChatRuleRequirement.Staff), + rules.toChatRules().speaker, + ) + } + + @Test + fun `unsupported speaker rule has no wire form`() { + assertFailsWith { ChatRuleRequirement.UnsupportedSpeakerRule.asProtoSpeakerRules() } + assertFailsWith { ChatRuleRequirement.UnsupportedSpeakerRule.asProtoListenerRules() } + } + + @Test + fun `creator and unsupported block posting but not reactions`() { + assertFalse(ChatRuleRequirement.Creator.blocksReactions) + assertFalse(ChatRuleRequirement.UnsupportedSpeakerRule.blocksReactions) + assertTrue(ChatRuleRequirement.Never.blocksReactions) + assertTrue(ChatRuleRequirement.Staff.blocksReactions) + } + + @Test + fun `creator is speaker-only and is rejected as a listener rule`() { + assertFailsWith { ChatRuleRequirement.Creator.asProtoListenerRules() } + } }