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