From b9ee53f823d00c2e61b02ebf50b264090335b032 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 14:27:44 -0400 Subject: [PATCH] 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(), + ) + } }