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() }
+ }
}