From cbdeda41a969f1d9acaffdcc8e120b60e87bf48c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 16:58:10 -0400 Subject: [PATCH 1/4] fix(flipcash): migrate reaction self-flag to self_reactor flipcash2 replaced EmojiReaction.reacted_by_self (bool) with a self_reactor Reactor message, so a reaction now carries who reacted and when instead of just a flag. Reactor also gains a version field. Update the domain model, both proto-to-domain mapping directions, Room serialization (EmojiReactionSerialized/ReactorSerialized), and the local reaction-overlay logic in EventStreamDelegate, which has to derive self status itself since ReactionUpdate carries no self flag: it compares the update actor to the signed-in user and uses the ADDED/REMOVED action to set or clear self_reactor. --- .../internal/delegates/EventStreamDelegate.kt | 22 +++++++++++++++++-- .../converters/ChatTypeConverters.kt | 3 ++- .../converters/ChatTypeConvertersTest.kt | 4 ++-- .../sources/mapper/chat/ChatEntityMapper.kt | 6 +++-- .../network/extensions/ProtobufToLocal.kt | 3 ++- .../services/models/chat/EmojiReaction.kt | 2 +- .../flipcash/services/models/chat/Reactor.kt | 1 + .../ChatMessagingControllerTest.kt | 2 +- 8 files changed, 33 insertions(+), 10 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 2032051dfe..fed1c3df25 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -16,6 +16,7 @@ import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MetadataUpdate 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.RosterChange import com.flipcash.services.models.chat.TypingNotification import com.flipcash.services.models.chat.TypingState @@ -448,23 +449,40 @@ class EventStreamDelegate @Inject constructor( val existing = overlays[update.messageId] val existingReactions = existing?.reactions?.toMutableList() ?: mutableListOf() + // The server does not report a "self" flag on the update itself, so the client + // maintains EmojiReaction.selfReactor by comparing the update's actor to the + // signed-in user: an ADDED update from self sets it, a REMOVED update from self + // clears it, and an update from anyone else leaves the existing self state alone. + val isSelfActor = update.actor == userManager.accountId + val idx = existingReactions.indexOfFirst { it.emoji == update.emoji } if (idx >= 0) { val current = existingReactions[idx] if (update.sequence <= current.sequence) return + val selfReactor = when { + isSelfActor && update.action == ReactionUpdate.Action.ADDED -> + Reactor(userId = update.actor, reactedAt = update.reactedAt, version = current.selfReactor?.version ?: 0) + isSelfActor && update.action == ReactionUpdate.Action.REMOVED -> null + else -> current.selfReactor + } existingReactions[idx] = EmojiReaction( emoji = update.emoji, count = update.count, - reactedBySelf = current.reactedBySelf, + selfReactor = selfReactor, sampleReactors = current.sampleReactors, sequence = update.sequence, ) } else { + val selfReactor = if (isSelfActor && update.action == ReactionUpdate.Action.ADDED) { + Reactor(userId = update.actor, reactedAt = update.reactedAt) + } else { + null + } existingReactions.add( EmojiReaction( emoji = update.emoji, count = update.count, - reactedBySelf = false, + selfReactor = selfReactor, sampleReactors = emptyList(), sequence = update.sequence, ) 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 cd61fb4133..adae839637 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 @@ -257,7 +257,7 @@ data class ReactionSummarySerialized( data class EmojiReactionSerialized( val emoji: String, val count: Long, - val reactedBySelf: Boolean, + val selfReactor: ReactorSerialized? = null, val sampleReactors: List, val sequence: Long, ) @@ -266,6 +266,7 @@ data class EmojiReactionSerialized( data class ReactorSerialized( val userIdHex: String, val reactedAtEpochSeconds: Long, + val version: 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 index dcc5b39091..a752a6b171 100644 --- 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 @@ -19,7 +19,7 @@ class ChatTypeConvertersTest { EmojiReactionSerialized( emoji = "\uD83D\uDE00", count = 3, - reactedBySelf = true, + selfReactor = ReactorSerialized(userIdHex = "1234", reactedAtEpochSeconds = 1500L), sampleReactors = listOf( ReactorSerialized(userIdHex = "aabb", reactedAtEpochSeconds = 1000L), ReactorSerialized(userIdHex = "ccdd", reactedAtEpochSeconds = 2000L), @@ -29,7 +29,7 @@ class ChatTypeConvertersTest { EmojiReactionSerialized( emoji = "\uD83D\uDC4D", count = 1, - reactedBySelf = false, + selfReactor = null, sampleReactors = emptyList(), sequence = 3, ), 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 a27cb3790c..a68f3587d1 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 @@ -389,7 +389,7 @@ private fun ReactionSummary.toSerialized(): ReactionSummarySerialized = Reaction private fun EmojiReaction.toSerialized(): EmojiReactionSerialized = EmojiReactionSerialized( emoji = emoji.value, count = count, - reactedBySelf = reactedBySelf, + selfReactor = selfReactor?.toSerialized(), sampleReactors = sampleReactors.map { it.toSerialized() }, sequence = sequence, ) @@ -397,6 +397,7 @@ private fun EmojiReaction.toSerialized(): EmojiReactionSerialized = EmojiReactio private fun Reactor.toSerialized(): ReactorSerialized = ReactorSerialized( userIdHex = userId.hexEncodedString(), reactedAtEpochSeconds = reactedAt.epochSeconds, + version = version, ) private fun ReactionSummarySerialized.toDomain(): ReactionSummary = ReactionSummary( @@ -407,7 +408,7 @@ private fun ReactionSummarySerialized.toDomain(): ReactionSummary = ReactionSumm private fun EmojiReactionSerialized.toDomain(): EmojiReaction = EmojiReaction( emoji = Emoji(emoji), count = count, - reactedBySelf = reactedBySelf, + selfReactor = selfReactor?.toDomain(), sampleReactors = sampleReactors.map { it.toDomain() }, sequence = sequence, ) @@ -415,6 +416,7 @@ private fun EmojiReactionSerialized.toDomain(): EmojiReaction = EmojiReaction( private fun ReactorSerialized.toDomain(): Reactor = Reactor( userId = userIdHex.hexToIdExt(), reactedAt = Instant.fromEpochSeconds(reactedAtEpochSeconds), + version = version, ) // endregion 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 3dfeb85dc1..96cc60907b 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 @@ -260,7 +260,7 @@ internal fun MessagingModel.EmojiReaction.toEmojiReaction(): EmojiReaction { return EmojiReaction( emoji = Emoji(emoji.value), count = count, - reactedBySelf = reactedBySelf, + selfReactor = if (hasSelfReactor()) selfReactor.toReactor() else null, sampleReactors = sampleReactorsList.map { it.toReactor() }, sequence = version, ) @@ -270,6 +270,7 @@ internal fun MessagingModel.Reactor.toReactor(): Reactor { return Reactor( userId = userId.toId(), reactedAt = Instant.fromEpochSeconds(reactedTs.seconds, reactedTs.nanos), + version = version, ) } 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 index ea3b8ec73f..cfba62d242 100644 --- 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 @@ -3,7 +3,7 @@ package com.flipcash.services.models.chat data class EmojiReaction( val emoji: Emoji, val count: Long, - val reactedBySelf: Boolean, + val selfReactor: Reactor?, val sampleReactors: List, val sequence: Long, ) 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 index dd9965a73c..740cd0dac7 100644 --- 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 @@ -6,4 +6,5 @@ import kotlin.time.Instant data class Reactor( val userId: ID, val reactedAt: Instant, + val version: Long = 0, ) 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 e8b0a58128..0377b1ec6c 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 @@ -58,7 +58,7 @@ class ChatMessagingControllerTest { private fun stubReaction(emoji: Emoji, count: Long = 1) = EmojiReaction( emoji = emoji, count = count, - reactedBySelf = false, + selfReactor = null, sampleReactors = emptyList(), sequence = 1, ) From b9797a996496a758abaf8266c75a3fb2dd708b59 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 16:59:38 -0400 Subject: [PATCH 2/4] feat(flipcash): add chat view-mode redaction and mute/unmute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads the flipcash2 ViewMode (FULL / FULL_OR_REDACTED / REDACTED) through GetChat, GetMessage(s), and GetDelta so a caller can ask for redacted content instead of full and the response can report a message as ChatMessage.redacted. Adds MuteChat/UnmuteChat as a new pair of RPCs on Chat: MuteState (Until/Forever) and ViewerState (per-caller chat state, currently just mute) get domain models, ChatMetadata and MetadataUpdate carry the new ViewerStateChanged case, ChatController/ChatRepository gain muteChat/ unmuteChat, and MuteChatError/UnmuteChatError join the error taxonomy. Two things are deliberately left as scaffolding for the muting feature itself, not built here: - EventStreamDelegate acknowledges ViewerStateChanged without persisting it — there is no chat_metadata column for viewer state yet, and adding one is a Room migration that belongs with that feature's storage work. - PushChatMetadata.muted is threaded through from the proto so it reaches NotificationPayload, but nothing yet uses it to suppress a notification. --- .../internal/delegates/EventStreamDelegate.kt | 7 +++ .../services/controllers/ChatController.kt | 26 +++++++++- .../controllers/ChatMessagingController.kt | 33 +++++++++--- .../internal/domain/ChatMetadataMapper.kt | 2 + .../services/internal/network/api/ChatApi.kt | 40 ++++++++++++++ .../internal/network/api/ChatMessagingApi.kt | 10 ++++ .../network/extensions/LocalToProtobuf.kt | 18 +++++++ .../network/extensions/ProtobufToLocal.kt | 25 +++++++++ .../network/services/ChatMessagingService.kt | 13 +++-- .../internal/network/services/ChatService.kt | 52 ++++++++++++++++++- .../InternalChatMessagingRepository.kt | 13 +++-- .../repositories/InternalChatRepository.kt | 22 +++++++- .../com/flipcash/services/models/Errors.kt | 20 +++++++ .../services/models/NotificationPayload.kt | 4 ++ .../services/models/chat/ChatMessage.kt | 3 ++ .../services/models/chat/ChatMetadata.kt | 2 + .../services/models/chat/MetadataUpdate.kt | 2 + .../services/models/chat/MuteState.kt | 14 +++++ .../flipcash/services/models/chat/ViewMode.kt | 17 ++++++ .../services/models/chat/ViewerState.kt | 13 +++++ .../repository/ChatMessagingRepository.kt | 5 ++ .../services/repository/ChatRepository.kt | 17 ++++++ .../controllers/ChatControllerTest.kt | 27 +++++++++- .../ChatMessagingControllerTest.kt | 9 ++-- 24 files changed, 369 insertions(+), 25 deletions(-) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MuteState.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewMode.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index fed1c3df25..46f12a88b4 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -359,6 +359,13 @@ class EventStreamDelegate @Inject constructor( metaUpdate.newLastActivity.toEpochMilliseconds(), ) } + is MetadataUpdate.ViewerStateChanged -> { + // The receiving user's own chat state (currently just mute) changed. + // Persisting it needs a chat_metadata column/migration that belongs + // with the muting feature's storage work, not this contract sync, so + // the update is acknowledged here without a write. Wire this up to + // metadataDataSource once that column exists. + } } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt index 630ba36134..577dfa903c 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt @@ -6,7 +6,10 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.StartChatParameters +import com.flipcash.services.models.chat.ViewerState +import com.flipcash.services.models.chat.ViewMode import com.flipcash.services.repository.ChatRepository import com.flipcash.services.user.UserManager import javax.inject.Inject @@ -17,11 +20,14 @@ class ChatController @Inject constructor( private val repository: ChatRepository, private val userManager: UserManager, ) { - suspend fun getChat(chatId: ChatId): Result { + suspend fun getChat( + chatId: ChatId, + viewMode: ViewMode = ViewMode.FULL, + ): Result { val owner = userManager.accountCluster?.authority?.keyPair ?: return Result.failure(Throwable("No account cluster in UserManager")) - return repository.getChat(owner, chatId) + return repository.getChat(owner, chatId, viewMode) } suspend fun getDmChatFeed( @@ -75,4 +81,20 @@ class ChatController @Inject constructor( return repository.leaveChat(owner, chatId) } + + /** Mutes [chatId] for the caller per [mute], returning the caller's new viewer state. */ + suspend fun muteChat(chatId: ChatId, mute: MuteState): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.muteChat(owner, chatId, mute) + } + + /** Clears any mute on [chatId] for the caller, returning the caller's new viewer state. */ + suspend fun unmuteChat(chatId: ChatId): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.unmuteChat(owner, chatId) + } } 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 96481143a0..98d3105e01 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 @@ -10,6 +10,7 @@ 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.models.chat.ViewMode import com.flipcash.services.repository.ChatMessagingRepository import com.flipcash.services.repository.DeltaUpdate import com.flipcash.services.repository.ReactorsPage @@ -26,24 +27,40 @@ class ChatMessagingController @Inject constructor( private fun requireOwner() = userManager.accountCluster?.authority?.keyPair ?: throw IllegalStateException("No account cluster in UserManager") - suspend fun getMessage(chatId: ChatId, messageId: Long): Result { + suspend fun getMessage( + chatId: ChatId, + messageId: Long, + viewMode: ViewMode = ViewMode.FULL, + ): Result { val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } - return repository.getMessage(owner, chatId, messageId) + return repository.getMessage(owner, chatId, messageId, viewMode) } - suspend fun getMessages(chatId: ChatId, queryOptions: QueryOptions = QueryOptions()): Result> { + suspend fun getMessages( + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + viewMode: ViewMode = ViewMode.FULL, + ): Result> { val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } - return repository.getMessages(owner, chatId, queryOptions) + return repository.getMessages(owner, chatId, queryOptions, viewMode) } - suspend fun getMessagesByIds(chatId: ChatId, messageIds: List): Result> { + suspend fun getMessagesByIds( + chatId: ChatId, + messageIds: List, + viewMode: ViewMode = ViewMode.FULL, + ): Result> { val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } - return repository.getMessagesByIds(owner, chatId, messageIds) + return repository.getMessagesByIds(owner, chatId, messageIds, viewMode) } - fun getDelta(chatId: ChatId, afterSequence: Long): Flow> { + fun getDelta( + chatId: ChatId, + afterSequence: Long, + viewMode: ViewMode = ViewMode.FULL, + ): Flow> { val owner = requireOwner() - return repository.getDelta(owner, chatId, afterSequence) + return repository.getDelta(owner, chatId, afterSequence, viewMode) } suspend fun sendMessage( 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 c9e2a5b862..4bbc5fff41 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 @@ -10,6 +10,7 @@ import com.flipcash.services.internal.network.extensions.toId import com.flipcash.services.internal.network.extensions.toMediaItem import com.flipcash.services.internal.network.extensions.toPointer import com.flipcash.services.internal.network.extensions.toRosterSummary +import com.flipcash.services.internal.network.extensions.toViewerState import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMetadata import kotlin.time.Instant @@ -42,6 +43,7 @@ class ChatMetadataMapper @Inject constructor( picture = if (from.hasPicture()) from.picture.toMediaItem() else null, rosterSummary = from.rosterSummary.toRosterSummary(), rules = if (from.hasRules()) from.rules.toChatRules() else null, + viewerState = if (from.hasViewerState()) from.viewerState.toViewerState() else null, ) } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt index 7a3f8762df..82bf7160eb 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt @@ -7,14 +7,18 @@ import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.network.extensions.asChatId import com.flipcash.services.internal.network.extensions.asProtoChatType import com.flipcash.services.internal.network.extensions.asProtoIdempotencyKey +import com.flipcash.services.internal.network.extensions.asProtoMuteState import com.flipcash.services.internal.network.extensions.asProtoRules import com.flipcash.services.internal.network.extensions.asQueryOptions +import com.flipcash.services.internal.network.extensions.asViewMode 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.ChatType import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.StartChatParameters +import com.flipcash.services.models.chat.ViewMode import com.getcode.utils.toByteString import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.core.GrpcApi @@ -37,9 +41,11 @@ internal class ChatApi @Inject constructor( suspend fun getChat( owner: KeyPair, chatId: ChatId, + viewMode: ViewMode = ViewMode.FULL, ): RpcChatService.GetChatResponse { val request = RpcChatService.GetChatRequest.newBuilder() .setChatId(chatId.asChatId()) + .setViewMode(viewMode.asViewMode()) .apply { setAuth(authenticate(owner)) } .build() @@ -154,4 +160,38 @@ internal class ChatApi @Inject constructor( api.leaveChat(request) } } + + suspend fun muteChat( + owner: KeyPair, + chatId: ChatId, + mute: MuteState, + ): RpcChatService.MuteChatResponse { + val request = RpcChatService.MuteChatRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMute(mute.asProtoMuteState()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.muteChat(request) + } + } + + suspend fun unmuteChat( + owner: KeyPair, + chatId: ChatId, + ): RpcChatService.UnmuteChatResponse { + val request = RpcChatService.UnmuteChatRequest.newBuilder() + .setChatId(chatId.asChatId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.unmuteChat(request) + } + } } 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 684d51224f..70222b90b9 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 @@ -13,6 +13,7 @@ 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 +import com.flipcash.services.internal.network.extensions.asViewMode import com.flipcash.services.internal.network.extensions.authenticate import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId @@ -21,6 +22,7 @@ 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 com.flipcash.services.models.chat.ViewMode import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn import com.getcode.ed25519.Ed25519.KeyPair @@ -46,10 +48,12 @@ internal class ChatMessagingApi @Inject constructor( owner: KeyPair, chatId: ChatId, messageId: Long, + viewMode: ViewMode = ViewMode.FULL, ): RpcMessagingService.GetMessageResponse { val request = RpcMessagingService.GetMessageRequest.newBuilder() .setChatId(chatId.asChatId()) .setMessageId(MessagingModel.MessageId.newBuilder().setValue(messageId)) + .setViewMode(viewMode.asViewMode()) .apply { setAuth(authenticate(owner)) } .build() @@ -64,10 +68,12 @@ internal class ChatMessagingApi @Inject constructor( owner: KeyPair, chatId: ChatId, queryOptions: QueryOptions, + viewMode: ViewMode = ViewMode.FULL, ): RpcMessagingService.GetMessagesResponse { val request = RpcMessagingService.GetMessagesRequest.newBuilder() .setChatId(chatId.asChatId()) .setOptions(queryOptions.asQueryOptions()) + .setViewMode(viewMode.asViewMode()) .apply { setAuth(authenticate(owner)) } .build() @@ -82,6 +88,7 @@ internal class ChatMessagingApi @Inject constructor( owner: KeyPair, chatId: ChatId, messageIds: List, + viewMode: ViewMode = ViewMode.FULL, ): RpcMessagingService.GetMessagesResponse { val request = RpcMessagingService.GetMessagesRequest.newBuilder() .setChatId(chatId.asChatId()) @@ -89,6 +96,7 @@ internal class ChatMessagingApi @Inject constructor( MessagingModel.MessageIdBatch.newBuilder() .addAllMessageIds(messageIds.map { MessagingModel.MessageId.newBuilder().setValue(it).build() }) ) + .setViewMode(viewMode.asViewMode()) .apply { setAuth(authenticate(owner)) } .build() @@ -162,10 +170,12 @@ internal class ChatMessagingApi @Inject constructor( owner: KeyPair, chatId: ChatId, afterSequence: Long, + viewMode: ViewMode = ViewMode.FULL, ): Flow { val request = RpcMessagingService.GetDeltaRequest.newBuilder() .setChatId(chatId.asChatId()) .setAfterSequence(afterSequence) + .setViewMode(viewMode.asViewMode()) .apply { setAuth(authenticate(owner)) } .build() 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 6f1ecb9c41..4fa4217cb3 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 @@ -14,8 +14,10 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ClientMessageId import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.TypingState +import com.flipcash.services.models.chat.ViewMode import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.network.jwt.ApiProvider import com.getcode.opencode.model.core.ID @@ -265,3 +267,19 @@ internal fun ChatRuleRequirement.MinimumBalance.asProtoMinimumBalanceRequirement .addAllMints(mints.map { it.asPublicKey() }) .build() } + +internal fun ViewMode.asViewMode(): MessagingModel.ViewMode { + return when (this) { + ViewMode.FULL -> MessagingModel.ViewMode.FULL + ViewMode.FULL_OR_REDACTED -> MessagingModel.ViewMode.FULL_OR_REDACTED + ViewMode.REDACTED -> MessagingModel.ViewMode.REDACTED + } +} + +internal fun MuteState.asProtoMuteState(): ChatModel.MuteState { + val builder = ChatModel.MuteState.newBuilder() + return when (this) { + is MuteState.Until -> builder.setUntil(until.asTimestamp()).build() + MuteState.Forever -> builder.setForever(ChatModel.MuteState.Forever.getDefaultInstance()).build() + } +} 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 96cc60907b..00de8f5ee2 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 @@ -44,6 +44,7 @@ import com.flipcash.services.models.chat.MediaItemRendition 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.MuteState import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatRuleRequirement @@ -54,6 +55,7 @@ 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.flipcash.services.models.chat.ViewerState import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat @@ -115,6 +117,7 @@ internal fun PushModels.Payload.asPayload(): NotificationPayload { sendingUserId = if (chatMetadata.hasSendingUserId()) chatMetadata.sendingUserId.toId() else null, chatType = chatMetadata.type.toChatType(), message = if (chatMetadata.hasMessage()) chatMetadata.message.toChatMessage() else null, + muted = chatMetadata.muted, ) } else null @@ -246,6 +249,7 @@ internal fun MessagingModel.Message.toChatMessage(): ChatMessage { lastEditedTs = if (hasLastEditedTs()) Instant.fromEpochSeconds(lastEditedTs.seconds, lastEditedTs.nanos) else null, eventSequence = eventSequence, reactions = if (hasReactions()) reactions.toReactionSummary() else null, + redacted = redacted, ) } @@ -358,6 +362,8 @@ internal fun ChatModel.MetadataUpdate.toMetadataUpdate( lastActivityChanged.newLastActivity.nanos, ) ) + ChatModel.MetadataUpdate.KindCase.VIEWER_STATE_CHANGED -> + MetadataUpdate.ViewerStateChanged(viewerStateChanged.viewerState.toViewerState()) else -> MetadataUpdate.LastActivityChanged(Instant.fromEpochSeconds(0)) } } @@ -388,9 +394,28 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { picture = if (hasPicture()) picture.toMediaItem() else null, rosterSummary = rosterSummary.toRosterSummary(), rules = if (hasRules()) rules.toChatRules() else null, + viewerState = if (hasViewerState()) viewerState.toViewerState() else null, ) } +// -- Viewer state / mute state -- + +internal fun ChatModel.ViewerState.toViewerState(): ViewerState { + return ViewerState( + mute = if (hasSettings() && settings.hasMute()) settings.mute.toMuteState() else null, + version = version, + ) +} + +internal fun ChatModel.MuteState.toMuteState(): MuteState { + return when (durationCase) { + ChatModel.MuteState.DurationCase.UNTIL -> + MuteState.Until(Instant.fromEpochSeconds(until.seconds, until.nanos)) + ChatModel.MuteState.DurationCase.FOREVER -> MuteState.Forever + else -> MuteState.Forever + } +} + // -- Chat member -- internal fun ChatModel.Member.toChatMember(): ChatMember { 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 41a6a3f034..0b07b0459d 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 @@ -29,6 +29,7 @@ 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.flipcash.services.models.chat.ViewMode import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.extensions.foldWithSuppression import com.getcode.opencode.utils.toValidationOrElse @@ -43,9 +44,10 @@ internal class ChatMessagingService @Inject constructor( owner: KeyPair, chatId: ChatId, messageId: Long, + viewMode: ViewMode = ViewMode.FULL, ): Result { return runCatching { - api.getMessage(owner, chatId, messageId) + api.getMessage(owner, chatId, messageId, viewMode) }.foldWithSuppression( onSuccess = { response -> when (response.result) { @@ -66,9 +68,10 @@ internal class ChatMessagingService @Inject constructor( owner: KeyPair, chatId: ChatId, queryOptions: QueryOptions, + viewMode: ViewMode = ViewMode.FULL, ): Result> { return runCatching { - api.getMessages(owner, chatId, queryOptions) + api.getMessages(owner, chatId, queryOptions, viewMode) }.foldWithSuppression( onSuccess = { response -> when (response.result) { @@ -90,9 +93,10 @@ internal class ChatMessagingService @Inject constructor( owner: KeyPair, chatId: ChatId, messageIds: List, + viewMode: ViewMode = ViewMode.FULL, ): Result> { return runCatching { - api.getMessagesByIds(owner, chatId, messageIds) + api.getMessagesByIds(owner, chatId, messageIds, viewMode) }.foldWithSuppression( onSuccess = { response -> when (response.result) { @@ -183,8 +187,9 @@ internal class ChatMessagingService @Inject constructor( owner: KeyPair, chatId: ChatId, afterSequence: Long, + viewMode: ViewMode = ViewMode.FULL, ): Flow> { - return api.getDelta(owner, chatId, afterSequence).map { response -> + return api.getDelta(owner, chatId, afterSequence, viewMode).map { response -> when (response.result) { RpcMessagingService.GetDeltaResponse.Result.OK -> Result.success( GetDeltaResult( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt index 0d98dbd6c8..9d4f90eadb 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt @@ -9,12 +9,16 @@ import com.flipcash.services.models.GetDmChatFeedError import com.flipcash.services.models.GetGroupChatFeedError import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.LeaveChatError +import com.flipcash.services.models.MuteChatError import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.StartChatError +import com.flipcash.services.models.UnmuteChatError import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.ViewMode import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.extensions.foldWithSuppression import com.getcode.opencode.utils.toValidationOrElse @@ -26,9 +30,10 @@ internal class ChatService @Inject constructor( suspend fun getChat( owner: KeyPair, chatId: ChatId, + viewMode: ViewMode = ViewMode.FULL, ): Result { return runCatching { - api.getChat(owner, chatId) + api.getChat(owner, chatId, viewMode) }.foldWithSuppression( onSuccess = { response -> when (response.result) { @@ -161,4 +166,49 @@ internal class ChatService @Inject constructor( } ) } + + suspend fun muteChat( + owner: KeyPair, + chatId: ChatId, + mute: MuteState, + ): Result { + return runCatching { + api.muteChat(owner, chatId, mute) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.MuteChatResponse.Result.OK -> Result.success(response.viewerState) + RpcChatService.MuteChatResponse.Result.DENIED -> Result.failure(MuteChatError.Denied()) + RpcChatService.MuteChatResponse.Result.NOT_FOUND -> Result.failure(MuteChatError.NotFound()) + RpcChatService.MuteChatResponse.Result.UNRECOGNIZED -> Result.failure(MuteChatError.Unrecognized()) + else -> Result.failure(MuteChatError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { MuteChatError.Other(cause = it) }) + } + ) + } + + suspend fun unmuteChat( + owner: KeyPair, + chatId: ChatId, + ): Result { + return runCatching { + api.unmuteChat(owner, chatId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.UnmuteChatResponse.Result.OK -> Result.success(response.viewerState) + RpcChatService.UnmuteChatResponse.Result.DENIED -> Result.failure(UnmuteChatError.Denied()) + RpcChatService.UnmuteChatResponse.Result.NOT_FOUND -> Result.failure(UnmuteChatError.NotFound()) + RpcChatService.UnmuteChatResponse.Result.UNRECOGNIZED -> Result.failure(UnmuteChatError.Unrecognized()) + else -> Result.failure(UnmuteChatError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { UnmuteChatError.Other(cause = it) }) + } + ) + } } 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 0b9d5e0b5a..5ba26a2157 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 @@ -12,6 +12,7 @@ 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.models.chat.ViewMode import com.flipcash.services.repository.ChatMessagingRepository import com.flipcash.services.repository.DeltaUpdate import com.flipcash.services.repository.ReactorsPage @@ -28,7 +29,8 @@ internal class InternalChatMessagingRepository( owner: KeyPair, chatId: ChatId, messageId: Long, - ): Result = service.getMessage(owner, chatId, messageId) + viewMode: ViewMode, + ): Result = service.getMessage(owner, chatId, messageId, viewMode) .onFailure { ErrorUtils.handleError(it) } .map { it.toChatMessage() } @@ -36,7 +38,8 @@ internal class InternalChatMessagingRepository( owner: KeyPair, chatId: ChatId, queryOptions: QueryOptions, - ): Result> = service.getMessages(owner, chatId, queryOptions) + viewMode: ViewMode, + ): Result> = service.getMessages(owner, chatId, queryOptions, viewMode) .onFailure { ErrorUtils.handleError(it) } .map { messages -> messages.map { it.toChatMessage() } } @@ -44,7 +47,8 @@ internal class InternalChatMessagingRepository( owner: KeyPair, chatId: ChatId, messageIds: List, - ): Result> = service.getMessagesByIds(owner, chatId, messageIds) + viewMode: ViewMode, + ): Result> = service.getMessagesByIds(owner, chatId, messageIds, viewMode) .onFailure { ErrorUtils.handleError(it) } .map { messages -> messages.map { it.toChatMessage() } } @@ -52,7 +56,8 @@ internal class InternalChatMessagingRepository( owner: KeyPair, chatId: ChatId, afterSequence: Long, - ): Flow> = service.getDelta(owner, chatId, afterSequence) + viewMode: ViewMode, + ): Flow> = service.getDelta(owner, chatId, afterSequence, viewMode) .map { result -> result .onFailure { ErrorUtils.handleError(it) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt index 1b4568d316..8f9a93ee0c 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt @@ -2,6 +2,7 @@ package com.flipcash.services.internal.repositories import com.flipcash.services.internal.domain.ChatMetadataMapper import com.flipcash.services.internal.network.extensions.toPagingToken +import com.flipcash.services.internal.network.extensions.toViewerState import com.flipcash.services.internal.network.services.ChatService import com.flipcash.services.models.LeaveChatError import com.flipcash.services.models.QueryOptions @@ -10,7 +11,10 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.StartChatParameters +import com.flipcash.services.models.chat.ViewerState +import com.flipcash.services.models.chat.ViewMode import com.flipcash.services.repository.ChatRepository import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.utils.ErrorUtils @@ -22,7 +26,8 @@ internal class InternalChatRepository( override suspend fun getChat( owner: KeyPair, chatId: ChatId, - ): Result = service.getChat(owner, chatId) + viewMode: ViewMode, + ): Result = service.getChat(owner, chatId, viewMode) .onFailure { ErrorUtils.handleError(it) } .map { mapper.map(it) } @@ -75,4 +80,19 @@ internal class InternalChatRepository( // Recovered before reporting, so an already-left chat is not logged as a failure. .recoverCatching { cause -> if (cause is LeaveChatError.NotFound) Unit else throw cause } .onFailure { ErrorUtils.handleError(it) } + + override suspend fun muteChat( + owner: KeyPair, + chatId: ChatId, + mute: MuteState, + ): Result = service.muteChat(owner, chatId, mute) + .onFailure { ErrorUtils.handleError(it) } + .map { it.toViewerState() } + + override suspend fun unmuteChat( + owner: KeyPair, + chatId: ChatId, + ): Result = service.unmuteChat(owner, chatId) + .onFailure { ErrorUtils.handleError(it) } + .map { it.toViewerState() } } 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 4b34262776..2a03342cf0 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 @@ -669,6 +669,26 @@ sealed class SetMinDmChatInitFeeError( data class Other(override val cause: Throwable? = null) : SetMinDmChatInitFeeError(message = cause?.message, cause = cause), NotifiableError } +sealed class MuteChatError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied: MuteChatError("Caller is not a member") + class NotFound: MuteChatError("Not found") + class Unrecognized : MuteChatError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : MuteChatError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class UnmuteChatError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied: UnmuteChatError("Caller is not a member") + class NotFound: UnmuteChatError("Not found") + class Unrecognized : UnmuteChatError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : UnmuteChatError(message = cause?.message, cause = cause), NotifiableError +} + // Thrown when a reserved blob failed server-side finalization (moderation / decode / size). // Terminal: the client must reserve a fresh upload to retry. class BlobRejectedException(val rejection: BlobRejection) : diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt index 9e840814db..3500cd2fae 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt @@ -45,6 +45,10 @@ data class PushChatMetadata( val sendingUserId: ID?, val chatType: ChatType, val message: ChatMessage? = null, + // Whether the recipient had this chat muted when the push was sent. The push is still + // delivered so the client can store the message, but a muted chat must not present a + // notification for it. + val muted: Boolean = false, ) data class NotificationPayload( 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 b82f660a84..a3cb7ee7fd 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 @@ -15,4 +15,7 @@ data class ChatMessage( val isFromSelf: Boolean = false, val deliveryStatus: DeliveryStatus = DeliveryStatus.SENT, val pendingClientIdHex: String? = null, + // Set when this copy was redacted for the viewer: it exists, but its content is a + // placeholder. See messaging.v1.Message.redacted. + val redacted: Boolean = false, ) 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 81641372f1..1f7be96234 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 @@ -20,4 +20,6 @@ data class ChatMetadata( // Participation requirements for this chat. Only set for group chats; null means the chat // has no requirements. val rules: ChatRules? = null, + // Per-viewer chat state, absent when the chat holds nothing about the viewer. + val viewerState: ViewerState? = null, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt index 5797b232ff..031d5b2f1e 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MetadataUpdate.kt @@ -5,4 +5,6 @@ import kotlin.time.Instant sealed interface MetadataUpdate { data class FullRefresh(val metadata: ChatMetadata) : MetadataUpdate data class LastActivityChanged(val newLastActivity: Instant) : MetadataUpdate + // The receiving user's own state for the chat has changed (e.g. mute set/cleared). + data class ViewerStateChanged(val viewerState: ViewerState) : MetadataUpdate } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MuteState.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MuteState.kt new file mode 100644 index 0000000000..310e64a833 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MuteState.kt @@ -0,0 +1,14 @@ +package com.flipcash.services.models.chat + +import kotlin.time.Instant + +/** + * A chat's mute duration for a viewer. Mirrors `chat.v1.MuteState`. + */ +sealed class MuteState { + /** Muted until [until], after which the mute lapses client-side; nothing is sent when it does. */ + data class Until(val until: Instant) : MuteState() + + /** Muted forever, until explicitly unmuted. */ + data object Forever : MuteState() +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewMode.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewMode.kt new file mode 100644 index 0000000000..711dc36d77 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewMode.kt @@ -0,0 +1,17 @@ +package com.flipcash.services.models.chat + +/** + * What a read intends to render, and so how much of a chat's messaging state the server may + * return. Mirrors `messaging.v1.ViewMode`. The default, [FULL], is the contract that predates + * redaction: full content, or DENIED. + */ +enum class ViewMode { + /** Full content or nothing. Never returns a redacted message. */ + FULL, + + /** The most the viewer's standing allows: full when possible, redacted otherwise. */ + FULL_OR_REDACTED, + + /** Redacted content, always, for any viewer who may read the chat at all. */ + REDACTED, +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt new file mode 100644 index 0000000000..91940896be --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt @@ -0,0 +1,13 @@ +package com.flipcash.services.models.chat + +/** + * Per-viewer state a chat holds about the requesting user, independent of membership. Mirrors + * `chat.v1.ViewerState`. + */ +data class ViewerState( + // Present if and only if the chat is muted for the viewer. + val mute: MuteState? = null, + // Advanced by exactly one on every real change to this state. Compared like + // RosterSummary.version: apply the greater value, drop the rest. + val version: Long = 0, +) 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 9ee3e199fa..3b472b5f8f 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 @@ -11,6 +11,7 @@ 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.flipcash.services.models.chat.ViewMode import com.getcode.ed25519.Ed25519.KeyPair import kotlinx.coroutines.flow.Flow @@ -19,24 +20,28 @@ interface ChatMessagingRepository { owner: KeyPair, chatId: ChatId, messageId: Long, + viewMode: ViewMode = ViewMode.FULL, ): Result suspend fun getMessages( owner: KeyPair, chatId: ChatId, queryOptions: QueryOptions, + viewMode: ViewMode = ViewMode.FULL, ): Result> suspend fun getMessagesByIds( owner: KeyPair, chatId: ChatId, messageIds: List, + viewMode: ViewMode = ViewMode.FULL, ): Result> fun getDelta( owner: KeyPair, chatId: ChatId, afterSequence: Long, + viewMode: ViewMode = ViewMode.FULL, ): Flow> suspend fun sendMessage( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt index ba831278b4..b7199a8f24 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt @@ -6,13 +6,17 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.StartChatParameters +import com.flipcash.services.models.chat.ViewerState +import com.flipcash.services.models.chat.ViewMode import com.getcode.ed25519.Ed25519.KeyPair interface ChatRepository { suspend fun getChat( owner: KeyPair, chatId: ChatId, + viewMode: ViewMode = ViewMode.FULL, ): Result suspend fun getDmChatFeed( @@ -55,4 +59,17 @@ interface ChatRepository { owner: KeyPair, chatId: ChatId, ): Result + + /** Sets [chatId] muted for the caller until [mute] lapses (or forever), returning the new viewer state. */ + suspend fun muteChat( + owner: KeyPair, + chatId: ChatId, + mute: MuteState, + ): Result + + /** Clears any mute on [chatId] for the caller, returning the new viewer state. */ + suspend fun unmuteChat( + owner: KeyPair, + chatId: ChatId, + ): Result } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt index f990fcc522..f10b944197 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt @@ -6,7 +6,10 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.StartChatParameters +import com.flipcash.services.models.chat.ViewerState +import com.flipcash.services.models.chat.ViewMode import com.flipcash.services.repository.ChatRepository import com.flipcash.services.user.UserManager import com.getcode.ed25519.Ed25519 @@ -327,11 +330,18 @@ private class FakeChatRepository : ChatRepository { var startChatResult: Result = Result.failure(RuntimeException("not configured")) var joinChatResult: Result = Result.failure(RuntimeException("not configured")) var leaveChatResult: Result = Result.failure(RuntimeException("not configured")) + var muteChatResult: Result = Result.failure(RuntimeException("not configured")) + var unmuteChatResult: Result = Result.failure(RuntimeException("not configured")) var lastChatId: ChatId? = null var lastQueryOptions: QueryOptions? = null var lastChatType: ChatType? = null + var lastMuteState: MuteState? = null - override suspend fun getChat(owner: Ed25519.KeyPair, chatId: ChatId): Result { + override suspend fun getChat( + owner: Ed25519.KeyPair, + chatId: ChatId, + viewMode: ViewMode, + ): Result { lastChatId = chatId return getChatResult } @@ -371,6 +381,21 @@ private class FakeChatRepository : ChatRepository { lastChatId = chatId return leaveChatResult } + + override suspend fun muteChat( + owner: Ed25519.KeyPair, + chatId: ChatId, + mute: MuteState, + ): Result { + lastChatId = chatId + lastMuteState = mute + return muteChatResult + } + + override suspend fun unmuteChat(owner: Ed25519.KeyPair, chatId: ChatId): Result { + lastChatId = chatId + return unmuteChatResult + } } // endregion 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 0377b1ec6c..9a69a0681c 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 @@ -11,6 +11,7 @@ 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.models.chat.ViewMode import com.flipcash.services.repository.ChatMessagingRepository import com.flipcash.services.repository.DeltaUpdate import com.flipcash.services.repository.ReactorsPage @@ -505,22 +506,22 @@ private class FakeChatMessagingRepository : ChatMessagingRepository { var lastPointerType: PointerType? = null var lastTypingState: TypingState? = null - override suspend fun getMessage(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long): Result { + override suspend fun getMessage(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long, viewMode: ViewMode): Result { lastChatId = chatId; lastMessageId = messageId return getMessageResult } - override suspend fun getMessages(owner: Ed25519.KeyPair, chatId: ChatId, queryOptions: QueryOptions): Result> { + override suspend fun getMessages(owner: Ed25519.KeyPair, chatId: ChatId, queryOptions: QueryOptions, viewMode: ViewMode): Result> { lastChatId = chatId; lastQueryOptions = queryOptions return getMessagesResult } - override suspend fun getMessagesByIds(owner: Ed25519.KeyPair, chatId: ChatId, messageIds: List): Result> { + override suspend fun getMessagesByIds(owner: Ed25519.KeyPair, chatId: ChatId, messageIds: List, viewMode: ViewMode): Result> { lastChatId = chatId; lastMessageIds = messageIds return getMessagesByIdsResult } - override fun getDelta(owner: Ed25519.KeyPair, chatId: ChatId, afterSequence: Long): Flow> { + override fun getDelta(owner: Ed25519.KeyPair, chatId: ChatId, afterSequence: Long, viewMode: ViewMode): Flow> { lastChatId = chatId; lastAfterSequence = afterSequence return flowOf(Result.failure(RuntimeException("not configured"))) } From a0ce300961cad6299f56d1dfbc4dc02397045e5c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 16:59:58 -0400 Subject: [PATCH 3/4] feat(flipcash): scaffold the reporting service flipcash2 adds a standalone Reporting service (Report RPC) for filing a report against a user, chat, message, or blob, with an optional free-form description. Adds the full Api/Service/Repository/Controller quad following the existing per-domain layering (see ResolverApi/ResolverService for the same generated-class-name-collision pattern via an import alias), a ReportTarget domain sealed interface mirroring the request's oneof, placed flat under models/ like the existing ResolveIdentifier rather than in a new subpackage, and ReportError in the shared error taxonomy. Wired into FlipcashModule alongside the other repositories. No caller wires this up yet; that is UI work for a later change. --- .../controllers/ReportingController.kt | 21 +++++++ .../services/inject/FlipcashModule.kt | 8 +++ .../internal/network/api/ReportingApi.kt | 61 +++++++++++++++++++ .../network/services/ReportingService.kt | 37 +++++++++++ .../InternalReportingRepository.kt | 18 ++++++ .../com/flipcash/services/models/Errors.kt | 10 +++ .../flipcash/services/models/ReportTarget.kt | 16 +++++ .../repository/ReportingRepository.kt | 13 ++++ 8 files changed, 184 insertions(+) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ReportingController.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ReportingApi.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ReportingService.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalReportingRepository.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/ReportTarget.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/repository/ReportingRepository.kt diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ReportingController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ReportingController.kt new file mode 100644 index 0000000000..ea0b6c2a5c --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ReportingController.kt @@ -0,0 +1,21 @@ +package com.flipcash.services.controllers + +import com.flipcash.services.models.ReportTarget +import com.flipcash.services.repository.ReportingRepository +import com.flipcash.services.user.UserManager +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ReportingController @Inject constructor( + private val repository: ReportingRepository, + private val userManager: UserManager, +) { + /** Files a report against [target], with an optional free-form [description]. */ + suspend fun report(target: ReportTarget, description: String = ""): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.report(owner, target, description) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt index 639bdfb907..1f81737653 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt @@ -30,6 +30,7 @@ import com.flipcash.services.internal.network.services.ProfileService import com.flipcash.services.internal.network.services.PurchaseService import com.flipcash.services.internal.network.services.PushService import com.flipcash.services.internal.network.services.ResolverService +import com.flipcash.services.internal.network.services.ReportingService import com.flipcash.services.internal.network.services.SettingsService import com.flipcash.services.internal.network.services.ThirdPartyService import com.flipcash.services.internal.repositories.InternalAccountRepository @@ -46,6 +47,7 @@ import com.flipcash.services.internal.repositories.InternalProfileRepository import com.flipcash.services.internal.repositories.InternalPurchaseRepository import com.flipcash.services.internal.repositories.InternalPushRepository import com.flipcash.services.internal.repositories.InternalResolverRepository +import com.flipcash.services.internal.repositories.InternalReportingRepository import com.flipcash.services.internal.repositories.InternalSettingsRepository import com.flipcash.services.internal.repositories.InternalThirdPartyRepository import com.flipcash.services.repository.AccountRepository @@ -62,6 +64,7 @@ import com.flipcash.services.repository.PurchaseRepository import com.flipcash.services.repository.PushRepository import com.flipcash.services.repository.BlobStorageRepository import com.flipcash.services.repository.ResolverRepository +import com.flipcash.services.repository.ReportingRepository import com.flipcash.services.repository.SettingsRepository import com.flipcash.services.repository.ThirdPartyRepository import com.getcode.opencode.ProtocolConfig @@ -254,4 +257,9 @@ internal object FlipcashModule { textModerationResponseMapper, imageModerationResponseMapper ) + + @Provides + internal fun providesReportingRepository( + service: ReportingService, + ): ReportingRepository = InternalReportingRepository(service) } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ReportingApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ReportingApi.kt new file mode 100644 index 0000000000..64877e2a00 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ReportingApi.kt @@ -0,0 +1,61 @@ +package com.flipcash.services.internal.network.api + +import com.codeinc.flipcash.gen.reporting.v1.ReportingGrpcKt +import com.codeinc.flipcash.gen.reporting.v1.validate +import com.flipcash.services.internal.annotations.FlipcashManagedChannel +import com.flipcash.services.internal.network.extensions.asChatId +import com.flipcash.services.internal.network.extensions.asMessageId +import com.flipcash.services.internal.network.extensions.asUserId +import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.models.ReportTarget +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.internal.network.core.GrpcApi +import com.getcode.utils.toByteString +import dev.bmcreations.protovalidate.orThrow +import io.grpc.ManagedChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton +import com.codeinc.flipcash.gen.reporting.v1.ReportingService as RpcReportingService + +@Singleton +internal class ReportingApi @Inject constructor( + @FlipcashManagedChannel + managedChannel: ManagedChannel, +) : GrpcApi(managedChannel) { + + private val api = ReportingGrpcKt.ReportingCoroutineStub(managedChannel) + .withWaitForReady() + + suspend fun report( + owner: KeyPair, + target: ReportTarget, + description: String, + ): RpcReportingService.ReportResponse { + val builder = RpcReportingService.ReportRequest.newBuilder() + .setDescription(description) + .apply { setAuth(authenticate(owner)) } + + when (target) { + is ReportTarget.User -> builder.setUserId(target.userId.asUserId()) + is ReportTarget.Chat -> builder.setChatId(target.chatId.asChatId()) + is ReportTarget.Message -> builder.setMessage( + RpcReportingService.ReportRequest.ReportedMessage.newBuilder() + .setChatId(target.chatId.asChatId()) + .setMessageId(target.messageId.asMessageId()) + ) + is ReportTarget.Blob -> builder.setBlobId( + com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder() + .setValue(target.blobId.bytes.toByteString()) + ) + } + + val request = builder.build() + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.report(request) + } + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ReportingService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ReportingService.kt new file mode 100644 index 0000000000..2c175aa065 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ReportingService.kt @@ -0,0 +1,37 @@ +package com.flipcash.services.internal.network.services + +import com.flipcash.services.internal.network.api.ReportingApi +import com.flipcash.services.models.ReportError +import com.flipcash.services.models.ReportTarget +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.internal.network.extensions.foldWithSuppression +import com.getcode.opencode.utils.toValidationOrElse +import javax.inject.Inject +import com.codeinc.flipcash.gen.reporting.v1.ReportingService as RpcReportingService + +internal class ReportingService @Inject constructor( + private val api: ReportingApi, +) { + suspend fun report( + owner: KeyPair, + target: ReportTarget, + description: String, + ): Result { + return runCatching { + api.report(owner, target, description) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcReportingService.ReportResponse.Result.OK -> Result.success(Unit) + RpcReportingService.ReportResponse.Result.DENIED -> Result.failure(ReportError.Denied()) + RpcReportingService.ReportResponse.Result.NOT_FOUND -> Result.failure(ReportError.NotFound()) + RpcReportingService.ReportResponse.Result.UNRECOGNIZED -> Result.failure(ReportError.Unrecognized()) + else -> Result.failure(ReportError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { ReportError.Other(cause = it) }) + } + ) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalReportingRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalReportingRepository.kt new file mode 100644 index 0000000000..8fa313459b --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalReportingRepository.kt @@ -0,0 +1,18 @@ +package com.flipcash.services.internal.repositories + +import com.flipcash.services.internal.network.services.ReportingService +import com.flipcash.services.models.ReportTarget +import com.flipcash.services.repository.ReportingRepository +import com.getcode.ed25519.Ed25519 +import com.getcode.utils.ErrorUtils + +internal class InternalReportingRepository( + private val service: ReportingService, +) : ReportingRepository { + override suspend fun report( + owner: Ed25519.KeyPair, + target: ReportTarget, + description: String, + ): Result = service.report(owner, target, description) + .onFailure { ErrorUtils.handleError(it) } +} 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 2a03342cf0..a77f33ab28 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 @@ -689,6 +689,16 @@ sealed class UnmuteChatError( data class Other(override val cause: Throwable? = null) : UnmuteChatError(message = cause?.message, cause = cause), NotifiableError } +sealed class ReportError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied: ReportError("Denied") + class NotFound: ReportError("Not found") + class Unrecognized : ReportError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : ReportError(message = cause?.message, cause = cause), NotifiableError +} + // Thrown when a reserved blob failed server-side finalization (moderation / decode / size). // Terminal: the client must reserve a fresh upload to retry. class BlobRejectedException(val rejection: BlobRejection) : diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/ReportTarget.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ReportTarget.kt new file mode 100644 index 0000000000..9430cf013a --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ReportTarget.kt @@ -0,0 +1,16 @@ +package com.flipcash.services.models + +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.ChatId +import com.getcode.opencode.model.core.ID + +/** + * What a report names as its subject. Mirrors the reporting `ReportRequest` oneof: a report is + * keyed by exactly one of a user, a chat, a message within a chat, or a blob. + */ +sealed interface ReportTarget { + data class User(val userId: ID) : ReportTarget + data class Chat(val chatId: ChatId) : ReportTarget + data class Message(val chatId: ChatId, val messageId: Long) : ReportTarget + data class Blob(val blobId: BlobId) : ReportTarget +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ReportingRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ReportingRepository.kt new file mode 100644 index 0000000000..071ceb7093 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ReportingRepository.kt @@ -0,0 +1,13 @@ +package com.flipcash.services.repository + +import com.flipcash.services.models.ReportTarget +import com.getcode.ed25519.Ed25519.KeyPair + +interface ReportingRepository { + /** Files a report against [target]. Reporting the same target more than once is a no-op. */ + suspend fun report( + owner: KeyPair, + target: ReportTarget, + description: String = "", + ): Result +} From 2053b9eb31b0bf3b9de0a996833c7006b612d251 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 17:02:00 -0400 Subject: [PATCH 4/4] chore(deps): pin flipcash2-client-protocol 0.10.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d0ba5fce38..5694901996 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,7 @@ protovalidate-kt = "0.1.3" # 0.3.0 is the first release of either package to ship R8 keep rules for its generated # messages, which is what lets proguard-rules.pro drop its own. ocp-client-protocol = "0.5.0" -flipcash2-client-protocol = "0.9.0" +flipcash2-client-protocol = "0.10.0" # The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's # `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata,