diff --git a/definitions/flipcash/protos/src/main/proto/common/v1/common.proto b/definitions/flipcash/protos/src/main/proto/common/v1/common.proto index 73a1eafa5e..21d46b01a8 100644 --- a/definitions/flipcash/protos/src/main/proto/common/v1/common.proto +++ b/definitions/flipcash/protos/src/main/proto/common/v1/common.proto @@ -61,6 +61,13 @@ message UserId { }]; } +// Username is a user's unique handle on Flipcash. It uses the same character +// set as X — letters, digits and underscores — with the exception that it must +// be lowercase. +message Username { + string value = 1 [(validate.rules).string.pattern = "^[a-z0-9_]{2,15}$"]; +} + message ChatId { // value has the following structure: // - 32 byte hash for DMs diff --git a/definitions/flipcash/protos/src/main/proto/profile/v1/model.proto b/definitions/flipcash/protos/src/main/proto/profile/v1/model.proto index 20e447c271..c02c0bb755 100644 --- a/definitions/flipcash/protos/src/main/proto/profile/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/profile/v1/model.proto @@ -12,12 +12,20 @@ import "google/protobuf/timestamp.proto"; import "validate/validate.proto"; message UserProfile { + // The ID of the user this profile belongs to. Always set, so a caller that + // looked the profile up by username learns the user's ID from the response. + common.v1.UserId user_id = 9 [(validate.rules).message.required = true]; + // Display name is the display name of the user (if found). string display_name = 1 [(validate.rules).string = { min_len: 0 max_len: 64 }]; + // The user's username on Flipcash. Public, so it is returned for any user, + // not just the caller. Unset when the user hasn't claimed one yet. + common.v1.Username username = 8; + // Social profiles are links to external social accounts repeated SocialProfile social_profiles = 2 [(validate.rules).repeated = { min_items: 0 diff --git a/definitions/flipcash/protos/src/main/proto/profile/v1/profile_service.proto b/definitions/flipcash/protos/src/main/proto/profile/v1/profile_service.proto index e5e82b0106..fbf946b351 100644 --- a/definitions/flipcash/protos/src/main/proto/profile/v1/profile_service.proto +++ b/definitions/flipcash/protos/src/main/proto/profile/v1/profile_service.proto @@ -38,7 +38,14 @@ service Profile { } message GetProfileRequest { - common.v1.UserId user_id = 1 [(validate.rules).message.required = true]; + // The user whose profile is being fetched, identified either by their user + // ID or by their username. Exactly one must be set. + oneof identifier { + option (validate.required) = true; + + common.v1.UserId user_id = 1; + common.v1.Username username = 3; + } // Optional auth to retrieve private profile information for self common.v1.Auth auth = 2; diff --git a/definitions/flipcash/protos/src/main/proto/resolver/v1/model.proto b/definitions/flipcash/protos/src/main/proto/resolver/v1/model.proto index 48348406bc..5c77a739cf 100644 --- a/definitions/flipcash/protos/src/main/proto/resolver/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/resolver/v1/model.proto @@ -15,8 +15,9 @@ message Identifier { oneof kind { option (validate.required) = true; - common.v1.PhoneNumber phone = 1; - common.v1.UserId user_id = 2; + common.v1.PhoneNumber phone = 1; + common.v1.UserId user_id = 2; + common.v1.Username username = 3; } } 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 fbb11dfe98..9f9a3fa577 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 @@ -2,6 +2,7 @@ package com.flipcash.services.controllers import com.flipcash.services.models.GetUserProfileError import com.flipcash.services.models.LinkingToken +import com.flipcash.services.models.ProfileIdentifier import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccountUnlinkRequest @@ -68,11 +69,21 @@ class ProfileController @Inject constructor( suspend fun getProfileForUser( userId: ID, - ): Result { + ): Result = getProfile(ProfileIdentifier.UserId(userId)) + + /** + * Fetches a profile by its owner's public Flipcash handle. The response carries + * the user's ID, so a caller that only had the username learns it from here. + */ + suspend fun getProfileForUsername( + username: String, + ): Result = getProfile(ProfileIdentifier.Username(username)) + + private suspend fun getProfile(identifier: ProfileIdentifier): Result { val owner = userManager.accountCluster?.authority?.keyPair ?: return Result.failure(Throwable("No account cluster in UserManager")) - return repository.getProfile(userId, owner) + return repository.getProfile(identifier, owner) } suspend fun setDisplayName( 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 c4c9f54625..511cf48b18 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 @@ -22,6 +22,10 @@ class ResolverController @Inject constructor( suspend fun resolve(userId: ID): Result = resolve(ResolveIdentifier.UserId(userId)) + /** Resolves a username to its owner's on-chain address. */ + suspend fun resolve(username: String): Result = + resolve(ResolveIdentifier.Username(username)) + private suspend fun resolve(identifier: ResolveIdentifier): Result { val owner = userManager.accountCluster?.authority?.keyPair ?: return Result.failure(Throwable("No account cluster in UserManager")) 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 a9a6601370..bea0845c6c 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,8 @@ 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.codeinc.flipcash.gen.profile.v1.usernameOrNull +import com.flipcash.services.internal.network.extensions.toId import com.flipcash.services.internal.network.extensions.toMediaItem import com.flipcash.services.models.UserProfile import com.flipcash.services.models.VerifiableContactMethod @@ -25,6 +27,9 @@ class UserProfileMapper @Inject constructor( Instant.fromEpochSeconds(from.joinTs.seconds, from.joinTs.nanos) } else null, tipCardColor = if (from.hasTipCardCustomization()) from.tipCardCustomization.color.hex else null, + userId = if (from.hasUserId()) from.userId.toId() else null, + // Public, so it is returned for any user — absent only when unclaimed. + username = from.usernameOrNull?.value, ) } } \ 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 016aff8cfd..9141d5315c 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 @@ -5,14 +5,15 @@ import com.codeinc.flipcash.gen.profile.v1.ProfileGrpcKt import com.codeinc.flipcash.gen.profile.v1.ProfileService import com.flipcash.services.internal.annotations.FlipcashManagedChannel import com.flipcash.services.internal.network.extensions.asUserId +import com.flipcash.services.internal.network.extensions.asUsername import com.flipcash.services.internal.network.extensions.authenticate import com.flipcash.services.internal.network.extensions.linkingToken +import com.flipcash.services.models.ProfileIdentifier 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 @@ -32,14 +33,24 @@ internal class ProfileApi @Inject constructor( .withWaitForReady() /** - * Gets the profile for a user + * Gets the profile for a user, keyed by either their user ID or their username. */ - suspend fun getProfile(userId: ID, owner: Ed25519.KeyPair): ProfileService.GetProfileResponse { + suspend fun getProfile( + identifier: ProfileIdentifier, + owner: Ed25519.KeyPair, + ): ProfileService.GetProfileResponse { val request = ProfileService.GetProfileRequest.newBuilder() - .setUserId(userId.asUserId()) + .apply { + when (identifier) { + is ProfileIdentifier.UserId -> setUserId(identifier.userId.asUserId()) + is ProfileIdentifier.Username -> setUsername(identifier.username.asUsername()) + } + } .apply { setAuth(authenticate(owner)) } .build() + request.validate().orThrow() + return withContext(Dispatchers.IO) { api.getProfile(request) } 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 24544ca5ee..36ee7fa64a 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 @@ -7,6 +7,7 @@ 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.flipcash.services.internal.network.extensions.asUsername import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.network.core.GrpcApi import dev.bmcreations.protovalidate.orThrow @@ -50,6 +51,8 @@ internal class ResolverApi @Inject constructor( builder.setPhone(Common.PhoneNumber.newBuilder().setValue(phone.phoneNumber)) is ResolveIdentifier.UserId -> builder.setUserId(userId.asUserId()) + is ResolveIdentifier.Username -> + builder.setUsername(username.asUsername()) }.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 1837f0826a..3551aac723 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 @@ -45,6 +45,10 @@ internal fun ID.asUserId(): Common.UserId { return Common.UserId.newBuilder().setValue(toByteString()).build() } +internal fun String.asUsername(): Common.Username { + return Common.Username.newBuilder().setValue(this).build() +} + internal fun Instant.asTimestamp(): Timestamp { return Timestamp.newBuilder().setSeconds(this.epochSeconds).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 b5cb3d75b2..47f51d314d 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 @@ -373,6 +373,8 @@ internal fun ChatModel.Metadata.toChatMetadata(): ChatMetadata { 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, + userId = if (hasUserId()) userId.toId() else null, + username = if (hasUsername()) username.value 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 62346f0fa1..34a1e38030 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 @@ -7,6 +7,7 @@ import com.flipcash.services.internal.network.extensions.toFlaggedCategory import com.getcode.opencode.utils.toValidationOrElse import com.flipcash.services.models.GetUserProfileError import com.flipcash.services.models.LinkSocialAccountError +import com.flipcash.services.models.ProfileIdentifier import com.flipcash.services.models.SetDisplayNameError import com.flipcash.services.models.SetProfilePictureError import com.flipcash.services.models.SocialAccountLinkRequest @@ -18,18 +19,17 @@ 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 import javax.inject.Inject internal class ProfileService @Inject constructor( private val api: ProfileApi, ) { suspend fun getProfile( - userId: ID, + identifier: ProfileIdentifier, owner: Ed25519.KeyPair, ): Result { return runCatching { - api.getProfile(userId, owner) + api.getProfile(identifier, owner) }.foldWithSuppression( onSuccess = { response -> when (response.result) { 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 fb656310ca..0898f23fc3 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 @@ -4,6 +4,7 @@ import com.flipcash.services.internal.domain.SocialAccountMapper import com.flipcash.services.internal.domain.UserProfileMapper import com.flipcash.services.internal.network.services.ProfileService import com.flipcash.services.models.GetUserProfileError +import com.flipcash.services.models.ProfileIdentifier import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccountUnlinkRequest @@ -12,7 +13,6 @@ 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 import com.getcode.utils.ErrorUtils internal class InternalProfileRepository( @@ -20,8 +20,11 @@ internal class InternalProfileRepository( private val userProfileMapper: UserProfileMapper, private val socialAccountMapper: SocialAccountMapper, ): ProfileRepository { - override suspend fun getProfile(userId: ID, owner: Ed25519.KeyPair): Result { - return service.getProfile(userId, owner) + override suspend fun getProfile( + identifier: ProfileIdentifier, + owner: Ed25519.KeyPair, + ): Result { + return service.getProfile(identifier, owner) .map { userProfileMapper.map(it) } .onFailure { if (it !is GetUserProfileError.NotFound) { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/ProfileIdentifier.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ProfileIdentifier.kt new file mode 100644 index 0000000000..36435d9f62 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ProfileIdentifier.kt @@ -0,0 +1,12 @@ +package com.flipcash.services.models + +import com.getcode.opencode.model.core.ID + +/** + * Whose profile to fetch. Mirrors the `GetProfileRequest.identifier` oneof: a + * profile is looked up by exactly one of a user ID or a username. + */ +sealed interface ProfileIdentifier { + data class UserId(val userId: ID) : ProfileIdentifier + data class Username(val username: String) : ProfileIdentifier +} 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 index 259029c2ff..ae0e05dcc8 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/ResolveIdentifier.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ResolveIdentifier.kt @@ -4,9 +4,10 @@ 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. + * a resolution is keyed by exactly one of a phone number, a user ID, or a username. */ sealed interface ResolveIdentifier { data class Phone(val phone: ContactMethod.Phone) : ResolveIdentifier data class UserId(val userId: ID) : ResolveIdentifier + data class Username(val username: String) : ResolveIdentifier } 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 844fabf84b..7d2b90c765 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 @@ -2,6 +2,7 @@ package com.flipcash.services.models import android.os.Parcelable import com.flipcash.services.models.chat.MediaItem +import com.getcode.opencode.model.core.ID import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import kotlin.time.Instant @@ -20,6 +21,12 @@ data class UserProfile( val joinedAt: Instant? = null, // The hex color for the user's tip card customization. Null when unset. val tipCardColor: String? = null, + // The ID of the user this profile belongs to. Server-provided on any fetched + // profile; null for a locally-constructed one. + val userId: ID? = null, + // The user's public Flipcash handle. Public, so it is present for any user — + // null when they haven't claimed one yet. + val username: String? = null, ): Parcelable { /** The phone number only when it has been verified — backwards-compatible accessor. */ val verifiedPhoneNumber: String? get() = phoneNumber?.takeIf { it.verified }?.value @@ -34,6 +41,8 @@ data class UserProfile( phoneNumber = null, email = null, tipCardColor = null, + userId = null, + username = null, ) } } 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 113dbdb613..33efbc44f8 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 @@ -1,5 +1,6 @@ package com.flipcash.services.repository +import com.flipcash.services.models.ProfileIdentifier import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.SocialAccountUnlinkRequest @@ -7,10 +8,9 @@ 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 getProfile(identifier: ProfileIdentifier, owner: Ed25519.KeyPair): Result suspend fun setDisplayName(displayName: String, owner: Ed25519.KeyPair): Result suspend fun setProfilePicture(blobId: BlobId, owner: Ed25519.KeyPair): Result suspend fun updateTipCard(owner: Ed25519.KeyPair, hexColor: String): Result 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 1f0e1f1efb..5f8b007bda 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 @@ -8,6 +8,7 @@ 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.models.ProfileIdentifier import com.flipcash.services.repository.ProfileRepository import com.flipcash.services.user.UserManager import com.getcode.ed25519.Ed25519 @@ -306,7 +307,7 @@ private class FakeProfileRepository : ProfileRepository { 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 getProfile(identifier: ProfileIdentifier, 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 updateTipCard(owner: Ed25519.KeyPair, hexColor: String) = updateTipCardResult