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 @@ -152,6 +152,29 @@ interface MessagingOperations {
/** Retries a failed pending message: resets to SENDING and re-sends to the server. */
suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List<MessageContent>): Result<ChatMessage>

/**
* Replaces [messageId]'s body with [text], optimistically.
*
* The change is visible immediately as a [PendingMutation] over the stored row; the row itself
* is only written once the server agrees. Returns the server's version of the message.
*/
suspend fun editMessage(chatId: ChatId, messageId: Long, text: String): Result<ChatMessage>

/**
* Deletes [messageId] for everyone, optimistically.
*
* "For everyone" is the only delete the wire models — there is no local-only variant to choose
* between. Same overlay-then-reconcile path as [editMessage].
*/
suspend fun deleteMessage(chatId: ChatId, messageId: Long): Result<ChatMessage>

/**
* Emits the edits and deletes in [chatId] that the server has not answered yet, keyed by
* message id, for composing over the stored transcript with
* [applying][com.flipcash.shared.chat.applying].
*/
fun observePendingMutations(chatId: ChatId): Flow<Map<Long, PendingMutation>>

/** Advances the local and remote read pointer for [chatId] to [messageId]. */
suspend fun advanceReadPointer(chatId: ChatId, messageId: Long): Result<Unit>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.flipcash.shared.chat

import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.MessageContent
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Instant

/**
* A single thing the viewer is allowed to do to a message.
*
* Permissions are modelled as capabilities rather than roles. `Member` on the wire is
* `{ user_id, user_profile, pointers }` with no role field, and the server only ever answers
* `DENIED`, `CANNOT_EDIT`, or `CANNOT_DELETE` — so anything the client decides here is scaffolding
* the server overrules. Resolving a set of capabilities means a later role taxonomy becomes one
* more input to [resolveCapabilities] and no call site changes: a menu asks what can be done to a
* message, never who the viewer is.
*
* [Reply] is resolved but not yet wired to a surface. It is here so the reply work lands as a new
* menu row rather than a second capability model.
*/
enum class MessageCapability {
Copy,
Reply,
Edit,
Delete,
}

/**
* 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.
*/
data class MessagePolicy(
val editWindow: Duration? = null,
) {
companion object {
val Default = MessagePolicy()
}
}

/**
* Resolves what [message] allows, per the capability table shared with iOS:
*
* | 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, unconfirmed (`eventSequence == 0`) | none |
* | Another participant's text | Copy, Reply |
* | Any cash or tip message | Reply |
* | A tombstone | none |
*/
fun resolveCapabilities(
message: ChatMessage,
policy: MessagePolicy = MessagePolicy.Default,
now: Instant = Clock.System.now(),
): Set<MessageCapability> {
val contents = message.content
if (contents.isEmpty()) return emptySet()

// Nothing left to act on: there is no text to copy and the delete already happened.
if (contents.any { it is MessageContent.Deleted }) return emptySet()

// `expected_event_sequence` is validated `>= 1`, so no valid edit or delete request can be
// built for a message the server has not acknowledged. The empty set is not a style choice —
// offering copy alone on a message that may still fail to send reads as a half-broken menu.
if (message.eventSequence == 0L) return emptySet()

// Cash is never editable: `EditMessageRequest.content` accepts Text, Reply, and Media, never
// Cash. It is deliberately not deletable either, so a payment cannot be hidden from the
// transcript that records it.
if (contents.any { it is MessageContent.Cash }) return setOf(MessageCapability.Reply)

// Server-authored notices, not a participant's message.
if (contents.all { it is MessageContent.System }) return emptySet()

val hasText = contents.any { it is MessageContent.Text || it is MessageContent.Reply }

return buildSet {
// Media carries no text, and this change edits text only. Not covered by the shared table;
// revisit when media messages actually ship.
if (hasText) add(MessageCapability.Copy)
add(MessageCapability.Reply)
if (message.isFromSelf) {
if (hasText && policy.allowsEdit(message, now)) add(MessageCapability.Edit)
add(MessageCapability.Delete)
}
}
}

