diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt index 725a4eb4a..b695fcf31 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt @@ -341,6 +341,12 @@ internal class SendFlowViewModel @Inject constructor( val label = if (name.isNotBlank()) "$formatted of $name" else formatted if (sentBySelf) "You sent $label" else "You received $label" } + + // TODO: + is MessageContent.Deleted -> null + is MessageContent.Media -> null + is MessageContent.Reply -> null + is MessageContent.System -> null } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListItem.kt index 7d0ce3079..ccc8485ff 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListItem.kt @@ -27,6 +27,10 @@ sealed interface ChatListItem { override val itemContentType: Any = when (content) { is MessageContent.Text -> "text-bubble" is MessageContent.Cash -> "cash-bubble" + is MessageContent.Deleted -> "deleted-message" + is MessageContent.Media -> "media" + is MessageContent.Reply -> "reply-message" + is MessageContent.System -> "system-message" } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 0fd9b8bbb..7d7632e08 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -65,6 +65,10 @@ fun ContentBubble( val bubbleMaxWidth = when (item.content) { is MessageContent.Text -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION is MessageContent.Cash -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION + is MessageContent.Deleted -> maxWidth + is MessageContent.Media -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION + is MessageContent.Reply -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION + is MessageContent.System -> maxWidth } Row( @@ -89,6 +93,12 @@ fun ContentBubble( position = position, maxWidth = bubbleMaxWidth, ) + + // TODO + is MessageContent.Deleted -> Unit + is MessageContent.Media -> Unit + is MessageContent.Reply -> Unit + is MessageContent.System -> Unit } } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index be956abb7..1fae39e3f 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -29,11 +29,16 @@ import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.EmojiReaction import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MetadataUpdate import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.ReactionSummary +import com.flipcash.services.models.chat.ReactionUpdate import com.flipcash.services.models.chat.TypingNotification import com.flipcash.services.models.chat.TypingState +import com.flipcash.services.models.GetDeltaError +import com.flipcash.services.repository.DeltaUpdate import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.user.UserManager @@ -57,6 +62,7 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -88,12 +94,14 @@ class ChatCoordinator @Inject constructor( companion object { private const val TAG = "ChatCoordinator" private val HEARTBEAT_INTERVAL = 30.seconds + private val GAP_FILL_DELAY = 2.seconds } private val supervisorJob = SupervisorJob() private val scope = CoroutineScope(dispatchers.IO + supervisorJob) private val cluster = MutableStateFlow(null) private val _state = MutableStateFlow(ChatState()) + private val sequenceTracker = EventSequenceTracker() private var syncJob: Job? = null private var flagObserverJob: Job? = null private var eventStreamCollectJob: Job? = null @@ -343,6 +351,7 @@ class ChatCoordinator @Inject constructor( networkObserverJob?.cancel() feedObserverJob = null _state.value = ChatState() + sequenceTracker.clearAll() cluster.value = null metadataDataSource.clear() messageDataSource.clear() @@ -426,10 +435,19 @@ class ChatCoordinator @Inject constructor( _state.update { it.copy(feedSyncState = FeedSyncState.Synced) } trace(tag = TAG, message = "Feed synced: ${page.chats.size} chats", type = TraceType.Process) - // Prefetch first page of messages for chats with no cached messages - page.chats - .filterNot { messageDataSource.hasMessages(it.chatId) } - .forEach { chat -> loadMessages(chat.chatId) } + // Delta-sync for chats with a known event sequence; full load for new chats + for (chat in page.chats) { + if (chat.latestEventSequence > 0) { + val localSeq = metadataDataSource.getLatestEventSequence(chat.chatId) + if (localSeq > 0 && localSeq < chat.latestEventSequence) { + performDeltaSync(chat.chatId) + continue + } + } + if (!messageDataSource.hasMessages(chat.chatId)) { + loadMessages(chat.chatId) + } + } } .onFailure { error -> _state.update { it.copy(feedSyncState = FeedSyncState.Error) } @@ -485,23 +503,51 @@ class ChatCoordinator @Inject constructor( private suspend fun applyUpdate(update: ChatUpdate) { val chatId = update.chatId + + // --- Resolve messages: prefer events, fall back to deprecated newMessages --- + + val resolvedMessages = if (update.events.isNotEmpty()) { + update.events + .flatMap { event -> event.mutations.map { it.message } } + .sortedBy { it.eventSequence } + .distinctBy { it.messageId } + } else { + @Suppress("DEPRECATION") + update.newMessages + } + trace( tag = TAG, - message = "applyUpdate: chatId=$chatId, newMessages=${update.newMessages.size}, pointers=${update.pointerUpdates.size}, typing=${update.typingNotifications.size}", + message = "applyUpdate: chatId=$chatId, messages=${resolvedMessages.size}, events=${update.events.size}, pointers=${update.pointerUpdates.size}, reactions=${update.reactionUpdates.size}, typing=${update.typingNotifications.size}", type = TraceType.Process, ) // --- Persist to DB first (suspend, off main thread) --- - val lastMsg = if (update.newMessages.isNotEmpty()) { - trace(tag = TAG, message = "Upserting ${update.newMessages.size} new messages for $chatId", type = TraceType.Process) - messageDataSource.upsert(chatId, update.newMessages) - update.newMessages.maxByOrNull { it.messageId }?.also { msg -> + val lastMsg = if (resolvedMessages.isNotEmpty()) { + trace(tag = TAG, message = "Upserting ${resolvedMessages.size} messages for $chatId", type = TraceType.Process) + messageDataSource.upsert(chatId, resolvedMessages) + resolvedMessages.maxByOrNull { it.messageId }?.also { msg -> metadataDataSource.updateLastMessageId(chatId, msg.messageId) metadataDataSource.updateLastActivity(chatId, msg.timestamp.toEpochMilliseconds()) } } else null + // Advance event sequence cursor — gap-aware (only advance contiguous frontier) + if (update.events.isNotEmpty()) { + val dbCursor = metadataDataSource.getLatestEventSequence(chatId) + val incomingSequences = update.events.map { it.sequence } + val result = sequenceTracker.processSequences(chatId, dbCursor, incomingSequences) + + if (result.newContiguousSequence > dbCursor) { + metadataDataSource.updateLatestEventSequence(chatId, result.newContiguousSequence) + } + + if (result.hasGap) { + scheduleGapFill(chatId) + } + } + for (pointer in update.pointerUpdates) { memberDataSource.updatePointers(chatId, pointer) } @@ -525,10 +571,24 @@ class ChatCoordinator @Inject constructor( } } + // --- Process reaction updates into in-memory overlay --- + + if (update.reactionUpdates.isNotEmpty()) { + _state.update { state -> + val chatOverlays = state.reactionOverlays[chatId]?.toMutableMap() ?: mutableMapOf() + for (reactionUpdate in update.reactionUpdates) { + applyReactionUpdate(chatOverlays, reactionUpdate) + } + state.copy( + reactionOverlays = state.reactionOverlays + (chatId to chatOverlays.toMap()) + ) + } + } + // --- Eagerly update token balance for incoming cash --- val selfId = userManager.accountId - for (msg in update.newMessages) { + for (msg in resolvedMessages) { if (msg.senderId == selfId) continue for (content in msg.content) { if (content is MessageContent.Cash) { @@ -560,6 +620,99 @@ class ChatCoordinator @Inject constructor( } } + private fun applyReactionUpdate( + overlays: MutableMap, + update: ReactionUpdate, + ) { + val existing = overlays[update.messageId] + val existingReactions = existing?.reactions?.toMutableList() ?: mutableListOf() + + // Find existing reaction for this emoji + val idx = existingReactions.indexOfFirst { it.emoji == update.emoji } + if (idx >= 0) { + val current = existingReactions[idx] + // LWW guard using sequence + if (update.sequence <= current.sequence) return + existingReactions[idx] = EmojiReaction( + emoji = update.emoji, + count = update.count, + reactedBySelf = current.reactedBySelf, // preserved; server will correct on next full fetch + sampleReactors = current.sampleReactors, + sequence = update.sequence, + ) + } else { + existingReactions.add( + EmojiReaction( + emoji = update.emoji, + count = update.count, + reactedBySelf = false, + sampleReactors = emptyList(), + sequence = update.sequence, + ) + ) + } + + // Remove reactions with count == 0 + existingReactions.removeAll { it.count <= 0 } + + overlays[update.messageId] = ReactionSummary( + messageId = update.messageId, + reactions = existingReactions.toList(), + ) + } + + private fun scheduleGapFill(chatId: ChatId) { + val job = scope.launch { + delay(GAP_FILL_DELAY) + if (!sequenceTracker.hasGap(chatId)) return@launch + trace(tag = TAG, message = "Gap fill timeout: fetching delta for $chatId", type = TraceType.Process) + performDeltaSync(chatId) + } + sequenceTracker.setGapFillJob(chatId, job) + } + + private suspend fun performDeltaSync(chatId: ChatId) { + val afterSequence = metadataDataSource.getLatestEventSequence(chatId) + trace(tag = TAG, message = "Delta sync for $chatId from sequence $afterSequence", type = TraceType.Process) + + try { + val result = messagingController.getDelta(chatId, afterSequence).first() + result + .onSuccess { delta -> + if (delta.messages.isNotEmpty()) { + messageDataSource.upsert(chatId, delta.messages) + val latest = delta.messages.maxByOrNull { it.messageId } + latest?.let { msg -> + metadataDataSource.updateLastMessageId(chatId, msg.messageId) + metadataDataSource.updateLastActivity(chatId, msg.timestamp.toEpochMilliseconds()) + } + } + if (delta.latestSequence > afterSequence) { + metadataDataSource.updateLatestEventSequence(chatId, delta.latestSequence) + } + // getDelta is authoritative — reset tracker to the server's sequence + sequenceTracker.resetTo(chatId, delta.latestSequence) + trace(tag = TAG, message = "Delta sync complete: ${delta.messages.size} messages, sequence ${delta.latestSequence}", type = TraceType.Process) + } + .onFailure { error -> + if (error is GetDeltaError.ResetRequired) { + trace(tag = TAG, message = "Delta sync reset required for $chatId, falling back to full load", type = TraceType.Process) + sequenceTracker.clear(chatId) + loadMessages(chatId) + } else { + trace(tag = TAG, message = "Delta sync failed for $chatId: ${error.message}", type = TraceType.Error) + } + } + } catch (e: Exception) { + trace(tag = TAG, message = "Delta sync exception for $chatId: ${e.message}", type = TraceType.Error) + } + } + + fun observeReactions(chatId: ChatId, messageId: Long): Flow { + return _state.map { it.reactionOverlays[chatId]?.get(messageId) } + .distinctUntilChanged() + } + private fun applyTypingNotification( typists: MutableSet, notification: TypingNotification, diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt index 8ea8c2119..d9c021b87 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt @@ -2,12 +2,14 @@ package com.flipcash.shared.chat import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ReactionSummary import com.getcode.opencode.model.core.ID import kotlin.time.Instant data class ChatState( val feed: List = emptyList(), val typingIndicators: Map> = emptyMap(), + val reactionOverlays: Map> = emptyMap(), val feedSyncState: FeedSyncState = FeedSyncState.Idle, val activeChat: ChatId? = null, ) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/EventSequenceTracker.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/EventSequenceTracker.kt new file mode 100644 index 000000000..d77388e75 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/EventSequenceTracker.kt @@ -0,0 +1,100 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import kotlinx.coroutines.Job +import java.util.TreeSet + +/** + * Tracks per-chat event sequences to detect out-of-order delivery gaps. + * + * Only advances the "contiguous frontier" — the highest sequence number with + * no gaps before it. Sequences that arrive ahead of the frontier are held in + * a pending set until the gap is filled (either by a late-arriving event or + * by a getDelta backfill). + * + * Session-scoped; initialized lazily from the DB cursor on first event per chat. + */ +internal class EventSequenceTracker { + + private val chatStates = mutableMapOf() + + private class GapState( + var lastContiguous: Long, + val pending: TreeSet = TreeSet(), + var gapFillJob: Job? = null, + ) + + data class SequenceResult( + val newContiguousSequence: Long, + val hasGap: Boolean, + ) + + /** + * Process incoming event sequences for a chat. + * + * @param chatId the chat these events belong to + * @param dbCursor the persisted cursor (used to initialize on first call) + * @param incomingSequences the sequence numbers from this batch of events + * @return the new contiguous frontier and whether a gap remains + */ + fun processSequences( + chatId: ChatId, + dbCursor: Long, + incomingSequences: List, + ): SequenceResult { + val state = chatStates.getOrPut(chatId) { GapState(lastContiguous = dbCursor) } + + // If the DB cursor advanced externally (e.g. delta sync), catch up + if (dbCursor > state.lastContiguous) { + state.lastContiguous = dbCursor + state.pending.headSet(dbCursor + 1).clear() + } + + state.pending.addAll(incomingSequences) + + // Consume contiguous sequences from the frontier + while (state.pending.isNotEmpty() && state.pending.first() == state.lastContiguous + 1) { + state.lastContiguous = state.pending.pollFirst()!! + } + + // Drop any sequences at or below the frontier (duplicates / late arrivals) + if (state.pending.isNotEmpty()) { + state.pending.headSet(state.lastContiguous + 1).clear() + } + + return SequenceResult( + newContiguousSequence = state.lastContiguous, + hasGap = state.pending.isNotEmpty(), + ) + } + + fun hasGap(chatId: ChatId): Boolean { + return chatStates[chatId]?.pending?.isNotEmpty() == true + } + + fun setGapFillJob(chatId: ChatId, job: Job) { + val state = chatStates[chatId] ?: return + state.gapFillJob?.cancel() + state.gapFillJob = job + } + + /** Reset tracker to a known-good sequence after an authoritative getDelta response. */ + fun resetTo(chatId: ChatId, sequence: Long) { + val state = chatStates[chatId] + if (state != null) { + state.gapFillJob?.cancel() + state.gapFillJob = null + state.lastContiguous = sequence + state.pending.clear() + } + } + + fun clear(chatId: ChatId) { + chatStates.remove(chatId)?.gapFillJob?.cancel() + } + + fun clearAll() { + chatStates.values.forEach { it.gapFillJob?.cancel() } + chatStates.clear() + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt new file mode 100644 index 000000000..79c6fe9de --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt @@ -0,0 +1,450 @@ +package com.flipcash.shared.chat + +import androidx.core.app.NotificationManagerCompat +import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.app.persistence.sources.ContactDataSource +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.controllers.EventStreamingController +import com.flipcash.services.models.chat.ChatEvent +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMutation +import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.ReactionUpdate +import com.getcode.utils.network.NetworkConnectivityListener +import com.flipcash.services.user.UserManager +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ChatCoordinatorEventsTest { + + private val selfId = listOf(1, 2, 3) + private val otherId = listOf(4, 5, 6) + private val chatId = ChatId("aabbccdd") + + private val chatUpdatesChannel = Channel(capacity = Channel.UNLIMITED) + + private lateinit var metadataDataSource: ChatMetadataDataSource + private lateinit var messageDataSource: ChatMessageDataSource + private lateinit var coordinator: ChatCoordinator + private lateinit var testDispatchers: TestDispatchers + + @Before + fun setUp() { + val userManager = mockk(relaxed = true) + every { userManager.accountId } returns selfId + val eventStreamingController = mockk(relaxed = true) + every { eventStreamingController.chatUpdates } returns chatUpdatesChannel.receiveAsFlow() + every { eventStreamingController.isConnected } returns true + every { eventStreamingController.isStreamActive } returns true + + val chatController = mockk(relaxed = true) + coEvery { chatController.getDmChatFeed() } returns Result.failure(RuntimeException("not needed")) + + metadataDataSource = mockk(relaxed = true) + messageDataSource = mockk(relaxed = true) + + testDispatchers = TestDispatchers(TestCoroutineScheduler()) + + coordinator = ChatCoordinator( + chatController = chatController, + messagingController = mockk(relaxed = true), + eventStreamingController = eventStreamingController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = mockk(relaxed = true), + contactDataSource = mockk(relaxed = true), + networkObserver = mockk(relaxed = true), + notificationManager = mockk(relaxed = true), + userManager = userManager, + tokenCoordinator = mockk(relaxed = true), + featureFlags = mockk(relaxed = true), + dispatchers = testDispatchers, + ) + } + + private fun textMessage( + id: Long, + senderId: List? = otherId, + eventSequence: Long = 0, + ) = ChatMessage( + messageId = id, + senderId = senderId, + content = listOf(MessageContent.Text("msg-$id")), + timestamp = Instant.fromEpochSeconds(1000 + id), + unreadSeq = 0, + eventSequence = eventSequence, + ) + + private fun chatEvent(sequence: Long, message: ChatMessage) = ChatEvent( + sequence = sequence, + count = 1, + ts = message.timestamp, + mutations = listOf(ChatMutation.MessageSent(message)), + ) + + private suspend fun triggerCollection() { + coordinator.onUserLoggedIn(mockk(relaxed = true)) + } + + // region Events vs newMessages + + @Test + fun `events are preferred over deprecated newMessages`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + val eventMsg = textMessage(id = 1, eventSequence = 1) + val deprecatedMsg = textMessage(id = 99) + + @Suppress("DEPRECATION") + val update = ChatUpdate( + chatId = chatId, + newMessages = listOf(deprecatedMsg), + events = listOf(chatEvent(1, eventMsg)), + ) + chatUpdatesChannel.send(update) + advanceTimeBy(1_000) + runCurrent() + + // Should upsert the event message, not the deprecated one + coVerify { + messageDataSource.upsert(chatId, match { messages -> + messages.size == 1 && messages[0].messageId == 1L + }) + } + coordinator.reset() + } + + @Test + fun `falls back to newMessages when events is empty`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + val msg = textMessage(id = 42) + @Suppress("DEPRECATION") + val update = ChatUpdate( + chatId = chatId, + newMessages = listOf(msg), + events = emptyList(), + ) + chatUpdatesChannel.send(update) + advanceTimeBy(1_000) + runCurrent() + + coVerify { + messageDataSource.upsert(chatId, match { messages -> + messages.size == 1 && messages[0].messageId == 42L + }) + } + coordinator.reset() + } + + @Test + fun `multiple events are flattened and deduped by messageId`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + val msg1 = textMessage(id = 1, eventSequence = 1) + val msg1Edited = textMessage(id = 1, eventSequence = 2) // same messageId, higher sequence + val msg2 = textMessage(id = 2, eventSequence = 3) + + val update = ChatUpdate( + chatId = chatId, + events = listOf( + chatEvent(1, msg1), + chatEvent(2, msg1Edited), + chatEvent(3, msg2), + ), + ) + chatUpdatesChannel.send(update) + advanceTimeBy(1_000) + runCurrent() + + // Should have 2 unique messages (deduped by messageId, taking first by sorted eventSequence) + coVerify { + messageDataSource.upsert(chatId, match { messages -> + messages.size == 2 + }) + } + coordinator.reset() + } + + // endregion + + // region Sequence advancement with gaps + + @Test + fun `contiguous events advance sequence cursor`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + coEvery { metadataDataSource.getLatestEventSequence(chatId) } returns 0L + + val update = ChatUpdate( + chatId = chatId, + events = listOf( + chatEvent(1, textMessage(id = 1, eventSequence = 1)), + chatEvent(2, textMessage(id = 2, eventSequence = 2)), + ), + ) + chatUpdatesChannel.send(update) + advanceTimeBy(1_000) + runCurrent() + + coVerify { metadataDataSource.updateLatestEventSequence(chatId, 2L) } + coordinator.reset() + } + + @Test + fun `gap in events does not advance cursor past gap`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + coEvery { metadataDataSource.getLatestEventSequence(chatId) } returns 0L + + // Send seq 1, then seq 3 (gap at 2) + val update1 = ChatUpdate( + chatId = chatId, + events = listOf(chatEvent(1, textMessage(id = 1, eventSequence = 1))), + ) + chatUpdatesChannel.send(update1) + advanceTimeBy(500) + runCurrent() + + val update2 = ChatUpdate( + chatId = chatId, + events = listOf(chatEvent(3, textMessage(id = 3, eventSequence = 3))), + ) + chatUpdatesChannel.send(update2) + advanceTimeBy(500) + runCurrent() + + // Cursor should advance to 1 (contiguous), not 3 + coVerify { metadataDataSource.updateLatestEventSequence(chatId, 1L) } + coVerify(exactly = 0) { metadataDataSource.updateLatestEventSequence(chatId, 3L) } + coordinator.reset() + } + + @Test + fun `late event fills gap and advances cursor`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + coEvery { metadataDataSource.getLatestEventSequence(chatId) } returns 0L + + // Send 1, then 3 (gap), then 2 (fills gap) + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + events = listOf(chatEvent(1, textMessage(id = 1, eventSequence = 1))), + )) + advanceTimeBy(100) + runCurrent() + + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + events = listOf(chatEvent(3, textMessage(id = 3, eventSequence = 3))), + )) + advanceTimeBy(100) + runCurrent() + + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + events = listOf(chatEvent(2, textMessage(id = 2, eventSequence = 2))), + )) + advanceTimeBy(100) + runCurrent() + + // After filling the gap, cursor should advance to 3 + coVerify { metadataDataSource.updateLatestEventSequence(chatId, 3L) } + coordinator.reset() + } + + // endregion + + // region Reaction overlays + + @Test + fun `reaction update is applied to in-memory overlay`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + val update = ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("\uD83D\uDE00"), + actor = otherId, + action = ReactionUpdate.Action.ADDED, + count = 1, + sequence = 1, + reactedAt = Instant.fromEpochSeconds(1000), + ), + ), + ) + chatUpdatesChannel.send(update) + advanceTimeBy(1_000) + runCurrent() + + val state = coordinator.state.value + val overlay = state.reactionOverlays[chatId] + assertNotNull(overlay) + val summary = overlay[1L] + assertNotNull(summary) + assertEquals(1, summary.reactions.size) + assertEquals("\uD83D\uDE00", summary.reactions[0].emoji.value) + assertEquals(1L, summary.reactions[0].count) + coordinator.reset() + } + + @Test + fun `reaction LWW guard rejects stale updates`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + // First update: count=3, sequence=5 + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("\uD83D\uDC4D"), + actor = otherId, + action = ReactionUpdate.Action.ADDED, + count = 3, + sequence = 5, + reactedAt = Instant.fromEpochSeconds(1000), + ), + ), + )) + advanceTimeBy(500) + runCurrent() + + // Stale update: count=1, sequence=2 (older) + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("\uD83D\uDC4D"), + actor = otherId, + action = ReactionUpdate.Action.REMOVED, + count = 1, + sequence = 2, // older than 5 + reactedAt = Instant.fromEpochSeconds(500), + ), + ), + )) + advanceTimeBy(500) + runCurrent() + + val reactions = coordinator.state.value.reactionOverlays[chatId]?.get(1L)?.reactions + assertNotNull(reactions) + assertEquals(1, reactions.size) + assertEquals(3L, reactions[0].count) // stayed at 3, stale update rejected + assertEquals(5L, reactions[0].sequence) + coordinator.reset() + } + + @Test + fun `reaction with count zero is pruned`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + // Add reaction + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("\uD83D\uDE00"), + actor = otherId, + action = ReactionUpdate.Action.ADDED, + count = 1, + sequence = 1, + reactedAt = Instant.fromEpochSeconds(1000), + ), + ), + )) + advanceTimeBy(500) + runCurrent() + + // Remove reaction (count=0) + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("\uD83D\uDE00"), + actor = otherId, + action = ReactionUpdate.Action.REMOVED, + count = 0, + sequence = 2, + reactedAt = Instant.fromEpochSeconds(2000), + ), + ), + )) + advanceTimeBy(500) + runCurrent() + + val reactions = coordinator.state.value.reactionOverlays[chatId]?.get(1L)?.reactions + assertNotNull(reactions) + assertTrue(reactions.isEmpty()) + coordinator.reset() + } + + @Test + fun `multiple emoji reactions on same message`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + + chatUpdatesChannel.send(ChatUpdate( + chatId = chatId, + reactionUpdates = listOf( + ReactionUpdate( + messageId = 1L, + emoji = Emoji("\uD83D\uDE00"), + actor = otherId, + action = ReactionUpdate.Action.ADDED, + count = 2, + sequence = 1, + reactedAt = Instant.fromEpochSeconds(1000), + ), + ReactionUpdate( + messageId = 1L, + emoji = Emoji("\uD83D\uDC4D"), + actor = otherId, + action = ReactionUpdate.Action.ADDED, + count = 5, + sequence = 2, + reactedAt = Instant.fromEpochSeconds(1000), + ), + ), + )) + advanceTimeBy(1_000) + runCurrent() + + val reactions = coordinator.state.value.reactionOverlays[chatId]?.get(1L)?.reactions + assertNotNull(reactions) + assertEquals(2, reactions.size) + coordinator.reset() + } + + // endregion +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/EventSequenceTrackerTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/EventSequenceTrackerTest.kt new file mode 100644 index 000000000..12631d391 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/EventSequenceTrackerTest.kt @@ -0,0 +1,193 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EventSequenceTrackerTest { + + private val chatId = ChatId("aabbccdd") + + @Test + fun `in-order sequences advance contiguous frontier`() { + val tracker = EventSequenceTracker() + + val r1 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1)) + assertEquals(1, r1.newContiguousSequence) + assertFalse(r1.hasGap) + + val r2 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(2)) + assertEquals(2, r2.newContiguousSequence) + assertFalse(r2.hasGap) + + val r3 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(3)) + assertEquals(3, r3.newContiguousSequence) + assertFalse(r3.hasGap) + } + + @Test + fun `batch of in-order sequences advances in one call`() { + val tracker = EventSequenceTracker() + val result = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 2, 3)) + assertEquals(3, result.newContiguousSequence) + assertFalse(result.hasGap) + } + + @Test + fun `gap is detected when sequence is skipped`() { + val tracker = EventSequenceTracker() + + // Receive seq 1 (ok), then seq 3 (skipping 2) + val r1 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1)) + assertEquals(1, r1.newContiguousSequence) + assertFalse(r1.hasGap) + + val r2 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(3)) + assertEquals(1, r2.newContiguousSequence) // stuck at 1 + assertTrue(r2.hasGap) + assertTrue(tracker.hasGap(chatId)) + } + + @Test + fun `late arrival fills gap and advances frontier`() { + val tracker = EventSequenceTracker() + + tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1)) + tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(3)) // gap at 2 + + val result = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(2)) + assertEquals(3, result.newContiguousSequence) + assertFalse(result.hasGap) + assertFalse(tracker.hasGap(chatId)) + } + + @Test + fun `multiple gaps only advance to first gap`() { + val tracker = EventSequenceTracker() + + // Receive 1, 3, 5 — gaps at 2 and 4 + val result = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 3, 5)) + assertEquals(1, result.newContiguousSequence) + assertTrue(result.hasGap) + + // Fill gap at 2 — advances to 3, still gap at 4 + val r2 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(2)) + assertEquals(3, r2.newContiguousSequence) + assertTrue(r2.hasGap) + + // Fill gap at 4 — advances to 5, no more gaps + val r3 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(4)) + assertEquals(5, r3.newContiguousSequence) + assertFalse(r3.hasGap) + } + + @Test + fun `duplicate sequences are ignored`() { + val tracker = EventSequenceTracker() + + tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 2)) + val result = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 2)) + assertEquals(2, result.newContiguousSequence) + assertFalse(result.hasGap) + } + + @Test + fun `dbCursor initializes the frontier`() { + val tracker = EventSequenceTracker() + + // DB already at 5, incoming 6 should be contiguous + val result = tracker.processSequences(chatId, dbCursor = 5, incomingSequences = listOf(6)) + assertEquals(6, result.newContiguousSequence) + assertFalse(result.hasGap) + } + + @Test + fun `dbCursor advancing externally catches up tracker`() { + val tracker = EventSequenceTracker() + + // First call: gap at 2 + tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 3)) + assertTrue(tracker.hasGap(chatId)) + + // External delta sync advanced DB to 5 + val result = tracker.processSequences(chatId, dbCursor = 5, incomingSequences = listOf(6)) + assertEquals(6, result.newContiguousSequence) + assertFalse(result.hasGap) // old pending (3) is below new cursor, cleared + } + + @Test + fun `resetTo clears pending and sets frontier`() { + val tracker = EventSequenceTracker() + + tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 3, 5)) + assertTrue(tracker.hasGap(chatId)) + + tracker.resetTo(chatId, 10) + assertFalse(tracker.hasGap(chatId)) + + // Next event should continue from 10 + val result = tracker.processSequences(chatId, dbCursor = 10, incomingSequences = listOf(11)) + assertEquals(11, result.newContiguousSequence) + assertFalse(result.hasGap) + } + + @Test + fun `clear removes all state for a chat`() { + val tracker = EventSequenceTracker() + + tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 3)) + assertTrue(tracker.hasGap(chatId)) + + tracker.clear(chatId) + assertFalse(tracker.hasGap(chatId)) + } + + @Test + fun `clearAll removes state for all chats`() { + val tracker = EventSequenceTracker() + val chatId2 = ChatId("11223344") + + tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 3)) + tracker.processSequences(chatId2, dbCursor = 0, incomingSequences = listOf(1, 5)) + + tracker.clearAll() + assertFalse(tracker.hasGap(chatId)) + assertFalse(tracker.hasGap(chatId2)) + } + + @Test + fun `independent chats track separately`() { + val tracker = EventSequenceTracker() + val chatId2 = ChatId("11223344") + + val r1 = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(1, 2, 3)) + val r2 = tracker.processSequences(chatId2, dbCursor = 5, incomingSequences = listOf(7)) + + assertEquals(3, r1.newContiguousSequence) + assertFalse(r1.hasGap) + + assertEquals(5, r2.newContiguousSequence) // stuck at 5, gap at 6 + assertTrue(r2.hasGap) + } + + @Test + fun `out-of-order batch is handled correctly`() { + val tracker = EventSequenceTracker() + + // Receive [3, 1, 2] all at once — should end up contiguous at 3 + val result = tracker.processSequences(chatId, dbCursor = 0, incomingSequences = listOf(3, 1, 2)) + assertEquals(3, result.newContiguousSequence) + assertFalse(result.hasGap) + } + + @Test + fun `sequences at or below cursor are ignored`() { + val tracker = EventSequenceTracker() + + val result = tracker.processSequences(chatId, dbCursor = 5, incomingSequences = listOf(3, 4, 5)) + assertEquals(5, result.newContiguousSequence) + assertFalse(result.hasGap) + } +} diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/21.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/21.json new file mode 100644 index 000000000..e4e2044c9 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/21.json @@ -0,0 +1,654 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "3b8651813c4108f23dff6c12b99597fd", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + } + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `user_profile_json` TEXT, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userProfileJson", + "columnName": "user_profile_json", + "affinity": "TEXT" + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3b8651813c4108f23dff6c12b99597fd')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index 20c1771de..e9d40645c 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -68,8 +68,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 17, to = 18), AutoMigration(from = 18, to = 19), AutoMigration(from = 19, to = 20), + AutoMigration(from = 20, to = 21), ], - version = 20, + version = 21, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index 0c20c9a28..c53fb7730 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -2,6 +2,7 @@ package com.flipcash.app.persistence.converters import androidx.room.TypeConverter import com.flipcash.app.persistence.entities.MessageStatus +import com.flipcash.services.models.chat.MediaItem import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -65,6 +66,20 @@ class ChatTypeConverters { } // endregion + + // region ReactionSummary + + @TypeConverter + fun fromReactionSummary(value: String?): ReactionSummarySerialized? { + return value?.let { json.decodeFromString(it) } + } + + @TypeConverter + fun toReactionSummary(summary: ReactionSummarySerialized?): String? { + return summary?.let { json.encodeToString(it) } + } + + // endregion } @Serializable @@ -83,6 +98,33 @@ sealed interface MessageContentSerialized { val tokenName: String = "", val tokenImageUrl: String = "", ) : MessageContentSerialized + + @Serializable + @SerialName("deleted") + data class Deleted( + val deletedAt: Long, + val deletedBy: String?, + ) : MessageContentSerialized + + @Serializable + @SerialName("reply") + data class Reply( + val repliedMessageId: Long, + val content: List, + ) : MessageContentSerialized + + @Serializable + @SerialName("media") + data class Media( + val items: List, + val caption: Text?, + ) : MessageContentSerialized + + @Serializable + @SerialName("system") + data class System(val fallbackText: String) : MessageContentSerialized + + } @Serializable @@ -117,3 +159,24 @@ sealed interface SocialAccountSerialized { val followerCount: Int, ) : SocialAccountSerialized } + +@Serializable +data class ReactionSummarySerialized( + val messageId: Long, + val reactions: List, +) + +@Serializable +data class EmojiReactionSerialized( + val emoji: String, + val count: Long, + val reactedBySelf: Boolean, + val sampleReactors: List, + val sequence: Long, +) + +@Serializable +data class ReactorSerialized( + val userIdHex: String, + val reactedAtEpochSeconds: Long, +) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index 322ed5dee..b47741fe9 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -34,8 +34,18 @@ interface ChatMessageDao { @Query("SELECT pending_client_id_hex FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id = :messageId") suspend fun getPendingClientId(chatIdHex: String, messageId: Long): String? + @Query("SELECT event_sequence FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id = :messageId") + suspend fun getEventSequence(chatIdHex: String, messageId: Long): Long? + @Transaction suspend fun upsert(entity: ChatMessageEntity) { + // Event-sequence guard: skip if stored sequence is newer (last-writer-wins). + // Passthrough when eventSequence == 0 (legacy messages). + if (entity.eventSequence > 0) { + val stored = getEventSequence(entity.chatIdHex, entity.messageId) + if (stored != null && stored > entity.eventSequence) return + } + val existingPendingId = getPendingClientId(entity.chatIdHex, entity.messageId) val merged = if (existingPendingId != null && entity.pendingClientIdHex == null) { entity.copy(pendingClientIdHex = existingPendingId) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index 325c4b64d..38b3ff909 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -28,6 +28,12 @@ interface ChatMetadataDao { @Query("UPDATE chat_metadata SET last_message_id = :messageId WHERE chat_id_hex = :chatIdHex") suspend fun updateLastMessageId(chatIdHex: String, messageId: Long) + @Query("UPDATE chat_metadata SET latest_event_sequence = :sequence WHERE chat_id_hex = :chatIdHex") + suspend fun updateLatestEventSequence(chatIdHex: String, sequence: Long) + + @Query("SELECT latest_event_sequence FROM chat_metadata WHERE chat_id_hex = :chatIdHex") + suspend fun getLatestEventSequence(chatIdHex: String): Long? + @Query("DELETE FROM chat_metadata") suspend fun deleteAll() } diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt index 5608f9afd..e3d177e05 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt @@ -23,4 +23,7 @@ data class ChatMessageEntity( @ColumnInfo(name = "unread_seq") val unreadSeq: Long, @ColumnInfo(name = "status", defaultValue = "SENT") val status: MessageStatus = MessageStatus.SENT, @ColumnInfo(name = "pending_client_id_hex") val pendingClientIdHex: String? = null, + @ColumnInfo(name = "event_sequence", defaultValue = "0") val eventSequence: Long = 0, + @ColumnInfo(name = "last_edited_ts_epoch_ms") val lastEditedTsEpochMs: Long? = null, + @ColumnInfo(name = "reactions_json") val reactionsJson: String? = null, ) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt index f2b25f2b4..0eef68d44 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt @@ -10,4 +10,5 @@ data class ChatMetadataEntity( @ColumnInfo(name = "chat_type") val chatType: String, @ColumnInfo(name = "last_activity_epoch_ms", index = true) val lastActivityEpochMs: Long, @ColumnInfo(name = "last_message_id") val lastMessageId: Long?, + @ColumnInfo(name = "latest_event_sequence", defaultValue = "0") val latestEventSequence: Long = 0, ) diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt new file mode 100644 index 000000000..137d60b9c --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt @@ -0,0 +1,142 @@ +package com.flipcash.app.persistence.converters + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ChatTypeConvertersTest { + + private val converter = ChatTypeConverters() + + // region ReactionSummary + + @Test + fun `fromReactionSummary and toReactionSummary roundtrip`() { + val original = ReactionSummarySerialized( + messageId = 42L, + reactions = listOf( + EmojiReactionSerialized( + emoji = "\uD83D\uDE00", + count = 3, + reactedBySelf = true, + sampleReactors = listOf( + ReactorSerialized(userIdHex = "aabb", reactedAtEpochSeconds = 1000L), + ReactorSerialized(userIdHex = "ccdd", reactedAtEpochSeconds = 2000L), + ), + sequence = 5, + ), + EmojiReactionSerialized( + emoji = "\uD83D\uDC4D", + count = 1, + reactedBySelf = false, + sampleReactors = emptyList(), + sequence = 3, + ), + ), + ) + val serialized = converter.toReactionSummary(original) + val deserialized = converter.fromReactionSummary(serialized) + assertEquals(original, deserialized) + } + + @Test + fun `fromReactionSummary returns null for null input`() { + assertNull(converter.fromReactionSummary(null)) + } + + @Test + fun `toReactionSummary returns null for null input`() { + assertNull(converter.toReactionSummary(null)) + } + + @Test + fun `roundtrip with empty reactions list`() { + val original = ReactionSummarySerialized( + messageId = 1L, + reactions = emptyList(), + ) + val serialized = converter.toReactionSummary(original) + val deserialized = converter.fromReactionSummary(serialized) + assertEquals(original, deserialized) + } + + // endregion + + // region MessageContent + + @Test + fun `fromMessageContentList and toMessageContentList roundtrip text`() { + val original = listOf(MessageContentSerialized.Text("hello")) + val serialized = converter.toMessageContentList(original) + val deserialized = converter.fromMessageContentList(serialized) + assertEquals(original, deserialized) + } + + @Test + fun `fromMessageContentList returns null for null input`() { + assertNull(converter.fromMessageContentList(null)) + } + + @Test + fun `toMessageContentList returns null for null input`() { + assertNull(converter.toMessageContentList(null)) + } + + // endregion + + // region MessageStatus + + @Test + fun `fromMessageStatus and toMessageStatus roundtrip`() { + for (status in com.flipcash.app.persistence.entities.MessageStatus.entries) { + val serialized = converter.fromMessageStatus(status) + val deserialized = converter.toMessageStatus(serialized) + assertEquals(status, deserialized) + } + } + + @Test + fun `toMessageStatus falls back to SENT for unknown value`() { + val result = converter.toMessageStatus("NONEXISTENT") + assertEquals(com.flipcash.app.persistence.entities.MessageStatus.SENT, result) + } + + // endregion + + // region UserProfile + + @Test + fun `fromUserProfile and toUserProfile roundtrip`() { + val original = UserProfileSerialized( + displayName = "Alice", + socialAccounts = listOf( + SocialAccountSerialized.TwitterX( + id = "123", + username = "alice", + name = "Alice", + description = "hello", + profilePicUrl = "https://example.com/pic.jpg", + verifiedType = "BLUE", + followerCount = 100, + ) + ), + verifiedPhoneNumber = "+1234567890", + verifiedEmailAddress = "alice@example.com", + ) + val serialized = converter.toUserProfile(original) + val deserialized = converter.fromUserProfile(serialized) + assertEquals(original, deserialized) + } + + @Test + fun `fromUserProfile returns null for null input`() { + assertNull(converter.fromUserProfile(null)) + } + + @Test + fun `toUserProfile returns null for null input`() { + assertNull(converter.toUserProfile(null)) + } + + // endregion +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index 520f24b3d..a8f289abd 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -40,6 +40,13 @@ class ChatMetadataDataSource @Inject constructor( db?.chatMetadataDao()?.updateLastMessageId(mapper.chatIdHex(chatId), messageId) } + suspend fun updateLatestEventSequence(chatId: ChatId, sequence: Long) { + db?.chatMetadataDao()?.updateLatestEventSequence(mapper.chatIdHex(chatId), sequence) + } + + suspend fun getLatestEventSequence(chatId: ChatId): Long = + db?.chatMetadataDao()?.getLatestEventSequence(mapper.chatIdHex(chatId)) ?: 0L + suspend fun exists(chatId: ChatId): Boolean = db?.chatMetadataDao()?.getById(mapper.chatIdHex(chatId)) != null diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index e99fa2baa..079ab7d59 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -1,7 +1,10 @@ package com.flipcash.app.persistence.sources.mapper.chat +import com.flipcash.app.persistence.converters.EmojiReactionSerialized import com.flipcash.app.persistence.converters.MessageContentSerialized import com.flipcash.app.persistence.converters.MessagePointerSerialized +import com.flipcash.app.persistence.converters.ReactorSerialized +import com.flipcash.app.persistence.converters.ReactionSummarySerialized import com.flipcash.app.persistence.converters.SocialAccountSerialized import com.flipcash.app.persistence.converters.UserProfileSerialized import com.flipcash.app.persistence.entities.ChatMemberEntity @@ -16,9 +19,13 @@ import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction 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.Reactor +import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.ClientMessageId import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.CurrencyCode @@ -46,6 +53,7 @@ class ChatEntityMapper @Inject constructor() { chatType = metadata.type.name, lastActivityEpochMs = metadata.lastActivity.toEpochMilliseconds(), lastMessageId = metadata.lastMessage?.messageId, + latestEventSequence = metadata.latestEventSequence, ) } @@ -60,6 +68,7 @@ class ChatEntityMapper @Inject constructor() { members = members, lastMessage = lastMessage, lastActivity = Instant.fromEpochMilliseconds(entity.lastActivityEpochMs), + latestEventSequence = entity.latestEventSequence, ) } @@ -75,6 +84,11 @@ class ChatEntityMapper @Inject constructor() { contentJson = message.content.map { it.toSerialized() }, timestampEpochMs = message.timestamp.toEpochMilliseconds(), unreadSeq = message.unreadSeq, + eventSequence = message.eventSequence, + lastEditedTsEpochMs = message.lastEditedTs?.toEpochMilliseconds(), + reactionsJson = message.reactions?.toSerialized()?.let { + kotlinx.serialization.json.Json.encodeToString(it) + }, ) } @@ -85,6 +99,11 @@ class ChatEntityMapper @Inject constructor() { content = entity.contentJson?.map { it.toDomain() } ?: emptyList(), timestamp = Instant.fromEpochMilliseconds(entity.timestampEpochMs), unreadSeq = entity.unreadSeq, + eventSequence = entity.eventSequence, + lastEditedTs = entity.lastEditedTsEpochMs?.let { Instant.fromEpochMilliseconds(it) }, + reactions = entity.reactionsJson?.let { + kotlinx.serialization.json.Json.decodeFromString(it).toDomain() + }, deliveryStatus = when (entity.status) { MessageStatus.SENDING -> DeliveryStatus.SENDING MessageStatus.SENT -> DeliveryStatus.SENT @@ -192,6 +211,19 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this tokenName = tokenName, tokenImageUrl = tokenImageUrl, ) + is MessageContent.Reply -> MessageContentSerialized.Reply( + repliedMessageId = repliedMessageId, + content = content.map { it.toSerialized() }, + ) + is MessageContent.Media -> MessageContentSerialized.Media( + items = items, + caption = caption?.let { MessageContentSerialized.Text(it.text) }, + ) + is MessageContent.System -> MessageContentSerialized.System(fallbackText = fallbackText) + is MessageContent.Deleted -> MessageContentSerialized.Deleted( + deletedAt = deletedTs.epochSeconds, + deletedBy = deletedBy?.hexEncodedString(), + ) } private fun MessageContentSerialized.toDomain(): MessageContent = when (this) { @@ -206,6 +238,19 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) { tokenName = tokenName, tokenImageUrl = tokenImageUrl, ) + is MessageContentSerialized.Reply -> MessageContent.Reply( + repliedMessageId = repliedMessageId, + content = content.map { it.toDomain() }, + ) + is MessageContentSerialized.Media -> MessageContent.Media( + items = items, + caption = caption?.let { MessageContent.Text(it.text) }, + ) + is MessageContentSerialized.System -> MessageContent.System(fallbackText = fallbackText) + is MessageContentSerialized.Deleted -> MessageContent.Deleted( + deletedTs = Instant.fromEpochSeconds(deletedAt), + deletedBy = deletedBy?.hexToIdExt(), + ) } private fun MessagePointer.toSerialized(): MessagePointerSerialized = MessagePointerSerialized( @@ -273,4 +318,40 @@ private fun String.hexToIdExt(): List { return data.toList() } +private fun ReactionSummary.toSerialized(): ReactionSummarySerialized = ReactionSummarySerialized( + messageId = messageId, + reactions = reactions.map { it.toSerialized() }, +) + +private fun EmojiReaction.toSerialized(): EmojiReactionSerialized = EmojiReactionSerialized( + emoji = emoji.value, + count = count, + reactedBySelf = reactedBySelf, + sampleReactors = sampleReactors.map { it.toSerialized() }, + sequence = sequence, +) + +private fun Reactor.toSerialized(): ReactorSerialized = ReactorSerialized( + userIdHex = userId.hexEncodedString(), + reactedAtEpochSeconds = reactedAt.epochSeconds, +) + +private fun ReactionSummarySerialized.toDomain(): ReactionSummary = ReactionSummary( + messageId = messageId, + reactions = reactions.map { it.toDomain() }, +) + +private fun EmojiReactionSerialized.toDomain(): EmojiReaction = EmojiReaction( + emoji = Emoji(emoji), + count = count, + reactedBySelf = reactedBySelf, + sampleReactors = sampleReactors.map { it.toDomain() }, + sequence = sequence, +) + +private fun ReactorSerialized.toDomain(): Reactor = Reactor( + userId = userIdHex.hexToIdExt(), + reactedAt = Instant.fromEpochSeconds(reactedAtEpochSeconds), +) + // endregion diff --git a/definitions/flipcash/protos/src/main/proto/chat/v1/model.proto b/definitions/flipcash/protos/src/main/proto/chat/v1/model.proto index 7256ca6d2..dacd3564c 100644 --- a/definitions/flipcash/protos/src/main/proto/chat/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/chat/v1/model.proto @@ -32,6 +32,17 @@ message Metadata { // The timestamp of the last activity in this chat google.protobuf.Timestamp last_activity = 5 [(validate.rules).timestamp.required = true]; + + // The chat's head event sequence — the value of the most recent event in its + // event log. A client compares this against its locally stored cursor for + // the chat to decide whether catch-up is needed: if its cursor is behind, it + // calls Messaging.GetDelta; if equal, it is current and can skip it. + // + // This is NOT derivable from last_message: an edit or deletion of an older + // message advances the head without changing last_message, so this value can + // exceed last_message.event_sequence. It is the same head reported by + // GetDeltaResponse.latest_sequence. + uint64 latest_event_sequence = 6; } message Member { diff --git a/definitions/flipcash/protos/src/main/proto/event/v1/model.proto b/definitions/flipcash/protos/src/main/proto/event/v1/model.proto index 6262a4995..ddb145345 100644 --- a/definitions/flipcash/protos/src/main/proto/event/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/event/v1/model.proto @@ -79,17 +79,37 @@ message ChatUpdate { // The chat that this update is for common.v1.ChatId chat = 1 [(validate.rules).message.required = true]; - // If present, new real-time messages sent on the chat - messaging.v1.MessageBatch new_messages = 2; - - // If present, message pointer updates for members in the chat + // If present, new real-time messages sent on the chat. + // + // Deprecated: superseded by `events` (Event.message_sent), which is + // sequenced and gap-detectable. New messages now arrive as events. + messaging.v1.MessageBatch new_messages = 2 [deprecated = true]; + + // If present, message pointer updates for members in the chat. Pointers are + // convergent (monotonic, last-writer-wins), so they ride the stream as a + // best-effort overlay and are reconciled from current state on reconnect — + // they are intentionally NOT part of the gap-detected event log. messaging.v1.PointerBatch pointer_updates = 3; - // If present, message typing notification state changes for members in the chat + // If present, message typing notification state changes for members in the + // chat. Transient and best-effort — not part of the event log. messaging.v1.IsTypingNotificationBatch is_typing_notifications = 4; // If present, updates to the chat metadata repeated chat.v1.MetadataUpdate metadata_updates = 5 [(validate.rules).repeated = { max_items: 1024 // Arbitrary }]; + + // If present, durable event-log events for the chat (messages sent, edited, + // and deleted). These are contiguous and ordered: clients apply them by + // ascending Event.sequence and gap-detect via Event.sequence/count, catching + // up with Messaging.GetDelta on a gap. This supersedes new_messages. + messaging.v1.EventBatch events = 6; + + // If present, best-effort real-time reaction changes for messages in the + // chat. Like pointer_updates, reactions are a convergent overlay — NOT part + // of the gap-detected event log; clients apply them last-writer-wins by + // ReactionUpdate.sequence and reconcile any misses by refreshing a message's + // ReactionSummary on view. + messaging.v1.ReactionUpdateBatch reaction_updates = 7; } diff --git a/definitions/flipcash/protos/src/main/proto/messaging/v1/messaging_service.proto b/definitions/flipcash/protos/src/main/proto/messaging/v1/messaging_service.proto index 975451c6a..c392a8d13 100644 --- a/definitions/flipcash/protos/src/main/proto/messaging/v1/messaging_service.proto +++ b/definitions/flipcash/protos/src/main/proto/messaging/v1/messaging_service.proto @@ -14,12 +14,76 @@ service Messaging { // GetMessage gets a single message in a chat rpc GetMessage(GetMessageRequest) returns (GetMessageResponse); - // GetMessages gets the set of messages for a chat using a paged and batched APIs + // GetMessages gets the set of messages for a chat using paged and batched APIs rpc GetMessages(GetMessagesRequest) returns (GetMessagesResponse); + // GetDelta returns, for cold-boot and reconnect catch-up, the current state + // of every message changed since the client's cursor, up to the chat's + // current head. It is a state delta, not a contiguous replay: each changed + // message appears once in its latest state and the client applies it + // last-writer-wins. Transient signals (typing) and convergent state + // (pointers, reactions) are fetched separately, not returned here. + // + // GetDelta always catches up to the head; there is no caller-specified + // upper bound. An online client that detects a gap while already receiving + // live updates does NOT bound the fetch: it calls GetDelta to the head and + // lets last-writer-wins (Message.event_sequence) absorb the overlap with + // live events buffered during the call — a message delivered by both paths + // is applied once, newest wins. A client may also wait briefly for an + // out-of-order live update to close a small gap before calling at all. + // + // On stream completion the client advances its cursor to the highest + // checkpoint_sequence it received, which equals latest_sequence — the client + // is now at the head. When the client is already current the server sends a + // single response with messages omitted (and checkpoint_sequence unset), + // leaving the cursor unchanged; latest_sequence still reports the head. + // + // This is a BOUNDED server stream: the server emits one or more batches and + // then completes once the delta up to the head (as of stream open) is + // exhausted. Unlike StreamEvents it does NOT stay open for live updates. + // Streaming the delta in batches avoids a per-page round trip; the server may + // currently send the whole delta as a single response, so clients must handle + // any number of batches and treat stream completion as "caught up." + // + // The Result field is meaningful on the first response and is OK for + // subsequent data batches; a terminal DENIED or RESET_REQUIRED is delivered + // as a single response that ends the stream. + rpc GetDelta(GetDeltaRequest) returns (stream GetDeltaResponse); + // SendMessage sends a message to a chat. rpc SendMessage(SendMessageRequest) returns (SendMessageResponse); + // EditMessage edits the content of a message the caller previously sent. + rpc EditMessage(EditMessageRequest) returns (EditMessageResponse); + + // DeleteMessage deletes a message the caller previously sent. The message is + // tombstoned (content replaced with DeletedContent), not removed, so the + // per-chat MessageId sequence stays gapless. + rpc DeleteMessage(DeleteMessageRequest) returns (DeleteMessageResponse); + + // AddReaction adds the caller's reaction with a given emoji to a message. + // Idempotent: re-adding the same emoji the caller already reacted with is a + // no-op success. + rpc AddReaction(AddReactionRequest) returns (AddReactionResponse); + + // RemoveReaction removes the caller's reaction with a given emoji from a + // message. Idempotent: removing a reaction the caller does not have is a + // no-op success. + rpc RemoveReaction(RemoveReactionRequest) returns (RemoveReactionResponse); + + // GetReactors returns the paged list of users who reacted to a message with + // a given emoji — the on-demand drill-down behind EmojiReaction.count, which + // never inlines the full reactor list. + rpc GetReactors(GetReactorsRequest) returns (GetReactorsResponse); + + // GetReactionSummary fetches the current aggregate reaction state for a + // single message. + rpc GetReactionSummary(GetReactionSummaryRequest) returns (GetReactionSummaryResponse); + + // GetReactionSummaries fetches the current aggregate reaction state using + // paged and batched APIs + rpc GetReactionSummaries(GetReactionSummariesRequest) returns (GetReactionSummariesResponse); + // AdvancePointer advances a pointer in message history for a chat member. rpc AdvancePointer(AdvancePointerRequest) returns (AdvancePointerResponse); @@ -72,11 +136,67 @@ message GetMessagesResponse { MessageBatch messages = 2; } +message GetDeltaRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + // The latest event sequence the client has already applied. The server + // returns the current state of messages whose event_sequence is greater than + // this value, up to the current head. Use 0 to fetch from the beginning of + // the retained log. + uint64 after_sequence = 2; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message GetDeltaResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + // after_sequence is older than the oldest state the server can still + // resolve a delta for. The client must discard its cursor and re-sync + // chat history from GetMessages before resuming the event stream. + RESET_REQUIRED = 2; + } + + // A batch of changed messages in STRICTLY ASCENDING event_sequence order, + // continuing in order across batches (every sequence in a batch is higher + // than every sequence in the prior batch). Across the whole stream this is + // the current state of every message changed since after_sequence, up to the + // head; a message normally appears once in its latest state, but one + // re-edited mid-stream may reappear at its new, higher sequence (apply + // last-writer-wins). Omitted (not an empty batch) when there are no changes + // to report — e.g. when the client is already current; the server still + // sets latest_sequence and the stream then completes. Note MessageBatch + // itself requires at least one message, so "no changes" is signaled by + // leaving this field unset, never by an empty batch. + MessageBatch messages = 2; + + // The chat's latest event sequence (head) as of stream open — the target this + // catch-up converges to. Informational while streaming: it tells the client + // how far the chat has advanced before the final batch arrives. Once the + // stream completes, the client's cursor equals this; it does not need + // contiguous coverage of the intervening points, only the resulting state. + uint64 latest_sequence = 3; + + // Resume checkpoint: the event_sequence through which the delta is complete + // as of this batch — the batch's high-water mark, monotonically increasing + // across the stream toward latest_sequence. Persist it AFTER fully applying + // the batch. If the stream drops mid-catch-up, resume by calling GetDelta + // again with after_sequence set to the last checkpoint_sequence received. + // Because event_sequence only ever increases, this resumes exactly where you + // left off with no skipped messages (and at worst a harmless last-writer-wins + // re-apply of the boundary). + uint64 checkpoint_sequence = 4; +} + message SendMessageRequest { common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; // Allowed content types that can be sent by client: // - TextContent + // - ReplyContent + // - MediaContent repeated Content content = 2 [(validate.rules).repeated = { min_items: 1 max_items: 1 @@ -102,6 +222,230 @@ message SendMessageResponse { Message message = 2; } +message EditMessageRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + MessageId message_id = 2 [(validate.rules).message.required = true]; + + // The new content for the message. Allowed content types match SendMessage: + // - TextContent + // - ReplyContent + // - MediaContent + repeated Content content = 3 [(validate.rules).repeated = { + min_items: 1 + max_items: 1 + }]; + + // Required optimistic-concurrency guard: the message's event_sequence as the + // client last observed it. The server applies the edit only if the message's + // current event_sequence still equals this value, and returns CONFLICT + // otherwise — so an edit based on a stale version (e.g. a concurrent + // edit/delete from the sender's other device) is rejected rather than + // clobbering the newer state. There is no last-writer-wins path. + uint64 expected_event_sequence = 4 [(validate.rules).uint64.gte = 1]; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message EditMessageResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + CANNOT_EDIT = 3; + // The message changed since expected_event_sequence (a concurrent + // edit/delete won). The edit was not applied; `message` carries the + // current state for the client to reconcile against and retry. + CONFLICT = 4; + } + + // On OK, the updated materialized message (advanced event_sequence, + // last_edited_ts set). On CONFLICT, the message's current state. + Message message = 2; +} + +message DeleteMessageRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + MessageId message_id = 2 [(validate.rules).message.required = true]; + + // Required optimistic-concurrency guard: the message's event_sequence as the + // client last observed it. The server applies the delete only if the + // message's current event_sequence still equals this value, and returns + // CONFLICT otherwise — so a delete based on a stale version is rejected + // rather than racing a concurrent edit/delete. There is no + // last-writer-wins path. + uint64 expected_event_sequence = 3 [(validate.rules).uint64.gte = 1]; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message DeleteMessageResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + CANNOT_DELETE = 3; + // The message changed since expected_event_sequence (a concurrent + // edit/delete won). The delete was not applied; `message` carries the + // current state for the client to reconcile against and retry. + CONFLICT = 4; + } + + // On OK, the tombstoned materialized message (content replaced with + // DeletedContent, event_sequence advanced). On CONFLICT, the current state. + Message message = 2; +} + +message AddReactionRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + MessageId message_id = 2 [(validate.rules).message.required = true]; + + // The emoji to react with. + Emoji emoji = 3 [(validate.rules).message.required = true]; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message AddReactionResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + CANNOT_REACT = 3; + // Adding this emoji would exceed the per-message distinct reaction-type + // cap. Reactions to emojis already present on the message are unaffected. + TOO_MANY_REACTION_TYPES = 4; + } + + // The affected emoji's aggregate after the add (count, reacted_by_self true). + EmojiReaction reaction = 2; +} + +message RemoveReactionRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + MessageId message_id = 2 [(validate.rules).message.required = true]; + + // The emoji whose reaction to remove for the caller. + Emoji emoji = 3 [(validate.rules).message.required = true]; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message RemoveReactionResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + } + + // The affected emoji's aggregate after the removal + EmojiReaction reaction = 2; +} + +message GetReactorsRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + MessageId message_id = 2 [(validate.rules).message.required = true]; + + // The emoji whose reactors to list. + Emoji emoji = 3 [(validate.rules).message.required = true]; + + // Paging over the reactor list (server-ordered, typically most-recent + // first). Leave options.paging_token unset on the first request; on every + // subsequent request, set it to the paging_token from the most recent + // response to advance through the list. The token is opaque and + // server-generated; do not construct it. + common.v1.QueryOptions options = 4; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message GetReactorsResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + } + + // A page of users who reacted with the requested emoji, with their reaction + // timestamps. Empty when the message exists but has no reactors for the emoji. + repeated Reactor reactors = 2 [(validate.rules).repeated = { + max_items: 100 + }]; + + // The server-generated cursor advanced past this page. The client MUST send + // the most recent value back in options.paging_token on the next + // GetReactorsRequest to fetch the following page. Set when result is OK and + // has_more is true. + common.v1.PagingToken paging_token = 3; + + // HasMore indicates whether further pages of reactors remain. When false, + // the reactor list has been fully read. When true, the client should issue + // another GetReactorsRequest with the returned paging_token. + bool has_more = 4; +} + +message GetReactionSummaryRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + MessageId message_id = 2 [(validate.rules).message.required = true]; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message GetReactionSummaryResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + } + + // The aggregate reaction state for the message. reacted_by_self is computed + // for the caller; clients still apply per (message, emoji) by + // EmojiReaction.sequence, so a summary that is slightly behind a live update + // is harmlessly ignored rather than regressing state. + ReactionSummary summary = 2; +} + +message GetReactionSummariesRequest { + common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; + + oneof query { + option (validate.required) = true; + + common.v1.QueryOptions options = 2; + MessageIdBatch message_ids = 3; + } + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message GetReactionSummariesResponse { + Result result = 1; + enum Result { + OK = 0; + DENIED = 1; + } + + // One summary per requested message, keyed by ReactionSummary.message_id. + // reacted_by_self in each summary is computed for the caller; clients still + // apply per (message, emoji) by EmojiReaction.sequence, so a summary that + // is slightly behind a live update is harmlessly ignored rather than regressing + // state. + repeated ReactionSummary summaries = 2 [(validate.rules).repeated = { + max_items: 100 + }]; +} + message AdvancePointerRequest { common.v1.ChatId chat_id = 1 [(validate.rules).message.required = true]; diff --git a/definitions/flipcash/protos/src/main/proto/messaging/v1/model.proto b/definitions/flipcash/protos/src/main/proto/messaging/v1/model.proto index f4b53492d..9ba8b88b5 100644 --- a/definitions/flipcash/protos/src/main/proto/messaging/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/messaging/v1/model.proto @@ -62,6 +62,34 @@ message Message { // client as the difference between the latest message's unread_seq and the // unread_seq of the message at their READ pointer. uint64 unread_seq = 5; + + // If set, the timestamp this message was last edited at. Absent on messages + // that have never been edited. The content above always reflects the + // current (materialized) state, so clients render it directly; this field + // only drives an "edited" affordance. Deletions are represented in content + // via DeletedContent, not here. + google.protobuf.Timestamp last_edited_ts = 6; + + // The event-log sequence at which this message reached its current state: + // the point of the most recent mutation (send, edit, or delete) affecting + // it. A per-message VERSION stamp — distinct from message_id (fixed + // identity/order) and unread_seq (unread accounting) — that advances on + // every edit/delete while message_id stays fixed. + // + // It makes a Message self-locating regardless of how it was obtained (event + // stream, GetMessages, SendMessage echo, last_message, push). Clients apply + // last-writer-wins by this value: ignore a copy whose event_sequence is <= + // the version already held, otherwise insert/replace. Cross-message gap + // detection is separate, via the live event log's Event.sequence/count and + // GetDelta catch-up. + uint64 event_sequence = 7 [(validate.rules).uint64.gte = 1]; + + // Aggregate reaction state for this message, current as of the time it was + // read. This is a convergent overlay, NOT part of the content versioned by + // event_sequence: reactions change without advancing event_sequence, so + // clients refresh it on view and via live reaction updates rather than + // through the event log. + ReactionSummary reactions = 8; } // Content for a chat message @@ -69,8 +97,12 @@ message Content { oneof type { option (validate.required) = true; - TextContent text = 1; - CashContent cash = 2; + TextContent text = 1; + CashContent cash = 2; + ReplyContent reply = 3; + MediaContent media = 4; + SystemContent system = 5; + DeletedContent deleted = 6; } } @@ -82,6 +114,7 @@ message TextContent { }]; } +// Cash content message CashContent { // Intent ID identifying the cash transaction at the OCP layer common.v1.IntentId intent_id = 1 [(validate.rules).message.required = true]; @@ -93,6 +126,218 @@ message CashContent { reserved 3; } +// Reply content +message ReplyContent { + // ID of the message being replied to + MessageId replied_message_id = 1 [(validate.rules).message.required = true]; + + // Reply message content. Allowed content types are: + // - TextContent + // - MediaContent + repeated Content content = 2 [(validate.rules).repeated = { + min_items: 1 + max_items: 1 + }]; +} + +// Media content (images, video, etc.) +message MediaContent { + // The media items attached to this message + repeated MediaItem items = 1 [(validate.rules).repeated = { + min_items: 1 + max_items: 1 + }]; + + // Optional caption rendered alongside the media + TextContent caption = 2; +} + +message MediaItem { + // Client-provided reference to media already uploaded out-of-band + MediaId media_id = 1 [(validate.rules).message.required = true]; + + // Server-authoritative metadata, resolved from the upload record. It is + // omitted on SendMessage and populated on stored/returned messages + MediaMetadata metadata = 2; +} + +message MediaId { + bytes value = 1 [(validate.rules).bytes = { + min_len: 16 + max_len: 16 + }]; +} + +// Server-authoritative metadata describing an uploaded media item. Never set by +// clients; the server derives every field from the uploaded bytes. +message MediaMetadata { + // MIME type (e.g. "image/jpeg", "video/mp4") + string mime_type = 1 [(validate.rules).string = { + min_len: 1 + max_len: 255 + }]; + + // Total size of the media in bytes. + uint64 size_bytes = 2 [(validate.rules).uint64.gte = 1]; + + // Pixel dimensions, for reserving layout before the bytes arrive. + uint32 width = 3 [(validate.rules).uint32.gte = 1]; + uint32 height = 4 [(validate.rules).uint32.gte = 1]; + + // Compact preview shown while the full media downloads (BlurHash string). + string blurhash = 5 [(validate.rules).string.max_len = 64]; + + // Duration in milliseconds for audio/video; 0 for stills. + uint64 duration_ms = 6; +} + +// System message content +message SystemContent { + // Best-effort, server-rendered text in the user's locale setting. Today this + // is the only way to display a system message; once the structured `event` + // oneof exists it becomes a fallback, rendered ONLY when the client does not + // recognize the variant (old client, new server). It is not localized per + // viewer — clients that know a variant render their own localized string. + string fallback_text = 1 [(validate.rules).string = { + min_len: 1 + max_len: 256 + }]; + + // todo: Define events once we have them +} + +// Deleted message content +message DeletedContent { + // Timestamp the message was deleted. Set whenever a message is tombstoned; + // clients can surface it as a "deleted" affordance. This is the deletion + // analog of Message.last_edited_ts, kept here so all deletion state lives in + // the content rather than as a separate flag on Message. + google.protobuf.Timestamp deleted_ts = 1 [(validate.rules).timestamp.required = true]; + + // When present, the user that deleted the message. If not present, a it is + // a system-level deletion (eg. moderation check). + common.v1.UserId deleted_by = 2; +} + +// Emoji identifies an emoji used in a reaction. The value is a unicode emoji +// sequence — a single grapheme cluster, which may include modifiers such as a +// skin-tone selector or ZWJ joins — or a custom emoji identifier where +// supported. +message Emoji { + // Structural bounds only — these bound size as defense-in-depth (min_len/ + // max_len count code points, max_bytes counts bytes; a complex ZWJ or + // tag-flag sequence is ~8 code points but ~32 bytes, so both earn their + // keep). True emoji validity (a real grapheme, normalization, any supported + // set) is enforced in server code, not here. + string value = 1 [(validate.rules).string = { + min_len: 1 + max_len: 32 + max_bytes: 128 + }]; +} + +// Reactor identifies a user who reacted to a message and when they did so. +message Reactor { + common.v1.UserId user_id = 1 [(validate.rules).message.required = true]; + + // Timestamp the user added this reaction. + google.protobuf.Timestamp reacted_ts = 2 [(validate.rules).timestamp.required = true]; +} + +// ReactionSummary is the aggregate reaction state attached to a message. It is +// bounded: the number of distinct reaction types per message is capped, so the +// summary stays small no matter how many users reacted. The full reactor list +// for any emoji is fetched on demand (paged), never inlined here. +message ReactionSummary { + // The message these reactions belong to. + MessageId message_id = 1 [(validate.rules).message.required = true]; + + // One entry per distinct emoji reacted to this message + repeated EmojiReaction reactions = 2; +} + +// EmojiReaction aggregates all reactions of a single emoji on a message. +message EmojiReaction { + // The emoji reacted with. + Emoji emoji = 1 [(validate.rules).message.required = true]; + + // Total number of users who reacted with this emoji. Authoritative and may + // be arbitrarily large; the individual reactor identities are not all + // returned here. + uint64 count = 2; + + // Whether the requesting user reacted with this emoji. Per-viewer: count and + // sample_reactors are shareable across users, but this bit is computed for + // the caller. + bool reacted_by_self = 3; + + // A small sample of reactors, with their reaction timestamps (e.g. for + // rendering a few avatars), capped well below count. The complete, paged + // reactor list is fetched on demand via GetReactors. + repeated Reactor sample_reactors = 4 [(validate.rules).repeated = { + max_items: 8 // Sample only; not the full list + }]; + + // Monotonic version of this emoji's aggregate on the message, assigned by + // the server and advanced on every change to it. Ordering only: clients + // apply reaction updates last-writer-wins by this value per (message, emoji) + // — and per actor for reacted_by_self — and treat a loaded summary as stale + // when a higher sequence arrives. It is NOT the chat event sequence + // (reactions never advance that), and it is NOT gapless: it carries no + // gap-detection meaning. + uint64 sequence = 5 [(validate.rules).uint64.gte = 1]; +} + +// ReactionUpdate is a best-effort, real-time reaction change for a single +// (message, emoji) cell. Reactions are a convergent overlay, so these ride the +// event stream OUTSIDE the gap-detected event log — a missed update is not +// caught up via GetDelta but reconciled by refreshing the message's +// ReactionSummary on view. +message ReactionUpdate { + // The message whose reactions changed. + MessageId message_id = 1 [(validate.rules).message.required = true]; + + // The emoji that was added or removed. + Emoji emoji = 2 [(validate.rules).message.required = true]; + + // The user who added or removed the reaction. A client renders + // reacted_by_self by comparing this to itself, so a reaction made on the + // user's other device is reflected. + common.v1.UserId actor = 3 [(validate.rules).message.required = true]; + + Action action = 4 [(validate.rules).enum = { + not_in: [0] + }]; + enum Action { + UNKNOWN = 0; + ADDED = 1; + REMOVED = 2; + } + + // The emoji's total reactor count after this change. 0 means no reactors + // remain and the client should drop the entry from the summary. + uint64 count = 5; + + // The emoji aggregate's new version after this change. Clients apply + // last-writer-wins by this value: ignore the count if sequence <= the + // count watermark held, and ignore the actor's reacted_by_self toggle if + // sequence <= the per-actor watermark held. Matches EmojiReaction.sequence. + uint64 sequence = 6 [(validate.rules).uint64.gte = 1]; + + // When the actor reacted. On ADDED, clients record this as the actor's + // Reactor.reacted_ts (e.g. when slotting them into sample_reactors); ignored + // for REMOVED. This is a display timestamp, distinct from `sequence`, which + // is the ordering key. + google.protobuf.Timestamp reacted_ts = 7 [(validate.rules).timestamp.required = true]; +} + +message ReactionUpdateBatch { + repeated ReactionUpdate reaction_updates = 1 [(validate.rules).repeated = { + min_items: 1 + max_items: 100 + }]; +} + // Pointer in a chat indicating a user's message history state in a chat. message Pointer { // The type of pointer indicates which user's message history state can be @@ -141,6 +386,72 @@ message PointerBatch { }]; } +// Event is a contiguous run of one or more durable mutations to a chat, delivered +// atomically — the unit of the chat's event log. Newly sent messages, edits, +// and deletions are all mutations within an event. +// +// Only content-bearing, non-idempotent mutations live in the log, because that is +// what gap detection protects: missing one means missing data. Convergent state +// such as pointer advances (last-writer-wins, monotonic) and transient signals +// such as typing notifications are delivered out-of-band and fetched as current +// state, NOT replayed through this log. +// +// Clients apply events in ascending sequence order and use the sequence/count +// pair to detect gaps; on a gap they catch up via GetDelta. +message Event { + // Per-chat event sequence valued AFTER this event applies: the END of the + // half-open range (sequence - count, sequence] this event occupies. This is + // a SEPARATE sequence from MessageId — edits and deletions advance it + // without minting a new MessageId. + uint64 sequence = 1 [(validate.rules).uint64.gte = 1]; + + // The number of points this event consumes, equal to the number of + // mutations it carries — each mutation is one point. The mutation at index + // i sits at point (sequence - count + 1 + i). Clients gap-detect with + // local + count == sequence, so a server that begins emitting count > 1 + // (e.g. a bulk delete) needs no client change. + uint32 count = 2 [(validate.rules).uint32.gte = 1]; + + // Timestamp this event occurred at. + google.protobuf.Timestamp ts = 3 [(validate.rules).timestamp.required = true]; + + // The mutations in this event, ascending by point. Length must equal count. + repeated Mutation mutations = 4 [(validate.rules).repeated = { + min_items: 1 + max_items: 100 + }]; +} + +// Mutation is a single point in the event log: one message sent, edited, or +// deleted. Each carries the full materialized state of the affected message, so +// clients apply it by inserting or replacing their cached copy without a +// refetch. +message Mutation { + oneof type { + option (validate.required) = true; + + // A newly sent message. Inserts a new message_id at the tail of the + // chat. This is the only mutation that advances the MessageId sequence. + Message message_sent = 1; + + // An edit to an existing message (same message_id, updated content, + // last_edited_ts set). + Message message_edited = 2; + + // A deletion of an existing message (same message_id, content replaced + // with DeletedContent). The message_id is retained as a tombstone, so + // the MessageId sequence stays gapless. + Message message_deleted = 3; + } +} + +message EventBatch { + repeated Event events = 1 [(validate.rules).repeated = { + min_items: 1 + max_items: 100 + }]; +} + message IsTypingNotification { common.v1.UserId user_id = 1 [(validate.rules).message.required = true]; diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt index 87f78aefb..96481143a 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt @@ -4,11 +4,17 @@ import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.TypingState import com.flipcash.services.repository.ChatMessagingRepository +import com.flipcash.services.repository.DeltaUpdate +import com.flipcash.services.repository.ReactorsPage import com.flipcash.services.user.UserManager +import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton @@ -35,6 +41,11 @@ class ChatMessagingController @Inject constructor( return repository.getMessagesByIds(owner, chatId, messageIds) } + fun getDelta(chatId: ChatId, afterSequence: Long): Flow> { + val owner = requireOwner() + return repository.getDelta(owner, chatId, afterSequence) + } + suspend fun sendMessage( chatId: ChatId, content: List, @@ -44,6 +55,69 @@ class ChatMessagingController @Inject constructor( return repository.sendMessage(owner, chatId, content, clientMessageId) } + suspend fun editMessage( + chatId: ChatId, + messageId: Long, + content: List, + expectedEventSequence: Long, + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.editMessage(owner, chatId, messageId, content, expectedEventSequence) + } + + suspend fun deleteMessage( + chatId: ChatId, + messageId: Long, + expectedEventSequence: Long, + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.deleteMessage(owner, chatId, messageId, expectedEventSequence) + } + + suspend fun addReaction( + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.addReaction(owner, chatId, messageId, emoji) + } + + suspend fun removeReaction( + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.removeReaction(owner, chatId, messageId, emoji) + } + + suspend fun getReactors( + chatId: ChatId, + messageId: Long, + emoji: Emoji, + queryOptions: QueryOptions = QueryOptions(), + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.getReactors(owner, chatId, messageId, emoji, queryOptions) + } + + suspend fun getReactionSummary( + chatId: ChatId, + messageId: Long, + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.getReactionSummary(owner, chatId, messageId) + } + + suspend fun getReactionSummaries( + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + ): Result> { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.getReactionSummaries(owner, chatId, queryOptions) + } + suspend fun advancePointer( chatId: ChatId, pointerType: PointerType, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt index b4b6bd7da..687754a73 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ChatMetadataMapper.kt @@ -28,6 +28,7 @@ class ChatMetadataMapper @Inject constructor( }, lastMessage = if (from.hasLastMessage()) from.lastMessage.toChatMessage() else null, lastActivity = Instant.fromEpochSeconds(from.lastActivity.seconds, from.lastActivity.nanos), + latestEventSequence = from.latestEventSequence, ) } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt index 3b7f37c0f..35987db50 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt @@ -8,6 +8,8 @@ import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.network.extensions.asChatId import com.flipcash.services.internal.network.extensions.asClientMessageId import com.flipcash.services.internal.network.extensions.asContent +import com.flipcash.services.internal.network.extensions.asEmoji +import com.flipcash.services.internal.network.extensions.asMessageId import com.flipcash.services.internal.network.extensions.asPointerType import com.flipcash.services.internal.network.extensions.asQueryOptions import com.flipcash.services.internal.network.extensions.asTypingState @@ -15,9 +17,12 @@ import com.flipcash.services.internal.network.extensions.authenticate import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.Emoji import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.TypingState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.core.GrpcApi import dev.bmcreations.protovalidate.orThrow @@ -150,4 +155,160 @@ internal class ChatMessagingApi @Inject constructor( api.notifyIsTyping(request) } } + + fun getDelta( + owner: KeyPair, + chatId: ChatId, + afterSequence: Long, + ): Flow { + val request = RpcMessagingService.GetDeltaRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setAfterSequence(afterSequence) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return api.getDelta(request).flowOn(Dispatchers.IO) + } + + suspend fun editMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + content: List, + expectedEventSequence: Long, + ): RpcMessagingService.EditMessageResponse { + val request = RpcMessagingService.EditMessageRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(messageId.asMessageId()) + .addAllContent(content.map { it.asContent() }) + .setExpectedEventSequence(expectedEventSequence) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.editMessage(request) + } + } + + suspend fun deleteMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + expectedEventSequence: Long, + ): RpcMessagingService.DeleteMessageResponse { + val request = RpcMessagingService.DeleteMessageRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(messageId.asMessageId()) + .setExpectedEventSequence(expectedEventSequence) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.deleteMessage(request) + } + } + + suspend fun addReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): RpcMessagingService.AddReactionResponse { + val request = RpcMessagingService.AddReactionRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(messageId.asMessageId()) + .setEmoji(emoji.asEmoji()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.addReaction(request) + } + } + + suspend fun removeReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): RpcMessagingService.RemoveReactionResponse { + val request = RpcMessagingService.RemoveReactionRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(messageId.asMessageId()) + .setEmoji(emoji.asEmoji()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.removeReaction(request) + } + } + + suspend fun getReactors( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + queryOptions: QueryOptions, + ): RpcMessagingService.GetReactorsResponse { + val request = RpcMessagingService.GetReactorsRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(messageId.asMessageId()) + .setEmoji(emoji.asEmoji()) + .setOptions(queryOptions.asQueryOptions()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getReactors(request) + } + } + + suspend fun getReactionSummary( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): RpcMessagingService.GetReactionSummaryResponse { + val request = RpcMessagingService.GetReactionSummaryRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(messageId.asMessageId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getReactionSummary(request) + } + } + + suspend fun getReactionSummaries( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): RpcMessagingService.GetReactionSummariesResponse { + val request = RpcMessagingService.GetReactionSummariesRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setOptions(queryOptions.asQueryOptions()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getReactionSummaries(request) + } + } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index ba1a3f6dc..6051adeb9 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -121,9 +121,48 @@ internal fun MessageContent.asContent(): MessagingModel.Content { ) ) .build() + is MessageContent.Reply -> MessagingModel.Content.newBuilder() + .setReply( + MessagingModel.ReplyContent.newBuilder() + .setRepliedMessageId(MessagingModel.MessageId.newBuilder().setValue(repliedMessageId)) + .addAllContent(content.map { it.asContent() }) + ) + .build() + is MessageContent.Media -> MessagingModel.Content.newBuilder() + .setMedia( + MessagingModel.MediaContent.newBuilder() + .addAllItems(items.map { it.asMediaItem() }) + .apply { if (caption != null) setCaption(MessagingModel.TextContent.newBuilder().setText(caption.text)) } + ) + .build() + is MessageContent.System -> MessagingModel.Content.newBuilder() + .setSystem(MessagingModel.SystemContent.newBuilder().setFallbackText(fallbackText)) + .build() + is MessageContent.Deleted -> { + val deletedBuilder = MessagingModel.DeletedContent.newBuilder() + .setDeletedTs(deletedTs.asTimestamp()) + deletedBy?.let { deletedBuilder.setDeletedBy(it.asUserId()) } + MessagingModel.Content.newBuilder() + .setDeleted(deletedBuilder) + .build() + } } } +internal fun com.flipcash.services.models.chat.MediaItem.asMediaItem(): MessagingModel.MediaItem { + return MessagingModel.MediaItem.newBuilder() + .setMediaId(MessagingModel.MediaId.newBuilder().setValue(mediaId.bytes.toByteString())) + .build() +} + +internal fun com.flipcash.services.models.chat.Emoji.asEmoji(): MessagingModel.Emoji { + return MessagingModel.Emoji.newBuilder().setValue(value).build() +} + +internal fun Long.asMessageId(): MessagingModel.MessageId { + return MessagingModel.MessageId.newBuilder().setValue(this).build() +} + internal fun PointerType.asPointerType(): MessagingModel.Pointer.Type { return when (this) { PointerType.SENT -> MessagingModel.Pointer.Type.SENT diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 0ffa8dbe8..8cc99c47b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -2,8 +2,6 @@ package com.flipcash.services.internal.network.extensions import com.codeinc.flipcash.gen.common.v1.Common -import com.codeinc.flipcash.gen.common.v1.Common.UserId -import com.codeinc.flipcash.gen.moderation.v1.ModerationService import com.codeinc.flipcash.gen.push.v1.navigationOrNull import com.codeinc.flipcash.gen.push.v1.Model as PushModels import com.flipcash.services.internal.extensions.toChecksum @@ -15,16 +13,26 @@ import com.flipcash.services.models.NotificationPayload import com.flipcash.services.models.PagingToken import com.flipcash.services.models.Substitution import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatEvent import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatMutation import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction +import com.flipcash.services.models.chat.MediaId +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.MediaMetadata import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.MetadataUpdate import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.ReactionSummary +import com.flipcash.services.models.chat.ReactionUpdate +import com.flipcash.services.models.chat.Reactor import com.flipcash.services.models.chat.TypingNotification import com.flipcash.services.models.chat.TypingState import com.getcode.opencode.model.core.ID @@ -118,10 +126,41 @@ internal fun MessagingModel.Content.toMessageContent(): MessageContent { ), mint = cash.amount.mint.value.toByteArray().toMint(), ) + MessagingModel.Content.TypeCase.REPLY -> MessageContent.Reply( + repliedMessageId = reply.repliedMessageId.value, + content = reply.contentList.map { it.toMessageContent() }, + ) + MessagingModel.Content.TypeCase.MEDIA -> MessageContent.Media( + items = media.itemsList.map { it.toMediaItem() }, + caption = if (media.hasCaption()) MessageContent.Text(media.caption.text) else null, + ) + MessagingModel.Content.TypeCase.SYSTEM -> MessageContent.System(system.fallbackText) + MessagingModel.Content.TypeCase.DELETED -> MessageContent.Deleted( + deletedTs = Instant.fromEpochSeconds(deleted.deletedTs.seconds, deleted.deletedTs.nanos), + deletedBy = if (deleted.hasDeletedBy()) deleted.deletedBy.toId() else null, + ) else -> MessageContent.Text("") } } +internal fun MessagingModel.MediaItem.toMediaItem(): MediaItem { + return MediaItem( + mediaId = MediaId(mediaId.value.toByteArray()), + metadata = if (hasMetadata()) metadata.toMediaMetadata() else null, + ) +} + +internal fun MessagingModel.MediaMetadata.toMediaMetadata(): MediaMetadata { + return MediaMetadata( + mimeType = mimeType, + sizeBytes = sizeBytes, + width = width, + height = height, + blurhash = blurhash, + durationMs = durationMs, + ) +} + internal fun MessagingModel.Message.toChatMessage(): ChatMessage { return ChatMessage( messageId = messageId.value, @@ -129,9 +168,70 @@ internal fun MessagingModel.Message.toChatMessage(): ChatMessage { content = contentList.map { it.toMessageContent() }, timestamp = Instant.fromEpochSeconds(ts.seconds, ts.nanos), unreadSeq = unreadSeq, + lastEditedTs = if (hasLastEditedTs()) Instant.fromEpochSeconds(lastEditedTs.seconds, lastEditedTs.nanos) else null, + eventSequence = eventSequence, + reactions = if (hasReactions()) reactions.toReactionSummary() else null, + ) +} + +internal fun MessagingModel.ReactionSummary.toReactionSummary(): ReactionSummary { + return ReactionSummary( + messageId = messageId.value, + reactions = reactionsList.map { it.toEmojiReaction() }, + ) +} + +internal fun MessagingModel.EmojiReaction.toEmojiReaction(): EmojiReaction { + return EmojiReaction( + emoji = Emoji(emoji.value), + count = count, + reactedBySelf = reactedBySelf, + sampleReactors = sampleReactorsList.map { it.toReactor() }, + sequence = sequence, ) } +internal fun MessagingModel.Reactor.toReactor(): Reactor { + return Reactor( + userId = userId.toId(), + reactedAt = Instant.fromEpochSeconds(reactedTs.seconds, reactedTs.nanos), + ) +} + +internal fun MessagingModel.ReactionUpdate.toReactionUpdate(): ReactionUpdate { + return ReactionUpdate( + messageId = messageId.value, + emoji = Emoji(emoji.value), + actor = actor.toId(), + action = when (action) { + MessagingModel.ReactionUpdate.Action.ADDED -> ReactionUpdate.Action.ADDED + MessagingModel.ReactionUpdate.Action.REMOVED -> ReactionUpdate.Action.REMOVED + else -> ReactionUpdate.Action.UNKNOWN + }, + count = count, + sequence = sequence, + reactedAt = Instant.fromEpochSeconds(reactedTs.seconds, reactedTs.nanos), + ) +} + +internal fun MessagingModel.Event.toChatEvent(): ChatEvent { + return ChatEvent( + sequence = sequence, + count = count, + ts = Instant.fromEpochSeconds(ts.seconds, ts.nanos), + mutations = mutationsList.map { it.toChatMutation() }, + ) +} + +internal fun MessagingModel.Mutation.toChatMutation(): ChatMutation { + return when (typeCase) { + MessagingModel.Mutation.TypeCase.MESSAGE_SENT -> ChatMutation.MessageSent(messageSent.toChatMessage()) + MessagingModel.Mutation.TypeCase.MESSAGE_EDITED -> ChatMutation.MessageEdited(messageEdited.toChatMessage()) + MessagingModel.Mutation.TypeCase.MESSAGE_DELETED -> ChatMutation.MessageDeleted(messageDeleted.toChatMessage()) + else -> ChatMutation.MessageSent(MessagingModel.Message.getDefaultInstance().toChatMessage()) + } +} + internal fun MessagingModel.Pointer.toPointer(): MessagePointer { return MessagePointer( type = type.toPointerType(), @@ -217,11 +317,13 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { }, lastMessage = if (hasLastMessage()) lastMessage.toChatMessage() else null, lastActivity = Instant.fromEpochSeconds(lastActivity.seconds, lastActivity.nanos), + latestEventSequence = latestEventSequence, ) } // -- EventModel.ChatUpdate -- +@Suppress("DEPRECATION") internal fun EventModel.ChatUpdate.toChatUpdate( metadataMapper: (ChatModel.Metadata) -> ChatMetadata = { it.toChatMetadata() }, ): ChatUpdate { @@ -231,5 +333,7 @@ internal fun EventModel.ChatUpdate.toChatUpdate( pointerUpdates = if (hasPointerUpdates()) pointerUpdates.pointersList.map { it.toPointer() } else emptyList(), typingNotifications = if (hasIsTypingNotifications()) isTypingNotifications.isTypingNotificationsList.map { it.toTypingNotification() } else emptyList(), metadataUpdates = metadataUpdatesList.map { it.toMetadataUpdate(metadataMapper) }, + events = if (hasEvents()) events.eventsList.map { it.toChatEvent() } else emptyList(), + reactionUpdates = if (hasReactionUpdates()) reactionUpdates.reactionUpdatesList.map { it.toReactionUpdate() } else emptyList(), ) } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt index de0c4b60a..41a6a3f03 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt @@ -3,20 +3,37 @@ package com.flipcash.services.internal.network.services import com.codeinc.flipcash.gen.messaging.v1.MessagingService as RpcMessagingService import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel import com.flipcash.services.internal.network.api.ChatMessagingApi +import com.flipcash.services.internal.network.extensions.toEmojiReaction +import com.flipcash.services.internal.network.extensions.toReactionSummary +import com.flipcash.services.internal.network.extensions.toReactor +import com.flipcash.services.models.AddReactionError import com.flipcash.services.models.AdvancePointerError +import com.flipcash.services.models.DeleteMessageError +import com.flipcash.services.models.EditMessageError +import com.flipcash.services.models.GetDeltaError +import com.flipcash.services.models.GetReactionSummariesError +import com.flipcash.services.models.GetReactionSummaryError +import com.flipcash.services.models.GetReactorsError import com.flipcash.services.models.SendMessageError import com.flipcash.services.models.GetMessageError import com.flipcash.services.models.GetMessagesError import com.flipcash.services.models.NotifyIsTypingError import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.RemoveReactionError import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.EmojiReaction +import com.flipcash.services.models.chat.Emoji import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.ReactionSummary +import com.flipcash.services.models.chat.Reactor import com.flipcash.services.models.chat.TypingState import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.extensions.foldWithSuppression import com.getcode.opencode.utils.toValidationOrElse +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import javax.inject.Inject internal class ChatMessagingService @Inject constructor( @@ -161,4 +178,216 @@ internal class ChatMessagingService @Inject constructor( } ) } + + fun getDelta( + owner: KeyPair, + chatId: ChatId, + afterSequence: Long, + ): Flow> { + return api.getDelta(owner, chatId, afterSequence).map { response -> + when (response.result) { + RpcMessagingService.GetDeltaResponse.Result.OK -> Result.success( + GetDeltaResult( + messages = if (response.hasMessages()) response.messages.messagesList else emptyList(), + latestSequence = response.latestSequence, + checkpointSequence = response.checkpointSequence, + ) + ) + RpcMessagingService.GetDeltaResponse.Result.DENIED -> Result.failure(GetDeltaError.Denied()) + RpcMessagingService.GetDeltaResponse.Result.RESET_REQUIRED -> Result.failure(GetDeltaError.ResetRequired()) + RpcMessagingService.GetDeltaResponse.Result.UNRECOGNIZED -> Result.failure(GetDeltaError.Unrecognized()) + else -> Result.failure(GetDeltaError.Other()) + } + } + } + + suspend fun editMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + content: List, + expectedEventSequence: Long, + ): Result { + return runCatching { + api.editMessage(owner, chatId, messageId, content, expectedEventSequence) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.EditMessageResponse.Result.OK -> Result.success(response.message) + RpcMessagingService.EditMessageResponse.Result.DENIED -> Result.failure(EditMessageError.Denied()) + RpcMessagingService.EditMessageResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(EditMessageError.MessageNotFound()) + RpcMessagingService.EditMessageResponse.Result.CANNOT_EDIT -> Result.failure(EditMessageError.CannotEdit()) + RpcMessagingService.EditMessageResponse.Result.CONFLICT -> Result.failure(EditMessageError.Conflict()) + RpcMessagingService.EditMessageResponse.Result.UNRECOGNIZED -> Result.failure(EditMessageError.Unrecognized()) + else -> Result.failure(EditMessageError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { EditMessageError.Other(cause = it) }) + } + ) + } + + suspend fun deleteMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + expectedEventSequence: Long, + ): Result { + return runCatching { + api.deleteMessage(owner, chatId, messageId, expectedEventSequence) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.DeleteMessageResponse.Result.OK -> Result.success(response.message) + RpcMessagingService.DeleteMessageResponse.Result.DENIED -> Result.failure(DeleteMessageError.Denied()) + RpcMessagingService.DeleteMessageResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(DeleteMessageError.MessageNotFound()) + RpcMessagingService.DeleteMessageResponse.Result.CANNOT_DELETE -> Result.failure(DeleteMessageError.CannotDelete()) + RpcMessagingService.DeleteMessageResponse.Result.CONFLICT -> Result.failure(DeleteMessageError.Conflict()) + RpcMessagingService.DeleteMessageResponse.Result.UNRECOGNIZED -> Result.failure(DeleteMessageError.Unrecognized()) + else -> Result.failure(DeleteMessageError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { DeleteMessageError.Other(cause = it) }) + } + ) + } + + suspend fun addReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result { + return runCatching { + api.addReaction(owner, chatId, messageId, emoji) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.AddReactionResponse.Result.OK -> Result.success(response.reaction.toEmojiReaction()) + RpcMessagingService.AddReactionResponse.Result.DENIED -> Result.failure(AddReactionError.Denied()) + RpcMessagingService.AddReactionResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(AddReactionError.MessageNotFound()) + RpcMessagingService.AddReactionResponse.Result.CANNOT_REACT -> Result.failure(AddReactionError.CannotReact()) + RpcMessagingService.AddReactionResponse.Result.TOO_MANY_REACTION_TYPES -> Result.failure(AddReactionError.TooManyReactionTypes()) + RpcMessagingService.AddReactionResponse.Result.UNRECOGNIZED -> Result.failure(AddReactionError.Unrecognized()) + else -> Result.failure(AddReactionError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { AddReactionError.Other(cause = it) }) + } + ) + } + + suspend fun removeReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result { + return runCatching { + api.removeReaction(owner, chatId, messageId, emoji) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.RemoveReactionResponse.Result.OK -> Result.success(response.reaction.toEmojiReaction()) + RpcMessagingService.RemoveReactionResponse.Result.DENIED -> Result.failure(RemoveReactionError.Denied()) + RpcMessagingService.RemoveReactionResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(RemoveReactionError.MessageNotFound()) + RpcMessagingService.RemoveReactionResponse.Result.UNRECOGNIZED -> Result.failure(RemoveReactionError.Unrecognized()) + else -> Result.failure(RemoveReactionError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { RemoveReactionError.Other(cause = it) }) + } + ) + } + + suspend fun getReactors( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + queryOptions: QueryOptions, + ): Result { + return runCatching { + api.getReactors(owner, chatId, messageId, emoji, queryOptions) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.GetReactorsResponse.Result.OK -> Result.success( + GetReactorsResult( + reactors = response.reactorsList.map { it.toReactor() }, + hasMore = response.hasMore, + ) + ) + RpcMessagingService.GetReactorsResponse.Result.DENIED -> Result.failure(GetReactorsError.Denied()) + RpcMessagingService.GetReactorsResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(GetReactorsError.MessageNotFound()) + RpcMessagingService.GetReactorsResponse.Result.UNRECOGNIZED -> Result.failure(GetReactorsError.Unrecognized()) + else -> Result.failure(GetReactorsError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetReactorsError.Other(cause = it) }) + } + ) + } + + suspend fun getReactionSummary( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): Result { + return runCatching { + api.getReactionSummary(owner, chatId, messageId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.GetReactionSummaryResponse.Result.OK -> Result.success(response.summary.toReactionSummary()) + RpcMessagingService.GetReactionSummaryResponse.Result.DENIED -> Result.failure(GetReactionSummaryError.Denied()) + RpcMessagingService.GetReactionSummaryResponse.Result.MESSAGE_NOT_FOUND -> Result.failure(GetReactionSummaryError.MessageNotFound()) + RpcMessagingService.GetReactionSummaryResponse.Result.UNRECOGNIZED -> Result.failure(GetReactionSummaryError.Unrecognized()) + else -> Result.failure(GetReactionSummaryError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetReactionSummaryError.Other(cause = it) }) + } + ) + } + + suspend fun getReactionSummaries( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result> { + return runCatching { + api.getReactionSummaries(owner, chatId, queryOptions) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.GetReactionSummariesResponse.Result.OK -> + Result.success(response.summariesList.map { it.toReactionSummary() }) + RpcMessagingService.GetReactionSummariesResponse.Result.DENIED -> Result.failure(GetReactionSummariesError.Denied()) + RpcMessagingService.GetReactionSummariesResponse.Result.UNRECOGNIZED -> Result.failure(GetReactionSummariesError.Unrecognized()) + else -> Result.failure(GetReactionSummariesError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetReactionSummariesError.Other(cause = it) }) + } + ) + } } + +data class GetDeltaResult( + val messages: List, + val latestSequence: Long, + val checkpointSequence: Long, +) + +data class GetReactorsResult( + val reactors: List, + val hasMore: Boolean, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt index 05c8c9988..0b9d5e0b5 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt @@ -6,12 +6,19 @@ import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.TypingState import com.flipcash.services.repository.ChatMessagingRepository +import com.flipcash.services.repository.DeltaUpdate +import com.flipcash.services.repository.ReactorsPage import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.utils.ErrorUtils +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map internal class InternalChatMessagingRepository( private val service: ChatMessagingService, @@ -41,6 +48,23 @@ internal class InternalChatMessagingRepository( .onFailure { ErrorUtils.handleError(it) } .map { messages -> messages.map { it.toChatMessage() } } + override fun getDelta( + owner: KeyPair, + chatId: ChatId, + afterSequence: Long, + ): Flow> = service.getDelta(owner, chatId, afterSequence) + .map { result -> + result + .onFailure { ErrorUtils.handleError(it) } + .map { delta -> + DeltaUpdate( + messages = delta.messages.map { it.toChatMessage() }, + latestSequence = delta.latestSequence, + checkpointSequence = delta.checkpointSequence, + ) + } + } + override suspend fun sendMessage( owner: KeyPair, chatId: ChatId, @@ -50,6 +74,65 @@ internal class InternalChatMessagingRepository( .onFailure { ErrorUtils.handleError(it) } .map { it.toChatMessage() } + override suspend fun editMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + content: List, + expectedEventSequence: Long, + ): Result = service.editMessage(owner, chatId, messageId, content, expectedEventSequence) + .onFailure { ErrorUtils.handleError(it) } + .map { it.toChatMessage() } + + override suspend fun deleteMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + expectedEventSequence: Long, + ): Result = service.deleteMessage(owner, chatId, messageId, expectedEventSequence) + .onFailure { ErrorUtils.handleError(it) } + .map { it.toChatMessage() } + + override suspend fun addReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result = service.addReaction(owner, chatId, messageId, emoji) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun removeReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result = service.removeReaction(owner, chatId, messageId, emoji) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun getReactors( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + queryOptions: QueryOptions, + ): Result = service.getReactors(owner, chatId, messageId, emoji, queryOptions) + .onFailure { ErrorUtils.handleError(it) } + .map { ReactorsPage(reactors = it.reactors, hasMore = it.hasMore) } + + override suspend fun getReactionSummary( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): Result = service.getReactionSummary(owner, chatId, messageId) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun getReactionSummaries( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result> = service.getReactionSummaries(owner, chatId, queryOptions) + .onFailure { ErrorUtils.handleError(it) } + override suspend fun advancePointer( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index 04e96fb3c..fcc23ec58 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -389,4 +389,89 @@ sealed class ResolveContactError( class Denied : ResolveContactError("Denied") class Unrecognized : ResolveContactError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : ResolveContactError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetDeltaError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetDeltaError("Denied") + class ResetRequired : GetDeltaError("Reset required") + class Unrecognized : GetDeltaError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetDeltaError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class EditMessageError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : EditMessageError("Denied") + class MessageNotFound : EditMessageError("Message not found") + class CannotEdit : EditMessageError("Cannot edit") + class Conflict : EditMessageError("Conflict") + class Unrecognized : EditMessageError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : EditMessageError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class DeleteMessageError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : DeleteMessageError("Denied") + class MessageNotFound : DeleteMessageError("Message not found") + class CannotDelete : DeleteMessageError("Cannot delete") + class Conflict : DeleteMessageError("Conflict") + class Unrecognized : DeleteMessageError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : DeleteMessageError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class AddReactionError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : AddReactionError("Denied") + class MessageNotFound : AddReactionError("Message not found") + class CannotReact : AddReactionError("Cannot react") + class TooManyReactionTypes : AddReactionError("Too many reaction types") + class Unrecognized : AddReactionError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : AddReactionError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class RemoveReactionError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : RemoveReactionError("Denied") + class MessageNotFound : RemoveReactionError("Message not found") + class Unrecognized : RemoveReactionError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : RemoveReactionError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetReactorsError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetReactorsError("Denied") + class MessageNotFound : GetReactorsError("Message not found") + class Unrecognized : GetReactorsError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetReactorsError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetReactionSummaryError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetReactionSummaryError("Denied") + class MessageNotFound : GetReactionSummaryError("Message not found") + class Unrecognized : GetReactionSummaryError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetReactionSummaryError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetReactionSummariesError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetReactionSummariesError("Denied") + class Unrecognized : GetReactionSummariesError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetReactionSummariesError(message = cause?.message, cause = cause), NotifiableError } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatEvent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatEvent.kt new file mode 100644 index 000000000..0321daf19 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatEvent.kt @@ -0,0 +1,10 @@ +package com.flipcash.services.models.chat + +import kotlin.time.Instant + +data class ChatEvent( + val sequence: Long, + val count: Int, + val ts: Instant, + val mutations: List, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt index 1b4baf4fd..b82f660a8 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt @@ -9,6 +9,9 @@ data class ChatMessage( val content: List, val timestamp: Instant, val unreadSeq: Long, + val lastEditedTs: Instant? = null, + val eventSequence: Long = 0, + val reactions: ReactionSummary? = null, val isFromSelf: Boolean = false, val deliveryStatus: DeliveryStatus = DeliveryStatus.SENT, val pendingClientIdHex: String? = null, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt index fc93f3707..ff3137669 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt @@ -8,4 +8,5 @@ data class ChatMetadata( val members: List, val lastMessage: ChatMessage?, val lastActivity: Instant, + val latestEventSequence: Long = 0, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMutation.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMutation.kt new file mode 100644 index 000000000..3102de21c --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMutation.kt @@ -0,0 +1,9 @@ +package com.flipcash.services.models.chat + +sealed interface ChatMutation { + val message: ChatMessage + + data class MessageSent(override val message: ChatMessage) : ChatMutation + data class MessageEdited(override val message: ChatMessage) : ChatMutation + data class MessageDeleted(override val message: ChatMessage) : ChatMutation +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt index 6bb655764..5d541ae4b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatUpdate.kt @@ -2,8 +2,11 @@ package com.flipcash.services.models.chat data class ChatUpdate( val chatId: ChatId, - val newMessages: List, - val pointerUpdates: List, - val typingNotifications: List, - val metadataUpdates: List, + @Deprecated("Use events instead", replaceWith = ReplaceWith("events")) + val newMessages: List = emptyList(), + val pointerUpdates: List = emptyList(), + val typingNotifications: List = emptyList(), + val metadataUpdates: List = emptyList(), + val events: List = emptyList(), + val reactionUpdates: List = emptyList(), ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Emoji.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Emoji.kt new file mode 100644 index 000000000..21c40d1e1 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Emoji.kt @@ -0,0 +1,4 @@ +package com.flipcash.services.models.chat + +@JvmInline +value class Emoji(val value: String) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EmojiReaction.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EmojiReaction.kt new file mode 100644 index 000000000..ea3b8ec73 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/EmojiReaction.kt @@ -0,0 +1,9 @@ +package com.flipcash.services.models.chat + +data class EmojiReaction( + val emoji: Emoji, + val count: Long, + val reactedBySelf: Boolean, + val sampleReactors: List, + val sequence: Long, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaId.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaId.kt new file mode 100644 index 000000000..9f7f43805 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaId.kt @@ -0,0 +1,7 @@ +package com.flipcash.services.models.chat + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +value class MediaId(val bytes: ByteArray) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt new file mode 100644 index 000000000..89b2fd7b6 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt @@ -0,0 +1,9 @@ +package com.flipcash.services.models.chat + +import kotlinx.serialization.Serializable + +@Serializable +data class MediaItem( + val mediaId: MediaId, + val metadata: MediaMetadata?, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaMetadata.kt new file mode 100644 index 000000000..359aebb39 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaMetadata.kt @@ -0,0 +1,13 @@ +package com.flipcash.services.models.chat + +import kotlinx.serialization.Serializable + +@Serializable +data class MediaMetadata( + val mimeType: String, + val sizeBytes: Long, + val width: Int, + val height: Int, + val blurhash: String, + val durationMs: Long, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt index 9d8dac8ee..96cf5f8c7 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt @@ -3,6 +3,7 @@ package com.flipcash.services.models.chat import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint +import kotlin.time.Instant sealed interface MessageContent { data class Text(val text: String) : MessageContent @@ -13,4 +14,17 @@ sealed interface MessageContent { val tokenName: String = "", val tokenImageUrl: String = "", ) : MessageContent + data class Reply( + val repliedMessageId: Long, + val content: List, + ) : MessageContent + data class Media( + val items: List, + val caption: Text?, + ) : MessageContent + data class System(val fallbackText: String) : MessageContent + data class Deleted( + val deletedTs: Instant, + val deletedBy: ID?, + ) : MessageContent } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionSummary.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionSummary.kt new file mode 100644 index 000000000..5c8dc00c3 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionSummary.kt @@ -0,0 +1,6 @@ +package com.flipcash.services.models.chat + +data class ReactionSummary( + val messageId: Long, + val reactions: List, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionUpdate.kt new file mode 100644 index 000000000..b99e96eb9 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ReactionUpdate.kt @@ -0,0 +1,20 @@ +package com.flipcash.services.models.chat + +import com.getcode.opencode.model.core.ID +import kotlin.time.Instant + +data class ReactionUpdate( + val messageId: Long, + val emoji: Emoji, + val actor: ID, + val action: Action, + val count: Long, + val sequence: Long, + val reactedAt: Instant, +) { + enum class Action { + UNKNOWN, + ADDED, + REMOVED, + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Reactor.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Reactor.kt new file mode 100644 index 000000000..dd9965a73 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Reactor.kt @@ -0,0 +1,9 @@ +package com.flipcash.services.models.chat + +import com.getcode.opencode.model.core.ID +import kotlin.time.Instant + +data class Reactor( + val userId: ID, + val reactedAt: Instant, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt index 6b5b350f9..9ee3e199f 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt @@ -4,10 +4,15 @@ import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.ReactionSummary +import com.flipcash.services.models.chat.Reactor import com.flipcash.services.models.chat.TypingState import com.getcode.ed25519.Ed25519.KeyPair +import kotlinx.coroutines.flow.Flow interface ChatMessagingRepository { suspend fun getMessage( @@ -28,6 +33,12 @@ interface ChatMessagingRepository { messageIds: List, ): Result> + fun getDelta( + owner: KeyPair, + chatId: ChatId, + afterSequence: Long, + ): Flow> + suspend fun sendMessage( owner: KeyPair, chatId: ChatId, @@ -35,6 +46,55 @@ interface ChatMessagingRepository { clientMessageId: ClientMessageId, ): Result + suspend fun editMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + content: List, + expectedEventSequence: Long, + ): Result + + suspend fun deleteMessage( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + expectedEventSequence: Long, + ): Result + + suspend fun addReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result + + suspend fun removeReaction( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + ): Result + + suspend fun getReactors( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + emoji: Emoji, + queryOptions: QueryOptions, + ): Result + + suspend fun getReactionSummary( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): Result + + suspend fun getReactionSummaries( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result> + suspend fun advancePointer( owner: KeyPair, chatId: ChatId, @@ -48,3 +108,14 @@ interface ChatMessagingRepository { state: TypingState, ): Result } + +data class DeltaUpdate( + val messages: List, + val latestSequence: Long, + val checkpointSequence: Long, +) + +data class ReactorsPage( + val reactors: List, + val hasMore: Boolean, +) diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt index d19fb609f..e8b0a5812 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt @@ -4,10 +4,18 @@ import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.Emoji +import com.flipcash.services.models.chat.EmojiReaction import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.Reactor +import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.TypingState import com.flipcash.services.repository.ChatMessagingRepository +import com.flipcash.services.repository.DeltaUpdate +import com.flipcash.services.repository.ReactorsPage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import com.flipcash.services.user.UserManager import com.getcode.ed25519.Ed25519 import com.getcode.opencode.model.accounts.AccountCluster @@ -47,6 +55,14 @@ class ChatMessagingControllerTest { unreadSeq = id, ) + private fun stubReaction(emoji: Emoji, count: Long = 1) = EmojiReaction( + emoji = emoji, + count = count, + reactedBySelf = false, + sampleReactors = emptyList(), + sequence = 1, + ) + // region getMessage @Test @@ -183,6 +199,281 @@ class ChatMessagingControllerTest { } // endregion + + // region editMessage + + @Test + fun `editMessage fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.editMessage(testChatId, 1, listOf(MessageContent.Text("edited")), 5) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `editMessage forwards parameters`() = runTest { + stubOwner() + val content = listOf(MessageContent.Text("edited")) + repository.editMessageResult = Result.success(stubMessage(1, "edited")) + + controller.editMessage(testChatId, 1, content, 5) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(1L, repository.lastMessageId) + assertEquals(content, repository.lastContent) + assertEquals(5L, repository.lastExpectedEventSequence) + } + + @Test + fun `editMessage returns updated message`() = runTest { + stubOwner() + val edited = stubMessage(1, "edited") + repository.editMessageResult = Result.success(edited) + + val result = controller.editMessage(testChatId, 1, listOf(MessageContent.Text("edited")), 5) + + assertEquals("edited", (result.getOrThrow().content.first() as MessageContent.Text).text) + } + + // endregion + + // region deleteMessage + + @Test + fun `deleteMessage fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.deleteMessage(testChatId, 1, 5) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `deleteMessage forwards parameters`() = runTest { + stubOwner() + repository.deleteMessageResult = Result.success(stubMessage(1)) + + controller.deleteMessage(testChatId, 1, 5) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(1L, repository.lastMessageId) + assertEquals(5L, repository.lastExpectedEventSequence) + } + + @Test + fun `deleteMessage surfaces repository failure`() = runTest { + stubOwner() + val cause = RuntimeException("not found") + repository.deleteMessageResult = Result.failure(cause) + + val result = controller.deleteMessage(testChatId, 1, 5) + + assertTrue(result.isFailure) + assertSame(cause, result.exceptionOrNull()) + } + + // endregion + + // region addReaction + + @Test + fun `addReaction fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.addReaction(testChatId, 1, Emoji("\uD83D\uDC4D")) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `addReaction forwards parameters`() = runTest { + stubOwner() + val emoji = Emoji("\uD83D\uDC4D") + repository.addReactionResult = Result.success(stubReaction(emoji)) + + controller.addReaction(testChatId, 1, emoji) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(1L, repository.lastMessageId) + assertEquals(emoji, repository.lastEmoji) + } + + @Test + fun `addReaction returns reaction from repository`() = runTest { + stubOwner() + val emoji = Emoji("\uD83D\uDC4D") + val reaction = stubReaction(emoji, count = 3) + repository.addReactionResult = Result.success(reaction) + + val result = controller.addReaction(testChatId, 1, emoji) + + assertEquals(3L, result.getOrThrow().count) + assertEquals(emoji, result.getOrThrow().emoji) + } + + // endregion + + // region removeReaction + + @Test + fun `removeReaction fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.removeReaction(testChatId, 1, Emoji("\uD83D\uDC4D")) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `removeReaction forwards parameters`() = runTest { + stubOwner() + val emoji = Emoji("\uD83D\uDE00") + repository.removeReactionResult = Result.success(stubReaction(emoji, count = 0)) + + controller.removeReaction(testChatId, 1, emoji) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(1L, repository.lastMessageId) + assertEquals(emoji, repository.lastEmoji) + } + + // endregion + + // region getReactors + + @Test + fun `getReactors fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.getReactors(testChatId, 1, Emoji("\uD83D\uDC4D")) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `getReactors forwards parameters with default QueryOptions`() = runTest { + stubOwner() + val emoji = Emoji("\uD83D\uDC4D") + repository.getReactorsResult = Result.success(ReactorsPage(emptyList(), hasMore = false)) + + controller.getReactors(testChatId, 1, emoji) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(1L, repository.lastMessageId) + assertEquals(emoji, repository.lastEmoji) + assertEquals(QueryOptions(), repository.lastQueryOptions) + } + + @Test + fun `getReactors returns page from repository`() = runTest { + stubOwner() + val emoji = Emoji("\uD83D\uDC4D") + val page = ReactorsPage( + reactors = listOf(Reactor(userId = listOf(1.toByte()), reactedAt = Instant.fromEpochSeconds(1000))), + hasMore = true, + ) + repository.getReactorsResult = Result.success(page) + + val result = controller.getReactors(testChatId, 1, emoji) + + assertEquals(1, result.getOrThrow().reactors.size) + assertTrue(result.getOrThrow().hasMore) + } + + // endregion + + // region getReactionSummary + + @Test + fun `getReactionSummary fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.getReactionSummary(testChatId, 1) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `getReactionSummary forwards chatId and messageId`() = runTest { + stubOwner() + repository.getReactionSummaryResult = Result.success(ReactionSummary(messageId = 42, reactions = emptyList())) + + controller.getReactionSummary(testChatId, 42) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(42L, repository.lastMessageId) + } + + @Test + fun `getReactionSummary returns summary from repository`() = runTest { + stubOwner() + val summary = ReactionSummary( + messageId = 1, + reactions = listOf(stubReaction(Emoji("\uD83D\uDC4D"), count = 5)), + ) + repository.getReactionSummaryResult = Result.success(summary) + + val result = controller.getReactionSummary(testChatId, 1) + + assertEquals(1, result.getOrThrow().reactions.size) + assertEquals(5L, result.getOrThrow().reactions.first().count) + } + + // endregion + + // region getReactionSummaries + + @Test + fun `getReactionSummaries fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = controller.getReactionSummaries(testChatId) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `getReactionSummaries uses default QueryOptions`() = runTest { + stubOwner() + repository.getReactionSummariesResult = Result.success(emptyList()) + + controller.getReactionSummaries(testChatId) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(QueryOptions(), repository.lastQueryOptions) + } + + @Test + fun `getReactionSummaries returns list from repository`() = runTest { + stubOwner() + val summaries = listOf( + ReactionSummary(messageId = 1, reactions = listOf(stubReaction(Emoji("\uD83D\uDC4D")))), + ReactionSummary(messageId = 2, reactions = emptyList()), + ) + repository.getReactionSummariesResult = Result.success(summaries) + + val result = controller.getReactionSummaries(testChatId) + + assertEquals(2, result.getOrThrow().size) + } + + // endregion + + // region getDelta + + @Test + fun `getDelta fails when no account cluster`() = runTest { + every { userManager.accountCluster } returns null + val result = runCatching { controller.getDelta(testChatId, 0) } + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `getDelta forwards chatId and afterSequence`() = runTest { + stubOwner() + + controller.getDelta(testChatId, 10) + + assertEquals(testChatId, repository.lastChatId) + assertEquals(10L, repository.lastAfterSequence) + } + + // endregion } // region Fakes @@ -192,15 +483,25 @@ private class FakeChatMessagingRepository : ChatMessagingRepository { var getMessagesResult: Result> = Result.failure(RuntimeException("not configured")) var getMessagesByIdsResult: Result> = Result.failure(RuntimeException("not configured")) var sendMessageResult: Result = Result.failure(RuntimeException("not configured")) + var editMessageResult: Result = Result.failure(RuntimeException("not configured")) + var deleteMessageResult: Result = Result.failure(RuntimeException("not configured")) + var addReactionResult: Result = Result.failure(RuntimeException("not configured")) + var removeReactionResult: Result = Result.failure(RuntimeException("not configured")) + var getReactorsResult: Result = Result.failure(RuntimeException("not configured")) + var getReactionSummaryResult: Result = Result.failure(RuntimeException("not configured")) + var getReactionSummariesResult: Result> = Result.failure(RuntimeException("not configured")) var advancePointerResult: Result = Result.failure(RuntimeException("not configured")) var notifyIsTypingResult: Result = Result.failure(RuntimeException("not configured")) var lastChatId: ChatId? = null var lastMessageId: Long? = null var lastMessageIds: List? = null + var lastAfterSequence: Long? = null var lastQueryOptions: QueryOptions? = null var lastContent: List? = null var lastClientMessageId: ClientMessageId? = null + var lastExpectedEventSequence: Long? = null + var lastEmoji: Emoji? = null var lastPointerType: PointerType? = null var lastTypingState: TypingState? = null @@ -219,11 +520,51 @@ private class FakeChatMessagingRepository : ChatMessagingRepository { return getMessagesByIdsResult } + override fun getDelta(owner: Ed25519.KeyPair, chatId: ChatId, afterSequence: Long): Flow> { + lastChatId = chatId; lastAfterSequence = afterSequence + return flowOf(Result.failure(RuntimeException("not configured"))) + } + override suspend fun sendMessage(owner: Ed25519.KeyPair, chatId: ChatId, content: List, clientMessageId: ClientMessageId): Result { lastChatId = chatId; lastContent = content; lastClientMessageId = clientMessageId return sendMessageResult } + override suspend fun editMessage(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long, content: List, expectedEventSequence: Long): Result { + lastChatId = chatId; lastMessageId = messageId; lastContent = content; lastExpectedEventSequence = expectedEventSequence + return editMessageResult + } + + override suspend fun deleteMessage(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long, expectedEventSequence: Long): Result { + lastChatId = chatId; lastMessageId = messageId; lastExpectedEventSequence = expectedEventSequence + return deleteMessageResult + } + + override suspend fun addReaction(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long, emoji: Emoji): Result { + lastChatId = chatId; lastMessageId = messageId; lastEmoji = emoji + return addReactionResult + } + + override suspend fun removeReaction(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long, emoji: Emoji): Result { + lastChatId = chatId; lastMessageId = messageId; lastEmoji = emoji + return removeReactionResult + } + + override suspend fun getReactors(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long, emoji: Emoji, queryOptions: QueryOptions): Result { + lastChatId = chatId; lastMessageId = messageId; lastEmoji = emoji; lastQueryOptions = queryOptions + return getReactorsResult + } + + override suspend fun getReactionSummary(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long): Result { + lastChatId = chatId; lastMessageId = messageId + return getReactionSummaryResult + } + + override suspend fun getReactionSummaries(owner: Ed25519.KeyPair, chatId: ChatId, queryOptions: QueryOptions): Result> { + lastChatId = chatId; lastQueryOptions = queryOptions + return getReactionSummariesResult + } + override suspend fun advancePointer(owner: Ed25519.KeyPair, chatId: ChatId, pointerType: PointerType, messageId: Long): Result { lastChatId = chatId; lastPointerType = pointerType; lastMessageId = messageId return advancePointerResult