Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,10 @@
<string name="subtitle_chatGate_minimumBalance">Minimum Balance: %1$s of %2$s</string>
<string name="subtitle_chatGate_minimumBalance_anyToken">Minimum Balance: %1$s</string>
<string name="subtitle_chatGate_staffOnly">This chat is open to Flipcash staff only.</string>
<string name="label_chatGate_speakerNever">Only Flipcash can send messages</string>
<string name="subtitle_chatGate_speakerMinimumBalance">Minimum Balance to Send Messages: %1$s of %2$s</string>
<string name="subtitle_chatGate_speakerMinimumBalance_anyToken">Minimum Balance to Send Messages: %1$s</string>
<string name="subtitle_chatGate_speakerStaffOnly">Only Flipcash staff can send messages here</string>
<string name="action_buyMoreToken">Buy More %1$s</string>
<string name="action_joinChat">Join Chat</string>
<string name="title_leaveChat">Leave Chat</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,9 @@ import com.flipcash.shared.chat.ChatDraftSnapshot
import com.flipcash.shared.chat.ChatDraftStore
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.speakerAccess
import com.flipcash.shared.chat.speakerBlock
import com.flipcash.shared.chat.MessageCapability
import com.flipcash.shared.chat.MessagePolicy
import com.flipcash.shared.chat.MessageReactions
Expand Down Expand Up @@ -358,10 +359,11 @@ internal class ChatViewModel @Inject constructor(
*/
val groupAccess: GroupAccess? = null,
/**
* Whether the chat's speaker rules let this viewer speak. True for a DM, which has none,
* and until the balance and staff flag arrive, so a group does not flash Reply away.
* The speaker requirement standing between this viewer and speaking, or null when there is
* none. Null for a DM, which has no rules, and until the balance and staff flag arrive, so
* a group does not flash Reply away.
*/
val canSpeak: Boolean = true,
val speakerBlock: ChatRuleRequirement? = null,
/**
* 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
Expand Down Expand Up @@ -449,6 +451,20 @@ internal class ChatViewModel @Inject constructor(
val obscuresTranscript: Boolean
get() = isOutsideGroup && !readsFromOutside

/** Whether the chat's speaker rules let this viewer speak. See [speakerBlock]. */
val canSpeak: Boolean
get() = speakerBlock == null

/**
* Whether a member who may not speak sees the read-only panel in place of the composer.
* Distinct from [replacesComposer], which is about the viewer being outside the group: a
* viewer outside has the Join gate, and speaking is a question asked only of a member.
*
* Mirrors iOS `ConversationGatePresentation.readOnly`.
*/
val isReadOnlySpeaker: Boolean
get() = subject is ChatSubject.Group && !isOutsideGroup && !replacesComposer && speakerBlock != null

/**
* Whether the gate stands where the composer does. Every viewer outside the group, eligible
* or not: reading a group from outside is not posting in it.
Expand Down Expand Up @@ -531,7 +547,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 OnCanSpeakResolved(val canSpeak: Boolean) : Event
data class OnSpeakerBlockResolved(val block: ChatRuleRequirement?) : Event

/** The gate's "Join Chat" button. */
data object JoinChat : Event
Expand Down Expand Up @@ -766,7 +782,7 @@ internal class ChatViewModel @Inject constructor(

/**
* Whether the chat's speaker rules let the viewer speak, alongside [viewerCanPost] (which is
* membership). Only a widget's Reply and reactions read it.
* membership). Reply and reactions read it on every message.
*/
private val viewerCanSpeak = stateFlow.map { it.canSpeak }.distinctUntilChanged()

Expand Down Expand Up @@ -1660,15 +1676,15 @@ internal class ChatViewModel @Inject constructor(
.distinctUntilChanged()
.flatMapLatest { group ->
if (group == null) {
flowOf(true)
flowOf(null)
} else {
tokenCoordinator.speakerAccess(
tokenCoordinator.speakerBlock(
rules = group.rules,
isStaff = userFlags.resolvedFlags.map { it.isStaff.effectiveValue },
)
}
}
.onEach { dispatchEvent(Event.OnCanSpeakResolved(it)) }
.onEach { dispatchEvent(Event.OnSpeakerBlockResolved(it)) }
.launchIn(viewModelScope)

// Observe member identity — if the other member loses identity (e.g. unlinked
Expand Down Expand Up @@ -2719,7 +2735,7 @@ 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.OnCanSpeakResolved -> { state -> state.copy(canSpeak = event.canSpeak) }
is Event.OnSpeakerBlockResolved -> { state -> state.copy(speakerBlock = event.block) }
Event.JoinChat -> { state ->
state.copy(joinProgress = LoadingSuccessState(loading = true))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import com.flipcash.app.messenger.internal.requiresStaff
import com.flipcash.features.messenger.R
import com.flipcash.services.models.chat.ChatType
import com.flipcash.shared.chat.models.ChatActionHandler
import com.flipcash.services.models.chat.ChatRuleRequirement
import com.flipcash.shared.chat.ui.ChatAnimations
import com.flipcash.shared.chat.ui.ComposerReplyStrip
import com.getcode.theme.CodeTheme
Expand Down Expand Up @@ -121,6 +122,23 @@ internal fun UserControlBottomBar(
return
}

// A member the speaker rules keep from speaking: nothing to type into, so the panel stands
// where the composer would. Checked after the join gate, which owns every viewer outside.
state.speakerBlock?.takeIf { state.isReadOnlySpeaker }?.let { block ->
val rules = (state.subject as? ChatSubject.Group)?.rules
val ruleMint = rules.balanceRequirement()?.mints?.firstOrNull()
val blockMint = (block as? ChatRuleRequirement.MinimumBalance)?.mints?.firstOrNull()
SpeakerGateBar(
requirement = block,
hazeState = hazeState,
// The resolved currency describes the chat's stated balance rule; only name it when
// that is the mint this requirement is about.
currencyName = state.ruleCurrency?.nameInRequirement
?.takeIf { blockMint != null && ruleMint?.bytes == blockMint.bytes },
)
return
}

val keyboard = rememberKeyboardController()
val focusRequester = remember { FocusRequester() }
var buttonHeight by remember { mutableStateOf(0.dp) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.flipcash.app.messenger.internal.screens.components

import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.flipcash.app.theme.FlipcashPreview
import com.flipcash.features.messenger.R
import com.flipcash.services.models.chat.ChatRuleRequirement
import com.getcode.opencode.model.financial.Fiat
import com.getcode.theme.CodeTheme
import dev.chrisbanes.haze.HazeInput
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.hazeBlur
import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState

/**
* Stands where the composer does for a member the chat's speaker rules keep from speaking (node
* 10588:1969 for the `never` copy). Mirrors iOS `ConversationGatePresentation.readOnly`.
*
* 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
* action the viewer could take from here.
*
* Copy follows iOS `ConversationGatePanel`: `never` names the sender, a balance states the amount
* as a sending requirement, staff names who may send.
*/
@Composable
internal fun SpeakerGateBar(
/** The requirement standing in the viewer's way. */
requirement: ChatRuleRequirement,
hazeState: HazeState,
/** The token a balance rule names, or null to state the amount alone (the reserve, or unresolved). */
currencyName: String?,
modifier: Modifier = Modifier,
/** Off only for a Robolectric render, whose native runtime cannot compile the blur shader. */
blurEnabled: Boolean = true,
) {
val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background)
val text = when (requirement) {
ChatRuleRequirement.Never -> stringResource(R.string.label_chatGate_speakerNever)
ChatRuleRequirement.Staff -> stringResource(R.string.subtitle_chatGate_speakerStaffOnly)
is ChatRuleRequirement.MinimumBalance ->
if (currencyName != null) {
stringResource(
R.string.subtitle_chatGate_speakerMinimumBalance,
requirement.amount.formatted(),
currencyName,
)
} else {
stringResource(
R.string.subtitle_chatGate_speakerMinimumBalance_anyToken,
requirement.amount.formatted(),
)
}
}

Box(
modifier = modifier
.fillMaxWidth()
.navigationBarsPadding()
.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)
.then(if (blurEnabled) Modifier.hazeBlur(HazeInput.Sources(hazeState), material) else Modifier)
.padding(horizontal = CodeTheme.dimens.grid.x3),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = CodeTheme.typography.textMedium,
color = CodeTheme.colors.textMain.copy(alpha = 0.4f),
textAlign = TextAlign.Center,
maxLines = 2,
)
}
}

@Preview
@Composable
private fun SpeakerGateBarPreview() {
FlipcashPreview(showBackground = true) {
SpeakerGateBar(
requirement = ChatRuleRequirement.Never,
hazeState = rememberHazeState(),
currencyName = null,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -534,4 +534,40 @@ class ChatViewModelStateTest {
replyTarget = replyingTo?.toDraftReply(),
editStash = editing?.stashedDraft,
)

@Test
fun `a member the speaker rules silence sees the read-only panel and cannot speak`() {
val state = ChatViewModel.State(
subject = group(isMember = true),
speakerBlock = ChatRuleRequirement.Never,
)
assertTrue(state.isReadOnlySpeaker)
assertFalse(state.canSpeak)
// The Join gate is not the one standing in: this viewer is in the group.
assertFalse(state.replacesComposer)
}

@Test
fun `a member who may speak keeps the composer`() {
val state = ChatViewModel.State(subject = group(isMember = true), speakerBlock = null)
assertFalse(state.isReadOnlySpeaker)
assertTrue(state.canSpeak)
}

@Test
fun `an outsider gets the join gate rather than the read-only panel`() {
val state = ChatViewModel.State(
subject = group(isMember = false),
speakerBlock = ChatRuleRequirement.Never,
)
assertTrue(state.replacesComposer)
assertFalse(state.isReadOnlySpeaker)
}

@Test
fun `a direct message has no rules and is never read-only`() {
val state = ChatViewModel.State(subject = dm)
assertTrue(state.canSpeak)
assertFalse(state.isReadOnlySpeaker)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.flipcash.app.messenger.internal

import android.graphics.Bitmap
import android.graphics.Canvas
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.unit.dp
import com.flipcash.app.messenger.internal.screens.components.SpeakerGateBar
import com.flipcash.app.theme.FlipcashPreview
import com.flipcash.services.models.chat.ChatRuleRequirement
import dev.chrisbanes.haze.rememberHazeState
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File

/**
* Renders the read-only composer panel to `build/screenshots/`. Not an assertion test.
*
* The blur is off: Robolectric's native runtime cannot compile the Haze shader. This shows layout,
* outline and copy, not the blur.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w402dp-h120dp-xhdpi")
class SpeakerGateBarScreenshotTest {

@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()

@Test
fun rendersNeverPanel() {
composeRule.mainClock.autoAdvance = false
composeRule.setContent {
FlipcashPreview(showBackground = true) {
SpeakerGateBar(
requirement = ChatRuleRequirement.Never,
hazeState = rememberHazeState(),
currencyName = null,
modifier = Modifier.width(402.dp),
blurEnabled = false,
)
}
}
repeat(10) { composeRule.mainClock.advanceTimeByFrame() }
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")
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
println("SCREENSHOT_WRITTEN: ${file.absolutePath}")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,30 @@ fun canSpeak(
rules: ChatRules?,
balances: List<TokenWithBalance>,
isStaff: Boolean,
): Boolean = rules?.speaker.orEmpty().none { it.isUnmet(balances, isStaff) }
): Boolean = unmetSpeakerRequirement(rules, balances, isStaff) == null

/** [canSpeak] over the live balance and the live staff flag, with the same de-duplication as [groupAccess]. */
fun TokenCoordinator.speakerAccess(
/**
* The speaker requirement to name when the viewer may not speak, or null when they may.
*
* Picks the way iOS's `ConversationGate` does: the first unmet minimum balance, since it is the
* only requirement a viewer can act on, falling back to the first unmet rule. A met rule is never
* named.
*/
fun unmetSpeakerRequirement(
rules: ChatRules?,
balances: List<TokenWithBalance>,
isStaff: Boolean,
): ChatRuleRequirement? {
val unmet = rules?.speaker.orEmpty().filter { it.isUnmet(balances, isStaff) }
return unmet.firstOrNull { it is ChatRuleRequirement.MinimumBalance } ?: unmet.firstOrNull()
}

/** [unmetSpeakerRequirement] over the live balance and the live staff flag, de-duplicated like [groupAccess]. */
fun TokenCoordinator.speakerBlock(
rules: ChatRules?,
isStaff: Flow<Boolean>,
): Flow<Boolean> = combine(tokenBalances, isStaff) { balances, staff ->
canSpeak(rules = rules, balances = balances, isStaff = staff)
): Flow<ChatRuleRequirement?> = combine(tokenBalances, isStaff) { balances, staff ->
unmetSpeakerRequirement(rules = rules, balances = balances, isStaff = staff)
}
.distinctUntilChanged()

Expand Down
Loading
Loading