From e57e49874e2faf998c6fe9bea7ce3beb53f82480 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 14:07:26 -0400 Subject: [PATCH 1/2] feat(chat): let an eligible non-member read a group before joining A viewer outside a group who meets its balance rule saw the same blurred transcript as one who didn't, because isGatedPreview answered both "blur the transcript" and "replace the composer". It is now two flags, named after iOS's ConversationGatePresentation: obscuresTranscript and replacesComposer. The blur comes off only when the viewer is a known non-member, the access is Eligible, and the group has listener rules. messaging.v1.ViewMode only serves a non-member the full transcript when they satisfy a listener rule; a group with no listener rules also computes Eligible but has nothing to serve. A null access or null membership keeps the blur, so the transcript fades in after the balance arrives rather than showing and then blurring. A balance that drops re-emits Blocked and the blur returns. openTranscript skips groups the viewer is outside of, and the group feed only carries joined groups, so nothing loaded an eligible viewer's messages. They are now fetched when readsFromOutside turns true, keyed on the chat so a dip and recovery refetches. Two guards follow from showing messages to a non-member: the read pointer is not advanced for someone outside the group, since the server refuses it, and OnGroupResolved clears groupAccess when membership or rules change, so a stale Eligible cannot unblur a group whose rules tightened. A succeeded join no longer holds the blur. The only viewer who can join is already reading the transcript sharp. It still holds the composer back through the checkmark. --- .../app/messenger/internal/ChatViewModel.kt | 101 ++++++++-- .../internal/screens/MessengerScreen.kt | 2 +- .../screens/components/ChatBottomBar.kt | 9 +- .../components/GatedTranscriptPlaceholder.kt | 11 +- .../screens/components/MessageList.kt | 2 +- .../internal/ChatViewModelStateTest.kt | 176 ++++++++++++------ .../internal/GroupChromeScreenshotTest.kt | 4 +- .../internal/GroupTranscriptScreenshotTest.kt | 71 +++++++ 8 files changed, 281 insertions(+), 95 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 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)) { From b9ee53f823d00c2e61b02ebf50b264090335b032 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 14:27:44 -0400 Subject: [PATCH 2/2] fix(chat): stop a viewer outside a group from replying to its messages With #1537 an eligible non-member reads a group's transcript under the Join gate, but resolveCapabilities looks only at the message, so every other person's message offered Reply. Swiping or picking Reply set replyingTo behind the gate, and the composer later opened quoting it once the viewer joined. A former member's own messages offered Edit and Delete the same way. resolveCapabilities takes canPost, and a viewer who cannot post keeps only Copy and Report. The transcript mapping re-runs when the viewer joins or leaves, and the selection bar narrows its set with readOnly() if they left after the row was mapped, as it already does with the edit and delete windows. --- .../app/messenger/internal/ChatViewModel.kt | 20 +++++++++--- .../flipcash/shared/chat/MessageCapability.kt | 25 +++++++++++++++ .../shared/chat/MessageCapabilityTest.kt | 31 +++++++++++++++++++ 3 files changed, 72 insertions(+), 4 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 27f6a6fb4a..d0e4c33aff 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 @@ -72,6 +72,7 @@ import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.splitAroundLinkCard import com.flipcash.shared.chat.models.SenderIdentity import com.flipcash.shared.chat.models.SeparatorConfig +import com.flipcash.shared.chat.readOnly import com.flipcash.shared.chat.resolveCapabilities import com.flipcash.shared.chat.ui.detectUrls import com.flipcash.shared.chat.ui.linkableText @@ -566,6 +567,13 @@ internal class ChatViewModel @Inject constructor( */ private val messagePolicy = stateFlow.map { it.messagePolicy }.distinctUntilChanged() + /** + * Re-runs the transcript mapping when the viewer joins or leaves, so a message resolved while + * they were reading from outside picks up Reply the moment the join lands, rather than on the + * next page. + */ + private val viewerCanPost = stateFlow.map { !it.isOutsideGroup }.distinctUntilChanged() + /** * Null for a DM, a map for a group — the distinction the transcript needs, because a DM's * counterparty is already named in the title bar and attributing each of their bubbles would be @@ -625,7 +633,8 @@ internal class ChatViewModel @Inject constructor( pendingMutations, messagePolicy, senderProfiles, - ) { pagingData, mutations, policy, profiles -> + viewerCanPost, + ) { pagingData, mutations, policy, profiles, canPost -> pagingData.flatMap { stored -> val message = stored.applying(mutations[stored.messageId]) message.content.flatMapIndexed { index, content -> @@ -709,7 +718,7 @@ internal class ChatViewModel @Inject constructor( // Resolved once, here, so no menu re-derives it: a later group-role // taxonomy becomes another input to the resolver rather than a branch at // each action site. - capabilities = resolveCapabilities(message, policy), + capabilities = resolveCapabilities(message, policy, canPost = canPost), quote = quote, sender = sender, // Independent of the profile lookup above: the runs have to break by @@ -2236,10 +2245,13 @@ internal class ChatViewModel @Inject constructor( // The transcript resolved this bubble when it was mapped, which may have been // well inside a window that has since closed. Narrow it again here so the bar // offers what is open now rather than what was open when the row was built. + // The same goes for membership: a viewer who has left since keeps only what a + // reader outside the group may do. val selected = event.bubble.takeUnless { alreadySelected }?.let { bubble -> + val open = bubble.capabilities + .withinWindows(bubble.timestamp, state.messagePolicy) bubble.copy( - capabilities = bubble.capabilities - .withinWindows(bubble.timestamp, state.messagePolicy), + capabilities = if (state.isOutsideGroup) open.readOnly() else open, ) } state.copy( diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt index 0b139562c4..47a42a722c 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt @@ -107,11 +107,36 @@ data class MessagePolicy( * Report follows one rule: anything a participant sent can be reported, and anything the server * wrote, or that no longer exists, cannot. Your own messages are left out because reporting one is * not a thing anyone does, and a row that is always present is a row people stop reading. + * + * A viewer who cannot post in the chat — someone reading a group from outside it — keeps only Copy + * and Report. Reply, Edit and Delete all post into the chat, and the gate that stands where their + * composer would is the only way in. + * + * @param canPost whether the viewer may post in this chat. False for a viewer outside a group, + * including one who has left it and still has messages of their own in the transcript. */ fun resolveCapabilities( message: ChatMessage, policy: MessagePolicy = MessagePolicy.Default, now: Instant = Clock.System.now(), + canPost: Boolean = true, +): Set { + val resolved = resolveForParticipant(message, policy, now) + return if (canPost) resolved else resolved.readOnly() +} + +/** + * Drops what a viewer who cannot post may not do. Split out for the same reason as [withinWindows]: + * a set resolved while the viewer could post has to narrow at the menu if they have since left. + */ +fun Set.readOnly(): Set = filterTo(mutableSetOf()) { + it == MessageCapability.Copy || it == MessageCapability.Report +} + +private fun resolveForParticipant( + message: ChatMessage, + policy: MessagePolicy, + now: Instant, ): Set { val contents = message.content if (contents.isEmpty()) return emptySet() diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt index 8d33e91d97..f8d348756b 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt @@ -314,4 +314,35 @@ class MessageCapabilityTest { atSend.withinWindows(sentAt, policy, now = sentAt + 90.minutes), ) } + + /** + * An eligible non-member reads a group's transcript under a Join gate. Reply, Edit and Delete + * all post into the chat, and there is no composer for them to land in until the join does. + */ + @Test + fun `a viewer who cannot post keeps only copy and report`() { + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Report), + resolveCapabilities(text(isFromSelf = false), canPost = false), + ) + assertEquals( + setOf(MessageCapability.Report), + resolveCapabilities(cash(isFromSelf = false), canPost = false), + ) + // Someone who left the group still has their own messages in its transcript. + assertEquals( + setOf(MessageCapability.Copy), + resolveCapabilities(text(), now = sentAt, canPost = false), + ) + assertEquals(emptySet(), resolveCapabilities(cash(), canPost = false)) + } + + @Test + fun `narrowing a resolved set to read-only matches resolving without posting`() { + val resolved = resolveCapabilities(text(), now = sentAt) + assertEquals( + resolveCapabilities(text(), now = sentAt, canPost = false), + resolved.readOnly(), + ) + } }