From 8d6240e0070a3c1b3f650dc87ffda47dc6e89f79 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 20 Jul 2026 17:23:39 -0400 Subject: [PATCH 1/2] feat(profile): add SetProfilePicture and propagate profile pictures Scaffold the SetProfilePicture RPC across the service layer (Api -> Service -> Repository -> Controller) with a SetProfilePictureError sealed type covering the proto result cases (DENIED, BLOB_NOT_FOUND/NOT_READY/REJECTED, INVALID_BLOB). The response's blob.v1.Media is mapped to the domain MediaItem. Propagate the new UserProfile.profile_picture field: add UserProfile.profilePicture (MediaItem), map it in UserProfileMapper and the inline chat-member profile builder, and persist it via UserProfileSerialized (+ compat DTO and the entity mappers). Co-Authored-By: Claude Opus 4.8 --- .../converters/ChatTypeConverters.kt | 3 ++ .../sources/mapper/chat/ChatEntityMapper.kt | 2 ++ .../services/controllers/ProfileController.kt | 15 ++++++++++ .../internal/domain/UserProfileMapper.kt | 2 ++ .../internal/network/api/ProfileApi.kt | 26 +++++++++++++++++ .../network/extensions/ProtobufToLocal.kt | 1 + .../network/services/ProfileService.kt | 26 +++++++++++++++++ .../repositories/InternalProfileRepository.kt | 10 +++++++ .../com/flipcash/services/models/Errors.kt | 17 +++++++++++ .../flipcash/services/models/UserProfile.kt | 5 ++++ .../services/repository/ProfileRepository.kt | 3 ++ .../controllers/ProfileControllerTest.kt | 4 +++ .../internal/domain/UserProfileMapperTest.kt | 29 +++++++++++++++++++ 13 files changed, 143 insertions(+) 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 7c5df3af8..335d783ca 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 @@ -77,6 +77,7 @@ class ChatTypeConverters { ?: compat.verifiedEmailAddress?.let { address -> VerifiableContactMethod(address, verified = true) }, + profilePicture = compat.profilePicture, ) }.getOrNull() } @@ -163,6 +164,7 @@ data class UserProfileSerialized( val socialAccounts: List, val phoneNumber: VerifiableContactMethod? = null, val email: VerifiableContactMethod? = null, + val profilePicture: MediaItem? = null, ) /** @@ -179,6 +181,7 @@ private data class UserProfileSerializedCompat( val email: VerifiableContactMethod? = null, val verifiedPhoneNumber: String? = null, val verifiedEmailAddress: String? = null, + val profilePicture: MediaItem? = null, ) @Serializable 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 2ffae589f..1871a90dd 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 @@ -275,6 +275,7 @@ private fun UserProfile.toSerialized(): UserProfileSerialized = UserProfileSeria socialAccounts = socialAccounts.map { it.toSerialized() }, phoneNumber = phoneNumber, email = email, + profilePicture = profilePicture, ) private fun UserProfileSerialized.toDomain(): UserProfile = UserProfile( @@ -282,6 +283,7 @@ private fun UserProfileSerialized.toDomain(): UserProfile = UserProfile( socialAccounts = socialAccounts.map { it.toDomain() }, phoneNumber = phoneNumber, email = email, + profilePicture = profilePicture, ) private fun SocialAccount.toSerialized(): SocialAccountSerialized = when (this) { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt index 11cfea6eb..280259753 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt @@ -6,6 +6,8 @@ import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccountUnlinkRequest import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.repository.ProfileRepository import com.flipcash.services.user.UserManager import com.getcode.opencode.model.core.ID @@ -84,6 +86,19 @@ class ProfileController @Inject constructor( return repository.setDisplayName(displayName, owner) } + /** + * Sets the caller's profile picture to a blob already uploaded via BlobStorage. + * Returns the full set of renditions the server derived from it. + */ + suspend fun setProfilePicture( + blobId: BlobId, + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.setProfilePicture(blobId, owner) + } + suspend fun linkTwitterXAccount( token: LinkingToken ): Result { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt index 566df85d2..7813a76ea 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt @@ -3,6 +3,7 @@ package com.flipcash.services.internal.domain import com.codeinc.flipcash.gen.profile.v1.Model import com.codeinc.flipcash.gen.profile.v1.emailAddressOrNull import com.codeinc.flipcash.gen.profile.v1.phoneNumberOrNull +import com.flipcash.services.internal.network.extensions.toMediaItem import com.flipcash.services.models.UserProfile import com.flipcash.services.models.VerifiableContactMethod import com.getcode.opencode.mapper.Mapper @@ -18,6 +19,7 @@ class UserProfileMapper @Inject constructor( // A value is only present on the server profile once verified. phoneNumber = from.phoneNumberOrNull?.value?.let { VerifiableContactMethod(it, verified = true) }, email = from.emailAddressOrNull?.value?.let { VerifiableContactMethod(it, verified = true) }, + profilePicture = if (from.hasProfilePicture()) from.profilePicture.toMediaItem() else null, ) } } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt index 89dca6956..462d03192 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt @@ -8,9 +8,11 @@ import com.flipcash.services.internal.network.extensions.authenticate import com.flipcash.services.internal.network.extensions.linkingToken import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccountUnlinkRequest +import com.flipcash.services.models.chat.BlobId import com.getcode.ed25519.Ed25519 import com.getcode.opencode.internal.network.core.GrpcApi import com.getcode.opencode.model.core.ID +import com.getcode.utils.toByteString import com.codeinc.flipcash.gen.profile.v1.validate import dev.bmcreations.protovalidate.orThrow import io.grpc.ManagedChannel @@ -61,6 +63,30 @@ internal class ProfileApi @Inject constructor( } } + /** + * Sets the caller's profile picture to a blob they have already uploaded via + * BlobStorage. The server derives the DISPLAY/THUMBNAIL renditions and returns + * the full set. + */ + suspend fun setProfilePicture( + blobId: BlobId, + owner: Ed25519.KeyPair, + ): ProfileService.SetProfilePictureResponse { + val request = ProfileService.SetProfilePictureRequest.newBuilder() + .setBlobId( + com.codeinc.flipcash.gen.blob.v1.Model.BlobId.newBuilder() + .setValue(blobId.bytes.toByteString()) + ) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.setProfilePicture(request) + } + } + /** * links a social account to a user */ 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 ee5b3cee8..7df913305 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 @@ -346,6 +346,7 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { socialAccounts = emptyList(), phoneNumber = phoneNumber.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, email = emailAddress.value.takeIf { it.isNotEmpty() }?.let { VerifiableContactMethod(it, verified = true) }, + profilePicture = if (hasProfilePicture()) profilePicture.toMediaItem() else null, ) }, pointers = member.pointersList.map { it.toPointer() }, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt index 6cbcfc20a..63ff5f012 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt @@ -8,9 +8,13 @@ import com.getcode.opencode.utils.toValidationOrElse import com.flipcash.services.models.GetUserProfileError import com.flipcash.services.models.LinkSocialAccountError import com.flipcash.services.models.SetDisplayNameError +import com.flipcash.services.models.SetProfilePictureError import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccountUnlinkRequest import com.flipcash.services.models.UnlinkSocialAccountError +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.internal.network.extensions.toMediaItem import com.getcode.ed25519.Ed25519 import com.getcode.opencode.internal.network.extensions.foldWithSuppression import com.getcode.opencode.model.core.ID @@ -58,6 +62,28 @@ internal class ProfileService @Inject constructor( ) } + suspend fun setProfilePicture( + blobId: BlobId, + owner: Ed25519.KeyPair, + ): Result { + return runCatching { + api.setProfilePicture(blobId, owner) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + ProfileService.SetProfilePictureResponse.Result.OK -> Result.success(response.profilePicture.toMediaItem()) + ProfileService.SetProfilePictureResponse.Result.DENIED -> Result.failure(SetProfilePictureError.Denied()) + ProfileService.SetProfilePictureResponse.Result.BLOB_NOT_FOUND -> Result.failure(SetProfilePictureError.BlobNotFound()) + ProfileService.SetProfilePictureResponse.Result.BLOB_NOT_READY -> Result.failure(SetProfilePictureError.BlobNotReady()) + ProfileService.SetProfilePictureResponse.Result.BLOB_REJECTED -> Result.failure(SetProfilePictureError.BlobRejected()) + ProfileService.SetProfilePictureResponse.Result.INVALID_BLOB -> Result.failure(SetProfilePictureError.InvalidBlob()) + ProfileService.SetProfilePictureResponse.Result.UNRECOGNIZED -> Result.failure(SetProfilePictureError.Unrecognized()) + } + }, + onFailure = { Result.failure(it.toValidationOrElse { cause -> SetProfilePictureError.Other(cause) }) } + ) + } + suspend fun linkSocialAccount( request: SocialAccountLinkRequest, owner: Ed25519.KeyPair, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt index 4ce31afbc..3b9f90434 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt @@ -8,6 +8,8 @@ import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccountUnlinkRequest import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.repository.ProfileRepository import com.getcode.ed25519.Ed25519 import com.getcode.opencode.model.core.ID @@ -36,6 +38,14 @@ internal class InternalProfileRepository( .onFailure { ErrorUtils.handleError(it) } } + override suspend fun setProfilePicture( + blobId: BlobId, + owner: Ed25519.KeyPair + ): Result { + return service.setProfilePicture(blobId, owner) + .onFailure { ErrorUtils.handleError(it) } + } + override suspend fun linkSocialAccount( request: SocialAccountLinkRequest, owner: Ed25519.KeyPair 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 0454ec89b..dfab0a611 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 @@ -205,6 +205,23 @@ sealed class SetDisplayNameError( data class Other(override val cause: Throwable? = null) : SetDisplayNameError(message = cause?.message, cause = cause), NotifiableError } +sealed class SetProfilePictureError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied: SetProfilePictureError("Denied") + // No such blob, or it is not owned by the caller. + class BlobNotFound: SetProfilePictureError("Blob not found") + // Blob is still PENDING/PROCESSING; retry once READY. + class BlobNotReady: SetProfilePictureError("Blob not ready") + // Blob failed validation or moderation; terminal for this id, must upload again. + class BlobRejected: SetProfilePictureError("Blob rejected") + // Blob is READY but unusable as a picture (e.g. not an image). + class InvalidBlob: SetProfilePictureError("Invalid blob") + class Unrecognized : SetProfilePictureError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : SetProfilePictureError(message = cause?.message, cause = cause), NotifiableError +} + sealed class LinkSocialAccountError( override val message: String? = null, override val cause: Throwable? = null diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt index 1647f4a9b..e5c4c6ef2 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt @@ -1,10 +1,15 @@ package com.flipcash.services.models +import com.flipcash.services.models.chat.MediaItem + data class UserProfile( val displayName: String?, val socialAccounts: List, val phoneNumber: VerifiableContactMethod?, val email: VerifiableContactMethod?, + // The user's profile picture, as the renditions it is stored as (DISPLAY for + // the profile view, THUMBNAIL for avatars). Null when unset. + val profilePicture: MediaItem? = null, ) { /** The phone number only when it has been verified — backwards-compatible accessor. */ val verifiedPhoneNumber: String? get() = phoneNumber?.takeIf { it.verified }?.value diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt index a8fb45948..4b968a326 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt @@ -4,12 +4,15 @@ import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.SocialAccountUnlinkRequest import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.MediaItem import com.getcode.ed25519.Ed25519 import com.getcode.opencode.model.core.ID interface ProfileRepository { suspend fun getProfile(userId: ID, owner: Ed25519.KeyPair): Result suspend fun setDisplayName(displayName: String, owner: Ed25519.KeyPair): Result + suspend fun setProfilePicture(blobId: BlobId, owner: Ed25519.KeyPair): Result suspend fun linkSocialAccount(request: SocialAccountLinkRequest, owner: Ed25519.KeyPair): Result suspend fun unlinkSocialAccount(request: SocialAccountUnlinkRequest, owner: Ed25519.KeyPair): Result } \ No newline at end of file diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt index eb06071c2..116505209 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt @@ -6,6 +6,8 @@ import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccountUnlinkRequest import com.flipcash.services.models.UserProfile import com.flipcash.services.models.VerifiableContactMethod +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.repository.ProfileRepository import com.flipcash.services.user.UserManager import com.getcode.ed25519.Ed25519 @@ -299,11 +301,13 @@ class ProfileControllerTest { private class FakeProfileRepository : ProfileRepository { var getProfileResult: Result = Result.failure(RuntimeException("not configured")) var setDisplayNameResult: Result = Result.success(Unit) + var setProfilePictureResult: Result = Result.failure(RuntimeException("not configured")) var linkSocialAccountResult: Result = Result.failure(RuntimeException("not configured")) var unlinkSocialAccountResult: Result = Result.success(Unit) override suspend fun getProfile(userId: ID, owner: Ed25519.KeyPair) = getProfileResult override suspend fun setDisplayName(displayName: String, owner: Ed25519.KeyPair) = setDisplayNameResult + override suspend fun setProfilePicture(blobId: BlobId, owner: Ed25519.KeyPair) = setProfilePictureResult override suspend fun linkSocialAccount(request: SocialAccountLinkRequest, owner: Ed25519.KeyPair) = linkSocialAccountResult override suspend fun unlinkSocialAccount(request: SocialAccountUnlinkRequest, owner: Ed25519.KeyPair) = diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/UserProfileMapperTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/UserProfileMapperTest.kt index 7e048e688..950253db8 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/UserProfileMapperTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/UserProfileMapperTest.kt @@ -1,14 +1,18 @@ package com.flipcash.services.internal.domain +import com.codeinc.flipcash.gen.blob.v1.Model as BlobModel import com.codeinc.flipcash.gen.email.v1.emailAddress import com.codeinc.flipcash.gen.phone.v1.phoneNumber import com.codeinc.flipcash.gen.profile.v1.Model import com.codeinc.flipcash.gen.profile.v1.socialProfile import com.codeinc.flipcash.gen.profile.v1.xProfile import com.flipcash.services.models.SocialAccount +import com.flipcash.services.models.chat.MediaItemRendition +import com.google.protobuf.ByteString import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertNotNull import kotlin.test.assertNull class UserProfileMapperTest { @@ -81,4 +85,29 @@ class UserProfileMapperTest { assertNull(result.verifiedPhoneNumber) assertNull(result.verifiedEmailAddress) } + + @Test + fun `maps profile picture renditions`() { + val proto = userProfile { + displayName = "Grace" + profilePicture = BlobModel.Media.newBuilder() + .addRenditions( + BlobModel.Rendition.newBuilder() + .setRole(BlobModel.Rendition.Role.DISPLAY) + .setBlobId(BlobModel.BlobId.newBuilder().setValue(ByteString.copyFrom(ByteArray(4) { 1 }))) + ) + .build() + } + + val result = mapper.map(proto) + val picture = assertNotNull(result.profilePicture) + assertEquals(1, picture.renditions.size) + assertEquals(MediaItemRendition.Role.DISPLAY, picture.renditions.first().role) + } + + @Test + fun `no profile picture returns null`() { + val proto = userProfile { displayName = "Heidi" } + assertNull(mapper.map(proto).profilePicture) + } } From fa15872d24a6a9a8f4eb043a788e37a3d5a2b016 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 20 Jul 2026 17:33:20 -0400 Subject: [PATCH 2/2] feat(chat,resolver): add tip DM support Wire up the service-layer pieces for tip DMs introduced by the proto sync: - buildTipDmPaymentMetadata() builds the intent AppMetadata for a tip DM payment (user-id to user-id, no phone source/destination) - ResolverController.resolveByUserId() resolves a user ID to their on-chain address via the resolver Identifier.user_id arm, added through Api/Service/Repo - getDmChatFeed now takes a required ChatType so callers filter explicitly for CONTACT_DM vs TIP_DM (threaded through Api/Service/Repo/Controller). No default, so intent is always stated; feed sync passes CONTACT_DM to preserve behaviour. Co-Authored-By: Claude Opus 4.8 --- .../internal/delegates/FeedSyncDelegate.kt | 3 +- .../chat/ChatCoordinatorEagerBalanceTest.kt | 2 +- .../shared/chat/ChatCoordinatorEventsTest.kt | 2 +- .../app/contacts/ContactCoordinator.kt | 2 +- .../services/controllers/ChatController.kt | 8 +++-- .../controllers/ResolverController.kt | 14 ++++++-- .../services/internal/network/api/ChatApi.kt | 4 +++ .../internal/network/api/ResolverApi.kt | 20 +++++++---- .../network/extensions/LocalToProtobuf.kt | 10 ++++++ .../internal/network/services/ChatService.kt | 4 ++- .../network/services/ResolverService.kt | 34 ++++++++++--------- .../repositories/InternalChatRepository.kt | 4 ++- .../InternalResolverRepository.kt | 6 ++-- .../services/models/DmPaymentMetadata.kt | 19 +++++++++++ .../services/models/ResolveIdentifier.kt | 12 +++++++ .../services/repository/ChatRepository.kt | 2 ++ .../services/repository/ResolverRepository.kt | 4 +-- .../controllers/ChatControllerTest.kt | 28 +++++++++++---- 18 files changed, 135 insertions(+), 43 deletions(-) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/ResolveIdentifier.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt index 3123c4f91..0e01828cf 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt @@ -8,6 +8,7 @@ import com.flipcash.services.controllers.ChatController import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.PointerType import com.flipcash.shared.chat.ChatSummary import com.flipcash.shared.chat.FeedOperations @@ -156,7 +157,7 @@ class FeedSyncDelegate @Inject constructor( private suspend fun performFeedSync() { stateHolder.update { it.copy(feedSyncState = FeedSyncState.Syncing) } - chatController.getDmChatFeed() + chatController.getDmChatFeed(ChatType.CONTACT_DM) .onSuccess { page -> metadataDataSource.upsert(page.chats) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt index a01fd45e9..79d497e57 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt @@ -68,7 +68,7 @@ class ChatCoordinatorEagerBalanceTest { every { eventStreamingController.isStreamActive } returns true val chatController = mockk(relaxed = true) - coEvery { chatController.getDmChatFeed() } returns Result.failure(RuntimeException("not needed")) + coEvery { chatController.getDmChatFeed(any(), any()) } returns Result.failure(RuntimeException("not needed")) testDispatchers = TestDispatchers(TestCoroutineScheduler()) 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 index ec8b33f2d..8af6e447d 100644 --- 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 @@ -72,7 +72,7 @@ class ChatCoordinatorEventsTest { every { eventStreamingController.isStreamActive } returns true val chatController = mockk(relaxed = true) - coEvery { chatController.getDmChatFeed() } returns Result.failure(RuntimeException("not needed")) + coEvery { chatController.getDmChatFeed(any(), any()) } returns Result.failure(RuntimeException("not needed")) metadataDataSource = mockk(relaxed = true) messageDataSource = mockk(relaxed = true) diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt index 1526a8ba9..821c997b2 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt @@ -227,7 +227,7 @@ class ContactCoordinator @Inject constructor( } suspend fun resolve(e164: String): Result { - return resolverController.resolve(ContactMethod.Phone(e164)) + return resolverController.resolve(phone = ContactMethod.Phone(e164)) } fun refreshContact(e164: String): DeviceContact? { 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 dfbc1126f..5e5861687 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 @@ -4,6 +4,7 @@ import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatFeedPage 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.repository.ChatRepository import com.flipcash.services.user.UserManager import javax.inject.Inject @@ -21,10 +22,13 @@ class ChatController @Inject constructor( return repository.getChat(owner, chatId) } - suspend fun getDmChatFeed(queryOptions: QueryOptions = QueryOptions()): Result { + suspend fun getDmChatFeed( + chatType: ChatType, + queryOptions: QueryOptions = QueryOptions(), + ): Result { val owner = userManager.accountCluster?.authority?.keyPair ?: return Result.failure(Throwable("No account cluster in UserManager")) - return repository.getDmChatFeed(owner, queryOptions) + return repository.getDmChatFeed(owner, queryOptions, chatType) } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ResolverController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ResolverController.kt index 1288b9f59..3740fd76f 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ResolverController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ResolverController.kt @@ -1,8 +1,10 @@ package com.flipcash.services.controllers import com.flipcash.services.models.ContactMethod +import com.flipcash.services.models.ResolveIdentifier import com.flipcash.services.repository.ResolverRepository import com.flipcash.services.user.UserManager +import com.getcode.opencode.model.core.ID import com.getcode.solana.keys.PublicKey import javax.inject.Inject @@ -10,9 +12,17 @@ class ResolverController @Inject constructor( private val repository: ResolverRepository, private val userManager: UserManager, ) { - suspend fun resolve(phone: ContactMethod.Phone): Result { + /** Resolves a phone number to its on-chain address. */ + suspend fun resolve(phone: ContactMethod.Phone): Result = + resolve(ResolveIdentifier.Phone(phone)) + + /** Resolves a user ID to their on-chain address (e.g. for a tip DM counterparty). */ + suspend fun resolve(userId: ID): Result = + resolve(ResolveIdentifier.UserId(userId)) + + private suspend fun resolve(identifier: ResolveIdentifier): Result { val owner = userManager.accountCluster?.authority?.keyPair ?: return Result.failure(Throwable("No account cluster in UserManager")) - return repository.resolve(owner, phone) + return repository.resolve(owner, identifier) } } 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 29300c2ee..26a819131 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 @@ -5,10 +5,12 @@ import com.codeinc.flipcash.gen.chat.v1.ChatService as RpcChatService import com.codeinc.flipcash.gen.chat.v1.validate 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.asQueryOptions 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.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.core.GrpcApi import dev.bmcreations.protovalidate.orThrow @@ -46,9 +48,11 @@ internal class ChatApi @Inject constructor( suspend fun getDmChatFeed( owner: KeyPair, queryOptions: QueryOptions, + chatType: ChatType, ): RpcChatService.GetDmChatFeedResponse { val request = RpcChatService.GetDmChatFeedRequest.newBuilder() .setQueryOptions(queryOptions.asQueryOptions()) + .setDmChatType(chatType.asProtoChatType()) .apply { setAuth(authenticate(owner)) } .build() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ResolverApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ResolverApi.kt index 6d2594d7e..a2a0eb311 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ResolverApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ResolverApi.kt @@ -3,8 +3,9 @@ package com.flipcash.services.internal.network.api import com.codeinc.flipcash.gen.phone.v1.Model import com.codeinc.flipcash.gen.resolver.v1.ResolverGrpcKt import com.codeinc.flipcash.gen.resolver.v1.validate -import com.flipcash.services.models.ContactMethod +import com.flipcash.services.models.ResolveIdentifier import com.flipcash.services.internal.annotations.FlipcashManagedChannel +import com.flipcash.services.internal.network.extensions.asUserId import com.flipcash.services.internal.network.extensions.authenticate import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.core.GrpcApi @@ -28,13 +29,10 @@ internal class ResolverApi @Inject constructor( suspend fun resolve( owner: KeyPair, - phone: ContactMethod.Phone, + identifier: ResolveIdentifier, ): RpcResolverService.ResolveResponse { val request = RpcResolverService.ResolveRequest.newBuilder() - .setIdentifier( - ResolverModel.Identifier.newBuilder() - .setPhone(Model.PhoneNumber.newBuilder().setValue(phone.phoneNumber)) - ) + .setIdentifier(identifier.asProtoIdentifier()) .apply { setAuth(authenticate(owner)) } .build() @@ -44,4 +42,14 @@ internal class ResolverApi @Inject constructor( api.resolve(request) } } + + private fun ResolveIdentifier.asProtoIdentifier(): ResolverModel.Identifier { + val builder = ResolverModel.Identifier.newBuilder() + return when (this) { + is ResolveIdentifier.Phone -> + builder.setPhone(Model.PhoneNumber.newBuilder().setValue(phone.phoneNumber)) + is ResolveIdentifier.UserId -> + builder.setUserId(userId.asUserId()) + }.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 24c926a2d..740d557d1 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 @@ -8,6 +8,7 @@ import com.flipcash.services.models.PagingToken import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ClientMessageId import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.PointerType @@ -20,6 +21,7 @@ import com.getcode.solana.keys.PublicKey import com.getcode.utils.toByteString import com.google.protobuf.Timestamp import kotlin.time.Instant +import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel internal fun Checksum.asHash(): Common.Hash { @@ -178,6 +180,14 @@ internal fun com.flipcash.services.models.chat.Emoji.asEmoji(): MessagingModel.E return MessagingModel.Emoji.newBuilder().setValue(value).build() } +internal fun ChatType.asProtoChatType(): ChatModel.ChatType { + return when (this) { + ChatType.UNKNOWN -> ChatModel.ChatType.UNKNOWN + ChatType.CONTACT_DM -> ChatModel.ChatType.CONTACT_DM + ChatType.TIP_DM -> ChatModel.ChatType.TIP_DM + } +} + internal fun Long.asMessageId(): MessagingModel.MessageId { return MessagingModel.MessageId.newBuilder().setValue(this).build() } 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 743d61b7b..72e28ba45 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 @@ -7,6 +7,7 @@ import com.flipcash.services.models.GetChatError import com.flipcash.services.models.GetDmChatFeedError import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.extensions.foldWithSuppression import com.getcode.opencode.utils.toValidationOrElse @@ -40,9 +41,10 @@ internal class ChatService @Inject constructor( suspend fun getDmChatFeed( owner: KeyPair, queryOptions: QueryOptions, + chatType: ChatType, ): Result { return runCatching { - api.getDmChatFeed(owner, queryOptions) + api.getDmChatFeed(owner, queryOptions, chatType) }.foldWithSuppression( onSuccess = { response -> when (response.result) { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ResolverService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ResolverService.kt index e2c611d83..f215521c1 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ResolverService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ResolverService.kt @@ -1,9 +1,9 @@ package com.flipcash.services.internal.network.services -import com.flipcash.services.models.ContactMethod import com.flipcash.services.internal.network.api.ResolverApi import com.flipcash.services.internal.network.extensions.toPublicKey import com.flipcash.services.models.ResolveContactError +import com.flipcash.services.models.ResolveIdentifier import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.extensions.foldWithSuppression import com.getcode.opencode.utils.toValidationOrElse @@ -16,27 +16,29 @@ internal class ResolverService @Inject constructor( ) { suspend fun resolve( owner: KeyPair, - phone: ContactMethod.Phone, + identifier: ResolveIdentifier, ): Result { return runCatching { - api.resolve(owner, phone) + api.resolve(owner, identifier) }.foldWithSuppression( - onSuccess = { response -> - when (response.result) { - RpcResolverService.ResolveResponse.Result.OK -> - Result.success(response.resolution.address.toPublicKey()) - RpcResolverService.ResolveResponse.Result.NOT_FOUND -> - Result.failure(ResolveContactError.NotFound()) - RpcResolverService.ResolveResponse.Result.DENIED -> - Result.failure(ResolveContactError.Denied()) - RpcResolverService.ResolveResponse.Result.UNRECOGNIZED -> - Result.failure(ResolveContactError.Unrecognized()) - else -> Result.failure(ResolveContactError.Other()) - } - }, + onSuccess = { it.toResult() }, onFailure = { cause -> Result.failure(cause.toValidationOrElse { ResolveContactError.Other(cause = it) }) } ) } + + private fun RpcResolverService.ResolveResponse.toResult(): Result { + return when (result) { + RpcResolverService.ResolveResponse.Result.OK -> + Result.success(resolution.address.toPublicKey()) + RpcResolverService.ResolveResponse.Result.NOT_FOUND -> + Result.failure(ResolveContactError.NotFound()) + RpcResolverService.ResolveResponse.Result.DENIED -> + Result.failure(ResolveContactError.Denied()) + RpcResolverService.ResolveResponse.Result.UNRECOGNIZED -> + Result.failure(ResolveContactError.Unrecognized()) + else -> Result.failure(ResolveContactError.Other()) + } + } } 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 85a32d718..0f5f039d3 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 @@ -7,6 +7,7 @@ import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatFeedPage 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.repository.ChatRepository import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.utils.ErrorUtils @@ -25,7 +26,8 @@ internal class InternalChatRepository( override suspend fun getDmChatFeed( owner: KeyPair, queryOptions: QueryOptions, - ): Result = service.getDmChatFeed(owner, queryOptions) + chatType: ChatType, + ): Result = service.getDmChatFeed(owner, queryOptions, chatType) .onFailure { ErrorUtils.handleError(it) } .map { response -> ChatFeedPage( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalResolverRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalResolverRepository.kt index 2c275ff50..a30a5e288 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalResolverRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalResolverRepository.kt @@ -1,7 +1,7 @@ package com.flipcash.services.internal.repositories import com.flipcash.services.internal.network.services.ResolverService -import com.flipcash.services.models.ContactMethod +import com.flipcash.services.models.ResolveIdentifier import com.flipcash.services.repository.ResolverRepository import com.getcode.ed25519.Ed25519 import com.getcode.solana.keys.PublicKey @@ -12,7 +12,7 @@ internal class InternalResolverRepository( ) : ResolverRepository { override suspend fun resolve( owner: Ed25519.KeyPair, - phone: ContactMethod.Phone, - ): Result = service.resolve(owner, phone) + identifier: ResolveIdentifier, + ): Result = service.resolve(owner, identifier) .onFailure { ErrorUtils.handleError(it) } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt index 0212fe056..aceaf4513 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt @@ -29,3 +29,22 @@ fun buildDmPaymentMetadata( ) ).build().toByteArray() } + +/** + * Builds the serialized Flipcash `AppMetadata` proto bytes for a tip DM payment. + * + * Unlike a contact DM payment there is no phone source/destination — a tip DM is + * between two user IDs, which map directly to/from public keys. Returns `null` when + * [chatId] is missing so the caller can pass the result through unconditionally. + */ +fun buildTipDmPaymentMetadata( + chatId: ChatId?, +): ByteArray? { + if (chatId == null) return null + return FlipcashIntentModel.AppMetadata.newBuilder() + .setChat( + FlipcashIntentModel.ChatMetadata.newBuilder() + .setChatId(Common.ChatId.newBuilder().setValue(chatId.bytes.toByteString())) + .setTipDmPayment(FlipcashIntentModel.ChatMetadata.TipDmPayment.newBuilder()) + ).build().toByteArray() +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/ResolveIdentifier.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ResolveIdentifier.kt new file mode 100644 index 000000000..259029c2f --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ResolveIdentifier.kt @@ -0,0 +1,12 @@ +package com.flipcash.services.models + +import com.getcode.opencode.model.core.ID + +/** + * What to resolve to an on-chain address. Mirrors the resolver `Identifier` oneof: + * a resolution is keyed by exactly one of a phone number or a user ID. + */ +sealed interface ResolveIdentifier { + data class Phone(val phone: ContactMethod.Phone) : ResolveIdentifier + data class UserId(val userId: ID) : ResolveIdentifier +} 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 4b4600349..d26a5f3a9 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 @@ -4,6 +4,7 @@ import com.flipcash.services.models.QueryOptions import com.flipcash.services.models.chat.ChatFeedPage import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType import com.getcode.ed25519.Ed25519.KeyPair interface ChatRepository { @@ -15,5 +16,6 @@ interface ChatRepository { suspend fun getDmChatFeed( owner: KeyPair, queryOptions: QueryOptions, + chatType: ChatType, ): Result } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ResolverRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ResolverRepository.kt index ed3ec9d00..5e2b7ecf6 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ResolverRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ResolverRepository.kt @@ -1,9 +1,9 @@ package com.flipcash.services.repository -import com.flipcash.services.models.ContactMethod +import com.flipcash.services.models.ResolveIdentifier import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.solana.keys.PublicKey interface ResolverRepository { - suspend fun resolve(owner: KeyPair, phone: ContactMethod.Phone): Result + suspend fun resolve(owner: KeyPair, identifier: ResolveIdentifier): 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 2003674b9..5de99c3e3 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 @@ -88,7 +88,7 @@ class ChatControllerTest { fun `getDmChatFeed fails when no account cluster`() = runTest { every { userManager.accountCluster } returns null - val result = controller.getDmChatFeed() + val result = controller.getDmChatFeed(ChatType.CONTACT_DM) assertTrue(result.isFailure) } @@ -98,7 +98,7 @@ class ChatControllerTest { stubOwner() repository.getDmChatFeedResult = Result.success(ChatFeedPage(emptyList(), null, false)) - controller.getDmChatFeed() + controller.getDmChatFeed(ChatType.CONTACT_DM) assertEquals(QueryOptions(), repository.lastQueryOptions) } @@ -110,13 +110,23 @@ class ChatControllerTest { val options = QueryOptions(limit = 25, token = token, descending = false) repository.getDmChatFeedResult = Result.success(ChatFeedPage(emptyList(), null, false)) - controller.getDmChatFeed(options) + controller.getDmChatFeed(ChatType.CONTACT_DM, options) assertEquals(25, repository.lastQueryOptions?.limit) assertEquals(token, repository.lastQueryOptions?.token) assertEquals(false, repository.lastQueryOptions?.descending) } + @Test + fun `getDmChatFeed forwards chat type filter`() = runTest { + stubOwner() + repository.getDmChatFeedResult = Result.success(ChatFeedPage(emptyList(), null, false)) + + controller.getDmChatFeed(ChatType.TIP_DM) + + assertEquals(ChatType.TIP_DM, repository.lastChatType) + } + @Test fun `getDmChatFeed returns page with chats and paging state`() = runTest { stubOwner() @@ -130,7 +140,7 @@ class ChatControllerTest { ) repository.getDmChatFeedResult = Result.success(page) - val result = controller.getDmChatFeed() + val result = controller.getDmChatFeed(ChatType.CONTACT_DM) val returned = result.getOrThrow() assertEquals(2, returned.chats.size) @@ -144,7 +154,7 @@ class ChatControllerTest { val cause = RuntimeException("server error") repository.getDmChatFeedResult = Result.failure(cause) - val result = controller.getDmChatFeed() + val result = controller.getDmChatFeed(ChatType.CONTACT_DM) assertTrue(result.isFailure) assertSame(cause, result.exceptionOrNull()) @@ -172,14 +182,20 @@ private class FakeChatRepository : ChatRepository { var getDmChatFeedResult: Result = Result.failure(RuntimeException("not configured")) var lastChatId: ChatId? = null var lastQueryOptions: QueryOptions? = null + var lastChatType: ChatType? = null override suspend fun getChat(owner: Ed25519.KeyPair, chatId: ChatId): Result { lastChatId = chatId return getChatResult } - override suspend fun getDmChatFeed(owner: Ed25519.KeyPair, queryOptions: QueryOptions): Result { + override suspend fun getDmChatFeed( + owner: Ed25519.KeyPair, + queryOptions: QueryOptions, + chatType: ChatType, + ): Result { lastQueryOptions = queryOptions + lastChatType = chatType return getDmChatFeedResult } }