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 22c20b7af5..27f6a6fb4a 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 @@ -308,29 +308,66 @@ internal class ChatViewModel @Inject constructor( get() = selection?.capabilities.orEmpty() /** - * Whether this conversation is being read from outside the group. + * Whether this conversation is being read from outside the group: a group the viewer has + * not joined, or one whose membership is not known yet. + */ + val isOutsideGroup: Boolean + get() = subject is ChatSubject.Group && subject.isMember != true + + /** + * Whether the server will hand this viewer the group's transcript in full without their + * joining it. + * + * The contract's line (`messaging.v1.ViewMode`): a non-member who satisfies a group's + * listener rules may read it in full, and a non-member of a group with no listener rules may + * not read it at all. So [GroupAccess.Eligible] alone is not enough — it is also the answer + * for a group with no rules, where there is nothing to read. + * + * Every unknown answers no. A null [groupAccess] is the balance or the staff flag not having + * arrived, and a null membership is a group hydrated by id that GetChat could not place the + * viewer in; reading either as eligible would show a transcript the viewer may not be owed. + */ + val readsFromOutside: Boolean + get() = subject is ChatSubject.Group && + subject.isMember == false && + groupAccess == GroupAccess.Eligible && + subject.rules?.listener.orEmpty().isNotEmpty() + + /** + * Whether the transcript is blurred, and the placeholder stands in for it when empty. + * + * Read off the subject first, not off [groupAccess] alone, because this one has to be right + * on the frame the group first renders. [groupAccess] arrives through the balance and staff + * flows, so it is null for as long as those take — and a withheld transcript that starts + * unblurred and then blurs has already shown what it was withholding. So the blur goes on + * with the subject and only comes off once the access says [GroupAccess.Eligible]: an + * eligible viewer watches it fade, which is the direction that leaks nothing. * - * Drives the blur over the transcript, the placeholder standing in for it, and the gate that - * stands where the composer does. + * [GroupAccess.Blocked] keeps it on, and a balance that drops while the chat is open turns + * [GroupAccess.Eligible] into [GroupAccess.Blocked] and puts it back. * - * Read off the subject rather than off [groupAccess], even though `Membered` is the same - * fact, because this one has to be right on the frame the group first renders. [groupAccess] - * arrives through the balance and staff flows, so it is null for as long as those take — and - * a withheld transcript that starts unblurred and then blurs has already shown what it was - * withholding. The subject is what the info card and the title bar draw from, so tying the - * blur to it means the three cannot disagree by a frame. + * Not held by a succeeded join. The only viewer who can join is an eligible one, who is + * already reading the transcript sharp, and a group with no listener rules unblurring as the + * roster confirms the join is the same sharp-transcript-over-gate state an eligible viewer + * sits in. * - * [groupAccess] still decides what the gate's button does, which is the part that genuinely - * depends on the balance. + * Mirrors iOS `ConversationGatePresentation.obscuresTranscript`. + */ + val obscuresTranscript: Boolean + get() = isOutsideGroup && !readsFromOutside + + /** + * 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. * - * A succeeded join keeps it true through the button's checkmark: one flag for all three - * renderers means the composer cannot arrive while the transcript is still blurred, and the - * roster — which can confirm membership on the next frame — cannot cut the confirmation - * short. + * A succeeded join keeps it true through the button's checkmark, so the roster — which can + * confirm membership on the next frame — cannot cut the confirmation short by swapping the + * composer in under it. + * + * Mirrors iOS `ConversationGatePresentation.replacesComposer`. */ - val isGatedPreview: Boolean - get() = joinProgress.success || - (subject is ChatSubject.Group && subject.isMember != true) + val replacesComposer: Boolean + get() = joinProgress.success || isOutsideGroup /** * The link that invites someone into this group, or `null` when there is nobody to invite: @@ -999,7 +1036,9 @@ internal class ChatViewModel @Inject constructor( * as a placeholder rather than fetched, so asking for its messages would pull the very ones the * blur is over — and an unknown membership is withheld here for the same reason it is on screen. * A member whose feed has not synced yet is not stranded: the group feed sync sends its own load - * for a group whose cursor is still at zero. + * for a group whose cursor is still at zero. An eligible non-member's transcript is fetched once + * the gate says so, off [State.readsFromOutside], rather than here, where the balance that + * decides it has not arrived yet. */ private suspend fun openTranscript(chatId: ChatId) { val hydrated = chatCoordinator.hydrateChat(chatId) ?: run { @@ -1245,11 +1284,25 @@ internal class ChatViewModel @Inject constructor( .onEach { dispatchEvent(Event.ChatDeactivated(isReadOnly = it)) } .launchIn(viewModelScope) + // An eligible non-member reads the group in full, but nothing else fetches its transcript: + // openTranscript leaves a group the viewer is outside of unloaded, and the group feed only + // carries groups the viewer is in. Keyed on the chat so a balance that dips and recovers + // refetches rather than trusting a page that may have moved on. + stateFlow.map { state -> state.chatId.takeIf { state.readsFromOutside } } + .distinctUntilChanged() + .filterNotNull() + .onEach { chatId -> chatCoordinator.loadMessages(chatId) } + .launchIn(viewModelScope) + // Advance read pointer when user scrolls to messages eventFlow .filterIsInstance() .onEach { event -> - val chatId = stateFlow.value.chatId ?: return@onEach + val state = stateFlow.value + val chatId = state.chatId ?: return@onEach + // A read pointer is a member's: an eligible non-member can see the messages now, + // but the server refuses to move a pointer for someone outside the chat. + if (state.isOutsideGroup) return@onEach viewModelScope.launch { chatCoordinator.advanceReadPointer(chatId, event.messageId) } } .launchIn(viewModelScope) @@ -2068,7 +2121,15 @@ internal class ChatViewModel @Inject constructor( } is Event.OnGroupResolved -> { state -> val metadata = event.membership.metadata + val previous = state.subject as? ChatSubject.Group + // The access was decided for the old membership and rules. Kept across a change + // to either, a stale Eligible would unblur a group whose rules just tightened + // until the balance flow caught up, so it goes back to unknown instead. + val accessStale = previous == null || + previous.isMember != event.membership.isMember || + previous.rules != metadata.rules state.copy( + groupAccess = if (accessStale) null else state.groupAccess, subject = ChatSubject.Group( chatId = metadata.chatId, groupTitle = metadata.title, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index f7dd700cc1..2086ea5077 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -203,7 +203,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { // open. The title bar stays sharp — it is how a non-member knows what they are looking at. BlurredContent( modifier = Modifier.fillMaxSize(), - enabled = state.isGatedPreview, + enabled = state.obscuresTranscript, ) { MessageList( modifier = Modifier diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index e8721ad38d..a976a9c66f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -78,11 +78,10 @@ internal fun UserControlBottomBar( } // The same shape as the block above it, and for the same reason: there is no composer to - // configure when the viewer cannot post. The decision is `isGatedPreview`, the same flag the - // transcript's blur reads, so a composer can never appear over a blurred chat — including for - // the frames before the gate has decided anything, and for as long as the gate holds its join - // confirmation. - if (state.isGatedPreview) { + // configure when the viewer cannot post. `replacesComposer` holds for every viewer outside the + // group — including an eligible one reading the transcript sharp, and the frames before the gate + // has decided anything — and for as long as the gate holds its join confirmation. + if (state.replacesComposer) { GroupGateBar( access = state.groupAccess, // The chat's own rule, read the same way the info card at the head of the transcript diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GatedTranscriptPlaceholder.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GatedTranscriptPlaceholder.kt index e35fd27c02..cac6746e15 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GatedTranscriptPlaceholder.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GatedTranscriptPlaceholder.kt @@ -26,11 +26,12 @@ import com.getcode.ui.components.chat.MessageNodeDefaults /** * What sits behind the blur of a group the viewer has not joined — node 10127:117171. * - * Shapes, not messages. No RPC serves a transcript to a non-member: `GetChat` gives a group's - * title, picture, roster summary and rules, and nothing else, so there is no conversation to fetch - * and blur. What the design shows through the blur is therefore drawn rather than loaded — bars in - * the proportions of an incoming run, so the gate reads as a conversation withheld rather than as a - * screen that failed to load. + * Shapes, not messages. A withheld transcript is never fetched: a viewer short of the group's rules + * could be served it redacted at best, and one whose standing is not known yet may not be owed it at + * all, so there is no conversation to blur. (An eligible non-member is the exception, and reads the + * real transcript sharp — see `ChatViewModel.State.readsFromOutside`.) What the design shows through + * the blur is therefore drawn rather than loaded — bars in the proportions of an incoming run, so the + * gate reads as a conversation withheld rather than as a screen that failed to load. * * Deliberately unlocalised and free of any text: the closer this got to real message content, the * more a screenshot with the blur stripped would look like one. Every value here is fixed, so it diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 3cc6b4208a..d46d99f8dd 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -321,7 +321,7 @@ internal fun MessageList( when { // Node 10127:117171. The blur over this list is what makes it a preview; // see GatedTranscriptPlaceholder for why it is drawn rather than fetched. - state.isGatedPreview -> item(key = "gated-transcript-placeholder") { + state.obscuresTranscript -> item(key = "gated-transcript-placeholder") { GatedTranscriptPlaceholder(modifier = Modifier.fillParentMaxWidth()) } 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 8d8f7105c5..796733d6d3 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 @@ -6,6 +6,7 @@ import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType import com.flipcash.shared.chat.ChatDraftReply import com.flipcash.shared.chat.ChatDraftSnapshot @@ -26,12 +27,12 @@ import kotlin.test.assertNull import kotlin.test.assertTrue /** - * The two answers the chat screen derives rather than stores: whether it is being read from outside - * the group, and whether there is an invite to hand out. + * The answers the chat screen derives rather than stores: whether the transcript is blurred, whether + * the gate stands where the composer does, and whether there is an invite to hand out. * - * Both are read by more than one renderer — the blur and the placeholder ask the first, the empty - * state and the transcript's invite action ask the second — so what is pinned here is that each has - * exactly one source and that a DM is never mistaken for a gated group. + * The first two used to be one flag and are now separate questions — an eligible non-member reads + * the transcript sharp under a Join gate — so what is pinned here is where they part, that every + * unknown keeps the blur on, and that a DM is never mistaken for a gated group. */ class ChatViewModelStateTest { @@ -39,12 +40,14 @@ class ChatViewModelStateTest { private val groupChatId = ChatId(groupUuid.bytes) private val unmet = ChatRuleRequirement.MinimumBalance(Fiat(100.0), emptyList()) - private fun group(isMember: Boolean?) = ChatSubject.Group( + private val gatedRules = ChatRules(listener = listOf(unmet), speaker = emptyList()) + + private fun group(isMember: Boolean?, rules: ChatRules? = gatedRules) = ChatSubject.Group( chatId = groupChatId, groupTitle = "Ballers", picture = null, memberCount = 1L, - rules = null, + rules = rules, isMember = isMember, ) @@ -61,66 +64,113 @@ class ChatViewModelStateTest { ) @Test - fun `a group the viewer is outside of is a gated preview`() { - assertTrue( - ChatViewModel.State( - subject = group(isMember = false), - groupAccess = GroupAccess.Blocked(unmet), - ).isGatedPreview + fun `an eligible non-member reads the transcript with the gate still up`() { + val state = ChatViewModel.State( + subject = group(isMember = false), + groupAccess = GroupAccess.Eligible, ) - // Eligible is still outside: the transcript stays withheld until the join lands, which is - // what makes "Join Chat" and "Buy More" the same screen with two buttons. - assertTrue( - ChatViewModel.State( - subject = group(isMember = false), - groupAccess = GroupAccess.Eligible, - ).isGatedPreview + // The contract serves a non-member who satisfies the listener rules in full, so there is + // something to read — but reading is not posting, so the Join gate keeps the composer's place. + assertFalse(state.obscuresTranscript) + assertTrue(state.readsFromOutside) + assertTrue(state.replacesComposer) + } + + @Test + fun `a blocked non-member stays blurred`() { + val state = ChatViewModel.State( + subject = group(isMember = false), + groupAccess = GroupAccess.Blocked(unmet), ) + assertTrue(state.obscuresTranscript) + assertFalse(state.readsFromOutside) + assertTrue(state.replacesComposer) + } + + @Test + fun `a balance that drops below the bar brings the blur back`() { + // The viewer spends the token with the chat open: the access flow re-emits Blocked, and + // nothing about the earlier Eligible may keep the transcript sharp. + val eligible = ChatViewModel.State( + subject = group(isMember = false), + groupAccess = GroupAccess.Eligible, + ) + assertFalse(eligible.obscuresTranscript) + + val blocked = eligible.copy(groupAccess = GroupAccess.Blocked(unmet)) + assertTrue(blocked.obscuresTranscript) + assertTrue(blocked.replacesComposer) } @Test fun `the blur is on before the gate has decided anything`() { - // The reason this reads the subject and not the access: access arrives through the balance - // and staff flows, so it is null for their first frames, while the info card draws as soon - // as the subject resolves. Off the access alone, a withheld transcript rendered sharp and - // then blurred — which is showing the thing it is meant to withhold. - assertTrue( - ChatViewModel.State( - subject = group(isMember = false), - groupAccess = null, - ).isGatedPreview + // Access arrives through the balance and staff flows, so it is null for their first frames, + // while the info card draws as soon as the subject resolves. Unblurring on anything short of + // Eligible would render a withheld transcript sharp and then blur it. + val pending = ChatViewModel.State( + subject = group(isMember = false), + groupAccess = null, ) + assertTrue(pending.obscuresTranscript) + assertTrue(pending.replacesComposer) // The same frame for a member is the other answer, not a pending one: nothing about their // balance can put them outside a group they are in. - assertFalse( - ChatViewModel.State( - subject = group(isMember = true), - groupAccess = null, - ).isGatedPreview + val member = ChatViewModel.State( + subject = group(isMember = true), + groupAccess = null, ) + assertFalse(member.obscuresTranscript) + assertFalse(member.replacesComposer) } @Test - fun `a member reads the group unblurred`() { - assertFalse( - ChatViewModel.State( - subject = group(isMember = true), - groupAccess = GroupAccess.Membered, - ).isGatedPreview + fun `an eligible non-member of a group with no listener rules stays blurred`() { + // No listener rule means no one outside the group may read it (messaging.v1.ViewMode), so + // lifting the blur would uncover an empty chat rather than a transcript. + val state = ChatViewModel.State( + subject = group(isMember = false, rules = null), + groupAccess = GroupAccess.Eligible, + ) + assertTrue(state.obscuresTranscript) + assertFalse(state.readsFromOutside) + assertTrue( + state.copy( + subject = group( + isMember = false, + rules = ChatRules(listener = emptyList(), speaker = emptyList()), + ), + ).obscuresTranscript ) } @Test - fun `the gate holds while the join button is showing its checkmark`() { - // The roster can confirm membership before the checkmark has been drawn. Holding the gate - // for as long as the success is up keeps the blur and the button in place for the beat, so - // the composer does not arrive over a transcript that is still blurred. - assertTrue( - ChatViewModel.State( - subject = group(isMember = true), - groupAccess = GroupAccess.Membered, - joinProgress = LoadingSuccessState(success = true), - ).isGatedPreview + fun `a member reads the group with the composer`() { + val state = ChatViewModel.State( + subject = group(isMember = true), + groupAccess = GroupAccess.Membered, + ) + assertFalse(state.obscuresTranscript) + assertFalse(state.replacesComposer) + } + + @Test + fun `the gate holds the composer back while the join button is showing its checkmark`() { + // The roster can confirm membership before the checkmark has been drawn. The composer waits + // for the success to clear, but the transcript does not: the joiner was eligible, and so was + // already reading it sharp. + val state = ChatViewModel.State( + subject = group(isMember = true), + groupAccess = GroupAccess.Membered, + joinProgress = LoadingSuccessState(success = true), + ) + assertTrue(state.replacesComposer) + assertFalse(state.obscuresTranscript) + // Nor does it re-blur the frame before the roster lands, while the viewer is still outside. + assertFalse( + state.copy( + subject = group(isMember = false), + groupAccess = GroupAccess.Eligible, + ).obscuresTranscript ) } @@ -133,7 +183,7 @@ class ChatViewModelStateTest { subject = group(isMember = true), groupAccess = GroupAccess.Membered, joinProgress = LoadingSuccessState(), - ).isGatedPreview + ).replacesComposer ) } @@ -147,16 +197,17 @@ class ChatViewModelStateTest { ChatViewModel.State( subject = group(isMember = null), groupAccess = null, - ).isGatedPreview + ).obscuresTranscript ) - // Still withheld once the balance answers: what the button offers is decided by the access, - // not the blur. - assertTrue( - ChatViewModel.State( - subject = group(isMember = null), - groupAccess = GroupAccess.Eligible, - ).isGatedPreview + // Still withheld once the balance answers: the access is computed as if the viewer were + // outside, and an unknown membership is not a known non-member. + val eligible = ChatViewModel.State( + subject = group(isMember = null), + groupAccess = GroupAccess.Eligible, ) + assertTrue(eligible.obscuresTranscript) + assertFalse(eligible.readsFromOutside) + assertTrue(eligible.replacesComposer) } @Test @@ -169,8 +220,11 @@ class ChatViewModelStateTest { @Test fun `a DM has no gate to render`() { // The null access is the whole point: a default would blur every contact conversation. - assertFalse(ChatViewModel.State(subject = dm, groupAccess = null).isGatedPreview) - assertFalse(ChatViewModel.State().isGatedPreview) + for (state in listOf(ChatViewModel.State(subject = dm, groupAccess = null), ChatViewModel.State())) { + assertFalse(state.obscuresTranscript) + assertFalse(state.replacesComposer) + assertFalse(state.readsFromOutside) + } } @Test diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt index 9a98edc1ae..d62c4dd447 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt @@ -231,8 +231,8 @@ class GroupChromeScreenshotTest { currency = badBoys, joinProgress = LoadingSuccessState(loading = true), ) - // The join came back accepted: the checkmark the gate holds while the blur - // lifts, before the composer takes its place. + // The join came back accepted: the checkmark the gate holds over the (already + // sharp) transcript, before the composer takes its place. GroupGateBar( access = GroupAccess.Membered, requirement = balanceRule, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupTranscriptScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupTranscriptScreenshotTest.kt index f466f51583..317993da17 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupTranscriptScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupTranscriptScreenshotTest.kt @@ -4,22 +4,31 @@ import android.graphics.Bitmap import android.graphics.Canvas import android.view.View import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.unit.dp import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems +import com.flipcash.app.messenger.internal.screens.components.GroupGateBar import com.flipcash.app.messenger.internal.screens.components.MessageRow import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.GroupAccess import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.SenderIdentity import com.flipcash.shared.chat.models.SeparatorConfig +import com.getcode.opencode.model.financial.Fiat import kotlinx.coroutines.flow.flowOf import org.junit.Rule import org.junit.Test @@ -96,6 +105,68 @@ class GroupTranscriptScreenshotTest { capture("group_transcript.png") } + /** + * The same transcript seen from outside the group, once per access: an eligible viewer reads it + * sharp over the Join gate, while a blocked one and one whose balance has not arrived yet see it + * blurred. The blur and the gate read the state's own flags, so this is the split as the screen + * draws it rather than a restatement of it. + * + * A heavy fade stands in for `BlurredContent`. Robolectric's native graphics draws neither + * blur: Haze's runtime shader does not compile there, and `Modifier.blur` renders sharp. + */ + @Test + fun rendersTranscriptFromOutsideTheGroup() { + val items = listOf( + bubble(3, "Not even close", noah, secondsIn = 20), + bubble(2, "I disagree. He isn't the best at all", noah, secondsIn = 10), + ChatListItem.DateSeparator(start), + ) + val requirement = ChatRuleRequirement.MinimumBalance(mints = emptyList(), amount = Fiat(100.0)) + val outsider = ChatSubject.Group( + chatId = ChatId(byteArrayOf(1)), + groupTitle = "Bad Boys", + picture = null, + memberCount = 412L, + rules = ChatRules(listener = listOf(requirement), speaker = emptyList()), + isMember = false, + ) + val states = listOf( + ChatViewModel.State(subject = outsider, groupAccess = GroupAccess.Eligible), + ChatViewModel.State(subject = outsider, groupAccess = GroupAccess.Blocked(requirement)), + ChatViewModel.State(subject = outsider, groupAccess = null), + ) + + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashThemeWrapper().Wrap { + val messages = flowOf(PagingData.from(items)).collectAsLazyPagingItems() + Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { + states.forEach { state -> + Column(modifier = Modifier.width(360.dp)) { + Box( + modifier = Modifier.alpha(if (state.obscuresTranscript) 0.15f else 1f), + ) { + Transcript(messages) + } + if (state.replacesComposer) { + GroupGateBar( + access = state.groupAccess, + requirement = requirement, + staffOnly = false, + currency = null, + onAction = {}, + ) + } + } + } + } + } + } + repeat(20) { composeRule.mainClock.advanceTimeByFrame() } + + capture("group_transcript_from_outside.png") + } + @Composable private fun Transcript(messages: LazyPagingItems) { Column(modifier = Modifier.width(360.dp).padding(16.dp)) {