/** 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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.flipcash.shared.chat

import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.MessageContent
import com.getcode.opencode.model.core.ID
import kotlin.time.Instant

/**
* An edit or delete the user has asked for and the server has not yet answered.
*
* The database holds the server's version and only the server's version. A mutation in flight is
* held here instead and composed over the stored row on the way to the screen, so the transcript
* updates on the tap while the row underneath stays truthful. Reconciliation is then a matter of
* dropping the overlay — there is no local write to undo.
*
* @param expectedSequence the message's `eventSequence` at the moment the request was built. It is
* what goes on the wire as `expected_event_sequence`, and it is what makes the overlay
* self-expiring: see [applying].
*/
data class PendingMutation(
val messageId: Long,
val expectedSequence: Long,
val kind: Kind,
) {
sealed interface Kind {
/** The body is shown as [text] until the server confirms it. */
data class Edited(val text: String, val editedAt: Instant) : Kind

/** The bubble is shown as a tombstone until the server confirms it. */
data class Deleted(val deletedAt: Instant, val deletedBy: ID?) : Kind
}
}

/**
* Composes [mutation] over this message, or returns it untouched when there is nothing pending.
*
* The overlay expires on a strictly higher `eventSequence`: once the stored row has moved past the
* sequence the request was built against, the server has spoken — whether it agreed, or something
* else changed the message first — and the row is the better answer. That guard is what lets a
* caller persist the server's reply and drop the overlay in either order without flashing stale
* text at the reader.
*/
fun ChatMessage.applying(mutation: PendingMutation?): ChatMessage {
if (mutation == null) return this
if (eventSequence > mutation.expectedSequence) return this

return when (val kind = mutation.kind) {
is PendingMutation.Kind.Edited -> copy(
content = content.replacingText(kind.text),
lastEditedTs = kind.editedAt,
)

is PendingMutation.Kind.Deleted -> copy(
content = listOf(MessageContent.Deleted(kind.deletedAt, kind.deletedBy)),
)
}
}

