From a91ae4628211f21203911f5bc34ded882725636d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 13:49:13 -0400 Subject: [PATCH 1/3] feat(chat): handle the creator speaker rule and keep unrecognised speaker rules closed Adds ChatRuleRequirement.Creator (SpeakerRules.creator, speaker-only) and ChatRuleRequirement.UnsupportedSpeakerRule for an unset or future speaker case, which was previously dropped and so read as no requirement. Both block posting only (blocksReactions is false). Unsupported is decode-only and rejected on encode; both persist. --- .../com/flipcash/shared/chat/GroupAccess.kt | 3 ++ .../flipcash/shared/chat/GroupAccessTest.kt | 12 ++++++ .../converters/ChatTypeConverters.kt | 8 ++++ .../sources/mapper/chat/ChatEntityMapper.kt | 4 ++ .../mapper/chat/ChatEntityMapperTest.kt | 25 +++++++++++ .../network/extensions/LocalToProtobuf.kt | 5 +++ .../network/extensions/ProtobufToLocal.kt | 8 +++- .../services/models/chat/ChatRules.kt | 31 +++++++++++++ .../extensions/WidgetAndNeverMappingTest.kt | 43 +++++++++++++++++++ 9 files changed, 138 insertions(+), 1 deletion(-) 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..cc3cb21272 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 @@ -180,5 +180,8 @@ 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 + // Speaker-only; nobody satisfies either as a listener rule. + ChatRuleRequirement.Creator, + ChatRuleRequirement.UnsupportedSpeakerRule -> true } } 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..4cfc82eb3e 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)) 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/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() } + } } From 1913b9ad3de6a7ac2b783fe59d9315bed13573a3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 13:59:50 -0400 Subject: [PATCH 2/3] chore(deps): bump flipcash2-client-protocol to 0.14.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From c53eff2b0230a6b519315d0f753a8714baed54bb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 14:11:04 -0400 Subject: [PATCH 3/3] feat(chat): gate posting on the creator and unsupported speaker rules Creator is met only when the viewer's account id equals the chat's creator; a chat with no creator id stays closed and staff get no bypass. UnsupportedSpeakerRule is unmet for everyone. Either replaces the composer and withholds Reply. Reactions are withheld only when an unmet rule has blocksReactions, so creator alone leaves them on and creator plus staff turns them off. SpeakerGateBar gets copy for both. --- .../core/src/main/res/values/strings.xml | 2 + .../app/messenger/internal/ChatSubject.kt | 3 + .../app/messenger/internal/ChatViewModel.kt | 32 ++++++-- .../screens/components/SpeakerGateBar.kt | 4 +- .../internal/ChatViewModelStateTest.kt | 58 ++++++++++++++ .../internal/SpeakerGateBarScreenshotTest.kt | 15 +++- .../com/flipcash/shared/chat/GroupAccess.kt | 62 ++++++++++++--- .../flipcash/shared/chat/MessageCapability.kt | 6 +- .../flipcash/shared/chat/GroupAccessTest.kt | 78 +++++++++++++++++++ .../shared/chat/MessageCapabilityTest.kt | 21 +++++ 10 files changed, 259 insertions(+), 22 deletions(-) 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 cc3cb21272..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,8 +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 - // Speaker-only; nobody satisfies either as a listener rule. - ChatRuleRequirement.Creator, + // 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 4cfc82eb3e..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 @@ -408,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.