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
2 changes: 2 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,8 @@
<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="subtitle_chatGate_speakerCreatorOnly">Only the creator can send messages</string>
<string name="subtitle_chatGate_speakerUnsupported">Update Flipcash to send messages</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 @@ -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*.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
}
}
Expand Down Expand Up @@ -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,
Expand All @@ -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))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Byte>(32) { 7 }
private val viewerId = List<Byte>(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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,21 @@ class SpeakerGateBarScreenshotTest {
val composeRule = createAndroidComposeRule<ComponentActivity>()

@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),
Expand All @@ -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}")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -114,7 +116,9 @@ fun canSpeak(
rules: ChatRules?,
balances: List<TokenWithBalance>,
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.
Expand All @@ -127,17 +131,52 @@ 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()
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<TokenWithBalance>,
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<Boolean>,
): Flow<ChatRuleRequirement?> = combine(tokenBalances, isStaff) { balances, staff ->
unmetSpeakerRequirement(rules = rules, balances = balances, isStaff = staff)
viewerId: ID? = null,
creatorId: ID? = null,
): Flow<SpeakerBlock?> = combine(tokenBalances, isStaff) { balances, staff ->
resolveSpeakerBlock(rules = rules, balances = balances, isStaff = staff, viewerId = viewerId, creatorId = creatorId)
}
.distinctUntilChanged()

Expand All @@ -148,6 +187,8 @@ fun TokenCoordinator.speakerBlock(
private fun ChatRuleRequirement.isUnmet(
balances: List<TokenWithBalance>,
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
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
Loading
Loading