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(), + ) + } }