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
1 change: 1 addition & 0 deletions apps/flipcash/features/messenger/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies {
implementation(project(":apps:flipcash:shared:menu"))
implementation(project(":apps:flipcash:shared:payments"))
implementation(project(":apps:flipcash:shared:tokens"))
implementation(project(":apps:flipcash:shared:userflags"))
implementation(project(":libs:vibrator:bindings"))
implementation(project(":libs:messaging"))
implementation(project(":services:flipcash"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@ import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.app.core.extensions.setText
import com.flipcash.app.core.ui.ConfirmationStyle
import com.flipcash.shared.chat.MessageCapability
import com.flipcash.shared.chat.MessagePolicy
import com.flipcash.shared.chat.withinWindows
import com.flipcash.shared.chat.applying
import com.flipcash.shared.chat.resolveCapabilities
import com.flipcash.shared.chat.models.ChatListItem
import com.flipcash.shared.chat.models.ReceiptStatus
import com.flipcash.shared.chat.models.SeparatorConfig
import com.flipcash.app.funding.PurchaseMethodController
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.app.userflags.UserFlagsCoordinator
import com.flipcash.features.messenger.R
import com.flipcash.services.models.TipOrigin
import com.flipcash.services.models.UserProfile
Expand Down Expand Up @@ -111,6 +114,7 @@ internal class ChatViewModel @Inject constructor(
private val resources: ResourceHelper,
private val analytics: FlipcashAnalyticsService,
private val clipboardManager: ClipboardManager,
private val userFlags: UserFlagsCoordinator,
) : BaseViewModel<ChatViewModel.State, ChatViewModel.Event>(
initialState = State(),
updateStateForEvent = updateStateForEvent,
Expand Down Expand Up @@ -170,6 +174,14 @@ internal class ChatViewModel @Inject constructor(
* closes, rather than sitting sharp and half-clipped at the sheet's own edge.
*/
val confirmingDelete: Boolean = false,
/**
* The edit and delete windows the server publishes through `UserFlags`.
*
* Held in state rather than read straight off the coordinator because the reducer needs
* it: a selection has to be narrowed to what is still open at the moment it is made, and
* the reducer is where the selection is set.
*/
val messagePolicy: MessagePolicy = MessagePolicy.Default,
) {
// Opening the participant's profile (the entry point to blocking) is only available for tip DMs.
val canViewProfile: Boolean
Expand Down Expand Up @@ -235,6 +247,7 @@ internal class ChatViewModel @Inject constructor(
data class LimitsChanged(val limits: Limits?) : Event
data class AdvanceReadPointer(val messageId: Long) : Event
data class ChatDeactivated(val isReadOnly: Boolean) : Event
data class MessagePolicyChanged(val policy: MessagePolicy) : Event

/** Selects [bubble], or leaves selection mode if it is already the selected one. */
data class ToggleMessageSelection(val bubble: ChatListItem.ContentBubble) : Event
Expand Down Expand Up @@ -272,9 +285,15 @@ internal class ChatViewModel @Inject constructor(
.flatMapLatest { chatCoordinator.observeOtherReadPointer(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)

/**
* Re-runs the transcript mapping when the windows change, so a message resolved under the
* defaults (both open, before the flags arrive) narrows as soon as the server's answer lands.
*/
private val messagePolicy = stateFlow.map { it.messagePolicy }.distinctUntilChanged()

@OptIn(ExperimentalCoroutinesApi::class)
val messages: Flow<PagingData<ChatListItem>> =
combine(messageStream, pendingMutations) { pagingData, mutations ->
combine(messageStream, pendingMutations, messagePolicy) { pagingData, mutations, policy ->
pagingData.flatMap { stored ->
val message = stored.applying(mutations[stored.messageId])
message.content.mapIndexed { index, content ->
Expand Down Expand Up @@ -308,7 +327,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),
capabilities = resolveCapabilities(message, policy),
)
}
}.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? ->
Expand Down Expand Up @@ -439,6 +458,19 @@ internal class ChatViewModel @Inject constructor(
}

private fun initChatHandlers() {
// Ahead of the transcript, so the first mapping already has the real windows rather than
// the defaults, which leave both edit and delete open.
userFlags.resolvedFlags
.map {
MessagePolicy(
editWindow = it.messageEditWindow.effectiveValue,
deleteWindow = it.messageDeleteWindow.effectiveValue,
)
}
.distinctUntilChanged()
.onEach { dispatchEvent(Event.MessagePolicyChanged(it)) }
.launchIn(viewModelScope)

// Unified chat open handler — resolves chatId and contact from the identifier
eventFlow
.filterIsInstance<Event.OnChatOpened>()
Expand Down Expand Up @@ -1168,10 +1200,20 @@ internal class ChatViewModel @Inject constructor(
is Event.LimitsChanged -> { state -> state.copy(limits = event.limits) }
is Event.AdvanceReadPointer -> { state -> state }
is Event.ChatDeactivated -> { state -> state.copy(isAnonymous = event.isReadOnly) }
is Event.MessagePolicyChanged -> { state -> state.copy(messagePolicy = event.policy) }
is Event.ToggleMessageSelection -> { state ->
val alreadySelected = state.selection?.itemKey == event.bubble.itemKey
// 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.
val selected = event.bubble.takeUnless { alreadySelected }?.let { bubble ->
bubble.copy(
capabilities = bubble.capabilities
.withinWindows(bubble.timestamp, state.messagePolicy),
)
}
state.copy(
selection = event.bubble.takeUnless { alreadySelected },
selection = selected,
confirmingDelete = false,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,4 +188,6 @@ private fun ResolvedUserFlags.editableEntries(): List<EditableEntry<*>> = listOf
EditableEntry(Field.PreferredUsdcOnRampLiquidityPool, usdcOnRampLiquidityPool),
EditableEntry(Field.MinimumHolderAmountForLeaderboard, minimumHolderAmountForLeaderboard),
EditableEntry(Field.RequireCoinbaseEmailVerification, requireCoinbaseEmailVerification),
EditableEntry(Field.MessageEditWindow, messageEditWindow),
EditableEntry(Field.MessageDeleteWindow, messageDeleteWindow),
)
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,17 @@ enum class MessageCapability {
/**
* Client-side limits on what may be done to a message.
*
* @param editWindow how long after sending a message stays editable, or `null` for no limit. The
* server does not publish a window today, so the default leaves edit open and lets `CANNOT_EDIT`
* be the authority.
* Both windows come from `UserFlags` (`message_edit_window`, `message_delete_window`), which sends
* them with explicit presence: an unset field is no limit rather than a zero-length one. The
* defaults are therefore both `null`, which leaves `CANNOT_EDIT` / `CANNOT_DELETE` as the
* authority for a build that has not seen the flags yet.
*
* @param editWindow how long after sending a message stays editable, or `null` for no limit.
* @param deleteWindow how long after sending a message stays deletable, or `null` for no limit.
*/
data class MessagePolicy(
val editWindow: Duration? = null,
val deleteWindow: Duration? = null,
) {
companion object {
val Default = MessagePolicy()
Expand All @@ -46,8 +51,9 @@ data class MessagePolicy(
*
* | Message | Capabilities |
* |---|---|
* | Own text, confirmed, within the edit window | Copy, Reply, Edit, Delete |
* | Own text, confirmed, outside a configured window | Copy, Reply, Delete |
* | Own text, confirmed, inside both windows | Copy, Reply, Edit, Delete |
* | Own text, confirmed, past the edit window | Copy, Reply, Delete |
* | Own text, confirmed, past both windows | Copy, Reply |
* | Own text, unconfirmed (`eventSequence == 0`) | none |
* | Another participant's text | Copy, Reply |
* | Any cash or tip message | Reply |
Expand Down Expand Up @@ -85,14 +91,33 @@ fun resolveCapabilities(
if (hasText) add(MessageCapability.Copy)
add(MessageCapability.Reply)
if (message.isFromSelf) {
if (hasText && policy.allowsEdit(message, now)) add(MessageCapability.Edit)
if (hasText) add(MessageCapability.Edit)
add(MessageCapability.Delete)
}
}
}.withinWindows(message.timestamp, policy, now)
}

/** True while [message] is still inside the configured edit window, or always if there is none. */
private fun MessagePolicy.allowsEdit(message: ChatMessage, now: Instant): Boolean {
val window = editWindow ?: return true
return now - message.timestamp <= window
/**
* Drops the capabilities of a message sent at [sentAt] whose window has since closed.
*
* Split out of [resolveCapabilities] because resolution happens once, when the transcript is
* mapped, and the windows keep running afterwards: a menu opened a minute later would otherwise
* still offer an edit the server is about to answer `CANNOT_EDIT`. A surface holding an
* already-resolved set re-applies this when it acts on it, and gets the same answer the resolver
* would give — the rule lives in one place either way.
*/
fun Set<MessageCapability>.withinWindows(
sentAt: Instant,
policy: MessagePolicy,
now: Instant = Clock.System.now(),
): Set<MessageCapability> = filterTo(mutableSetOf()) { capability ->
when (capability) {
MessageCapability.Edit -> policy.editWindow.stillOpen(sentAt, now)
MessageCapability.Delete -> policy.deleteWindow.stillOpen(sentAt, now)
MessageCapability.Copy, MessageCapability.Reply -> true
}
}

/** True while a message sent at [sentAt] is inside this window, or always if there is none. */
private fun Duration?.stillOpen(sentAt: Instant, now: Instant): Boolean =
this == null || now - sentAt <= this
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant

Expand Down Expand Up @@ -138,4 +139,72 @@ class MessageCapabilityTest {
resolveCapabilities(text(), policy, now = sentAt + 16.minutes),
)
}

@Test
fun `a delete window drops Delete once it lapses and leaves Edit alone`() {
val policy = MessagePolicy(deleteWindow = 60.minutes)

assertEquals(
setOf(
MessageCapability.Copy,
MessageCapability.Reply,
MessageCapability.Edit,
MessageCapability.Delete,
),
resolveCapabilities(text(), policy, now = sentAt + 59.minutes),
)

// Edit survives: this policy sets no edit window, and an unset window is no limit.
assertEquals(
setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Edit),
resolveCapabilities(text(), policy, now = sentAt + 61.minutes),
)
}

@Test
fun `the windows run independently, so the shorter one lapses first`() {
val policy = MessagePolicy(editWindow = 15.minutes, deleteWindow = 60.minutes)

assertEquals(
setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete),
resolveCapabilities(text(), policy, now = sentAt + 30.minutes),
)
assertEquals(
setOf(MessageCapability.Copy, MessageCapability.Reply),
resolveCapabilities(text(), policy, now = sentAt + 90.minutes),
)
}

@Test
fun `an unset window leaves its capability open`() {
assertEquals(
setOf(
MessageCapability.Copy,
MessageCapability.Reply,
MessageCapability.Edit,
MessageCapability.Delete,
),
resolveCapabilities(text(), MessagePolicy.Default, now = sentAt + 365.days),
)
}

/**
* The transcript resolves once, when it is mapped; the menu re-applies the windows when it
* opens. Both go through the same rule, so a set narrowed after the fact matches what the
* resolver would have returned at that instant.
*/
@Test
fun `re-applying the windows to a resolved set matches resolving at that instant`() {
val policy = MessagePolicy(editWindow = 15.minutes, deleteWindow = 60.minutes)
val atSend = resolveCapabilities(text(), policy, now = sentAt)

assertEquals(
resolveCapabilities(text(), policy, now = sentAt + 30.minutes),
atSend.withinWindows(sentAt, policy, now = sentAt + 30.minutes),
)
assertEquals(
resolveCapabilities(text(), policy, now = sentAt + 90.minutes),
atSend.withinWindows(sentAt, policy, now = sentAt + 90.minutes),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,18 @@ interface ChatMessageDao {
@Query("DELETE FROM chat_messages WHERE chat_id_hex = :chatIdHex AND status = 'SENDING'")
suspend fun deleteAllPending(chatIdHex: String)

/**
* The optimistic row is written before the server has stamped the message, so every
* server-assigned field is written here — `event_sequence` included. Leaving it at the pending
* row's 0 would keep a sent message looking unacknowledged until some later fetch of the chat
* overwrote the row, which is what the edit/delete guards and last-writer-wins read it for.
*/
@Query("""
UPDATE chat_messages
SET message_id = :newMessageId,
timestamp_epoch_ms = :newTimestampMs,
unread_seq = :newUnreadSeq,
event_sequence = :newEventSequence,
status = 'SENT'
WHERE chat_id_hex = :chatIdHex AND pending_client_id_hex = :clientIdHex
""")
Expand All @@ -117,6 +124,7 @@ interface ChatMessageDao {
newMessageId: Long,
newTimestampMs: Long,
newUnreadSeq: Long,
newEventSequence: Long,
)

@Transaction
Expand All @@ -127,6 +135,7 @@ interface ChatMessageDao {
newMessageId = serverMessage.messageId,
newTimestampMs = serverMessage.timestampEpochMs,
newUnreadSeq = serverMessage.unreadSeq,
newEventSequence = serverMessage.eventSequence,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.test.core.app.ApplicationProvider
import com.flipcash.app.persistence.FlipcashDatabase
import com.flipcash.app.persistence.converters.MessageContentSerialized
import com.flipcash.app.persistence.entities.ChatMessageEntity
import com.flipcash.app.persistence.entities.MessageStatus
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
Expand Down Expand Up @@ -49,6 +50,17 @@ class ChatMessageDaoTest {
unreadSeq = messageId,
)

private fun pending(body: String) = ChatMessageEntity(
chatIdHex = CHAT_HEX,
messageId = -1,
senderIdHex = SENDER_HEX,
contentJson = listOf(MessageContentSerialized.Text(body)),
timestampEpochMs = 1,
unreadSeq = 0,
status = MessageStatus.SENDING,
pendingClientIdHex = CLIENT_HEX,
)

private fun tombstone(messageId: Long) = text(messageId, "gone").copy(
contentJson = listOf(MessageContentSerialized.Deleted(deletedAt = 1, deletedBy = SENDER_HEX)),
isDeleted = true,
Expand Down Expand Up @@ -103,9 +115,32 @@ class ChatMessageDaoTest {
assertEquals(2L, dao.getLatestVisible(CHAT_HEX)?.messageId)
}

/**
* The optimistic row is written with no event sequence, because the client has none to write:
* the server stamps it. Confirming has to carry the echo's stamp onto the row, or the message
* stays at sequence 0 until something else refetches the chat — and everything keyed on the
* stamp (edit, delete, last-writer-wins) treats it as unacknowledged in the meantime.
*/
@Test
fun `confirming a pending message carries the server's event sequence`() = runTest {
dao.upsert(pending("hello"))

dao.confirmPendingMessage(
CHAT_HEX,
CLIENT_HEX,
text(7, "hello").copy(eventSequence = 42, unreadSeq = 3),
)

val stored = dao.getMessage(CHAT_HEX, 7)!!
assertEquals(42L, stored.eventSequence)
assertEquals(MessageStatus.SENT, stored.status)
assertEquals(3L, stored.unreadSeq)
}

private companion object {
const val CHAT_HEX = "aabb"
const val OTHER_HEX = "ccdd"
const val SENDER_HEX = "1122"
const val CLIENT_HEX = "eeff"
}
}
Loading
Loading