/**
* Swaps the body text while leaving everything wrapping it in place — so editing a reply keeps its
* citation instead of flattening it to a bare text message.
*/
internal fun List<MessageContent>.replacingText(text: String): List<MessageContent> =
map { content ->
when (content) {
is MessageContent.Text -> MessageContent.Text(text)
is MessageContent.Reply -> content.copy(content = content.content.replacingText(text))
else -> content
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,24 @@ import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.services.models.chat.PointerType
import com.flipcash.services.models.chat.TypingState
import com.flipcash.services.models.DeleteMessageError
import com.flipcash.services.models.EditMessageError
import com.flipcash.shared.chat.ChatHydrationState
import com.flipcash.shared.chat.MessagingOperations
import com.flipcash.shared.chat.PendingMutation
import com.flipcash.shared.chat.internal.ChatStateHolder
import com.flipcash.shared.chat.replacingText
import com.flipcash.services.user.UserManager
import com.getcode.opencode.model.core.ID
import com.getcode.utils.TraceType
import com.getcode.utils.trace
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
Expand Down Expand Up @@ -68,6 +74,15 @@ class MessagingDelegate @Inject constructor(
private val analytics: FlipcashAnalyticsService,
) : MessagingOperations {

/**
* Edits and deletes awaiting a server answer, per chat, keyed by message id.
*
* In memory on purpose. The database is the server's version of the transcript; a mutation the
* server has not confirmed does not belong in it, and keeping it out means a rollback is a map
* removal rather than a compensating write.
*/
private val pendingMutations = MutableStateFlow<Map<ChatId, Map<Long, PendingMutation>>>(emptyMap())

// region MessagingOperations

override suspend fun getOtherMember(chatId: ChatId): ChatMember? {
Expand Down Expand Up @@ -216,6 +231,55 @@ class MessagingDelegate @Inject constructor(
}
}

override fun observePendingMutations(chatId: ChatId): Flow<Map<Long, PendingMutation>> =
pendingMutations.map { it[chatId].orEmpty() }.distinctUntilChanged()

override suspend fun editMessage(chatId: ChatId, messageId: Long, text: String): Result<ChatMessage> {
val edited = text.trim()
if (edited.isBlank()) {
return Result.failure(IllegalArgumentException("Cannot edit a message to be blank"))
}

val stored = messageDataSource.getMessage(chatId, messageId)
?: return Result.failure(IllegalStateException("No local copy of message $messageId"))

// Read the stored body rather than building a bare text message, so anything wrapping the
// text — a reply citation, once replies ship — survives the edit.
val content = stored.content.replacingText(edited)
val expectedSequence = stored.eventSequence

putMutation(
chatId = chatId,
mutation = PendingMutation(
messageId = messageId,
expectedSequence = expectedSequence,
kind = PendingMutation.Kind.Edited(edited, Clock.System.now()),
),
)

return messagingController.editMessage(chatId, messageId, content, expectedSequence)
.reconcile(chatId, messageId) { it is EditMessageError.Conflict }
}

override suspend fun deleteMessage(chatId: ChatId, messageId: Long): Result<ChatMessage> {
val stored = messageDataSource.getMessage(chatId, messageId)
?: return Result.failure(IllegalStateException("No local copy of message $messageId"))

val expectedSequence = stored.eventSequence

putMutation(
chatId = chatId,
mutation = PendingMutation(
messageId = messageId,
expectedSequence = expectedSequence,
kind = PendingMutation.Kind.Deleted(Clock.System.now(), userManager.accountId),
),
)

return messagingController.deleteMessage(chatId, messageId, expectedSequence)
.reconcile(chatId, messageId) { it is DeleteMessageError.Conflict }
}

override suspend fun advanceReadPointer(chatId: ChatId, messageId: Long): Result<Unit> {
val selfId = userManager.accountId ?: return Result.failure(
IllegalStateException("No account")
Expand Down Expand Up @@ -303,7 +367,61 @@ class MessagingDelegate @Inject constructor(
}
}

/**
* Settles a mutation against the server's answer, then drops the overlay either way.
*
* Ordering matters on success: persist first, drop second. Room publishes asynchronously, and
* the `eventSequence` guard in `applying` retires the overlay the moment the newer row lands,
* so the reader never sees a gap. Dropping first would flash the pre-edit text.
*
* A conflict means someone else moved the message first. There is no automatic retry — the
* user's edit was written against a version that no longer exists, and silently re-applying it
* over whatever replaced it is how you clobber someone. Re-read instead so the transcript shows
* what is actually there, and let the caller tell the user.
*
* Any other failure reverts. A delete that did not happen means the message is still there, and
* showing it again is the truth.
*/
private suspend fun Result<ChatMessage>.reconcile(
chatId: ChatId,
messageId: Long,
isConflict: (Throwable) -> Boolean,
): Result<ChatMessage> = this
.onSuccess { serverMessage ->
messageDataSource.upsert(chatId, listOf(serverMessage))
clearMutation(chatId, messageId)
}
.onFailure { cause ->
if (isConflict(cause)) {
messagingController.getMessage(chatId, messageId)
.onSuccess { messageDataSource.upsert(chatId, listOf(it)) }
.onFailure {
trace(
tag = TAG,
message = "Re-reading conflicted message $messageId failed",
type = TraceType.Error,
error = it,
)
}
}
clearMutation(chatId, messageId)
}

private fun putMutation(chatId: ChatId, mutation: PendingMutation) {
pendingMutations.update { all ->
all + (chatId to (all[chatId].orEmpty() + (mutation.messageId to mutation)))
}
}

private fun clearMutation(chatId: ChatId, messageId: Long) {
pendingMutations.update { all ->
val remaining = all[chatId].orEmpty() - messageId
if (remaining.isEmpty()) all - chatId else all + (chatId to remaining)
}
}

internal suspend fun clear() {
pendingMutations.value = emptyMap()
metadataDataSource.clear()
messageDataSource.clear()
memberDataSource.clear()
Expand Down
Loading
Loading