From b1fe39e8aff58a360f88af83eff0e135bac09f18 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 13:55:25 -0400 Subject: [PATCH] fix(chat): read speaker rules from chat metadata for every chat type The speaker rules were read from the group subject only, and DMs were treated as unrestricted. The Flipcash welcome chat is a DM that carries a never rule in its metadata, so its composer stayed and the send button showed "Send a Tip". Another session found the DM gap. Rules now come from observeMetadata for any chat; a DM without rules is still unblocked. The never pill also draws the design's 0x1E1E1E fill at 0.46 alpha over the blur. --- .../app/messenger/internal/ChatViewModel.kt | 16 ++++++++++------ .../screens/components/SpeakerGateBar.kt | 11 ++++++++++- .../messenger/internal/ChatViewModelStateTest.kt | 16 ++++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) 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 51eec541d..9826c4194 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 @@ -463,7 +463,7 @@ internal class ChatViewModel @Inject constructor( * Mirrors iOS `ConversationGatePresentation.readOnly`. */ val isReadOnlySpeaker: Boolean - get() = subject is ChatSubject.Group && !isOutsideGroup && !replacesComposer && speakerBlock != null + get() = subject != null && !isOutsideGroup && !replacesComposer && speakerBlock != null /** * Whether the gate stands where the composer does. Every viewer outside the group, eligible @@ -1671,15 +1671,19 @@ internal class ChatViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnGroupAccessResolved(it)) } .launchIn(viewModelScope) - // The speaker rules, on the same terms as the gate above. A DM has none, so it speaks. - stateFlow.map { it.subject as? ChatSubject.Group } + // The speaker rules, read from the chat's metadata for every chat type: the Flipcash welcome + // chat is a DM that carries a `never` rule. A chat without rules, as most DMs are, speaks. + stateFlow.mapNotNull { it.chatId } .distinctUntilChanged() - .flatMapLatest { group -> - if (group == null) { + .flatMapLatest { chatCoordinator.observeMetadata(it) } + .map { it?.metadata?.rules } + .distinctUntilChanged() + .flatMapLatest { rules -> + if (rules == null) { flowOf(null) } else { tokenCoordinator.speakerBlock( - rules = group.rules, + rules = rules, isStaff = userFlags.resolvedFlags.map { it.isStaff.effectiveValue }, ) } 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 b54b5508e..e54fe506f 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 @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.screens.components +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -10,6 +11,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -37,6 +40,9 @@ import dev.chrisbanes.haze.rememberHazeState * Copy follows iOS `ConversationGatePanel`: `never` names the sender, a balance states the amount * as a sending requirement, staff names who may send. */ +/** The design's fill, drawn over the blur (node 10588:1969), matching iOS's tint. */ +private val PillTint = Color(0xFF1E1E1E).copy(alpha = 0.46f) + @Composable internal fun SpeakerGateBar( /** The requirement standing in the viewer's way. */ @@ -48,6 +54,7 @@ internal fun SpeakerGateBar( /** Off only for a Robolectric render, whose native runtime cannot compile the blur shader. */ blurEnabled: Boolean = true, ) { + val shape = CodeTheme.shapes.medium val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) val text = when (requirement) { ChatRuleRequirement.Never -> stringResource(R.string.label_chatGate_speakerNever) @@ -74,8 +81,10 @@ internal fun SpeakerGateBar( .padding(horizontal = CodeTheme.dimens.grid.x6.coerceAtLeast(CodeTheme.dimens.inset)) .padding(vertical = CodeTheme.dimens.grid.x3) .height(50.dp) - .border(CodeTheme.dimens.border, CodeTheme.colors.divider, CodeTheme.shapes.medium) + .border(CodeTheme.dimens.border, CodeTheme.colors.divider, shape) + .clip(shape) .then(if (blurEnabled) Modifier.hazeBlur(HazeInput.Sources(hazeState), material) else Modifier) + .background(PillTint) .padding(horizontal = CodeTheme.dimens.grid.x3), contentAlignment = Alignment.Center, ) { 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 6002939dc..4b8759adf 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 @@ -570,4 +570,20 @@ class ChatViewModelStateTest { assertTrue(state.canSpeak) assertFalse(state.isReadOnlySpeaker) } + + @Test + fun `a DM carrying a never rule is read-only with the never panel`() { + // The Flipcash welcome chat: a DM whose metadata holds the rule. + val state = ChatViewModel.State(subject = dm, speakerBlock = ChatRuleRequirement.Never) + assertTrue(state.isReadOnlySpeaker) + assertFalse(state.canSpeak) + assertEquals(ChatRuleRequirement.Never, state.speakerBlock) + } + + @Test + fun `a DM with no rules keeps the composer`() { + val state = ChatViewModel.State(subject = dm, speakerBlock = null) + assertFalse(state.isReadOnlySpeaker) + assertTrue(state.canSpeak) + } }