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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MessageCapability> {
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<MessageCapability>.readOnly(): Set<MessageCapability> = filterTo(mutableSetOf()) {
it == MessageCapability.Copy || it == MessageCapability.Report
}

private fun resolveForParticipant(
message: ChatMessage,
policy: MessagePolicy,
now: Instant,
): Set<MessageCapability> {
val contents = message.content
if (contents.isEmpty()) return emptySet()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
}
}
Loading