diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/AbbreviatedLinkTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/AbbreviatedLinkTest.kt index 4591366663..d0f5057ff3 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/AbbreviatedLinkTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/AbbreviatedLinkTest.kt @@ -9,7 +9,7 @@ class AbbreviatedLinkTest { fun `an id link keeps the first five characters of the id`() { assertEquals( "app.flipcash.com/tip/b0ced…", - "https://app.flipcash.com/tip/b0ced1f2a3b4c5d6".abbreviatedLink(), + "https://app.flipcash.com/tip/b0ced1f2-a3b4-c5d6-e7f8-091a2b3c4d5e".abbreviatedLink(), ) } @@ -41,7 +41,7 @@ class AbbreviatedLinkTest { fun `a segment too long to be a handle is abbreviated`() { assertEquals( "flipcash.com/abcde…", - "https://flipcash.com/abcdefghijklmnop".abbreviatedLink(), + "https://flipcash.com/${"abcde" + "f".repeat(26)}".abbreviatedLink(), ) } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModel.kt index 912d127d69..e5fb9e955f 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModel.kt @@ -10,6 +10,8 @@ import com.flipcash.features.tipping.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.GetUserProfileError +import com.flipcash.services.models.MaxUsernameLength +import com.flipcash.services.models.MinUsernameLength import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper @@ -23,12 +25,12 @@ import kotlinx.coroutines.flow.onEach import javax.inject.Inject /** - * The server's handle bounds — `^[a-z0-9_]{2,15}$`, the same pattern the claim screen writes against. - * Enforced as the field is typed so a lookup can't fail request validation, which surfaces only as a - * generic error. + * The contract's handle bounds — `common.v1.Username`, `^[a-z0-9_]{2,30}$`. Wider than what the claim + * screen lets someone pick, since this looks up handles the server assigned. Enforced as the field is + * typed so a lookup can't fail request validation, which surfaces only as a generic error. */ -internal const val MinHandleLength = 2 -internal const val MaxHandleLength = 15 +internal const val MinHandleLength = MinUsernameLength +internal const val MaxHandleLength = MaxUsernameLength /** * Turning a typed `@handle` into somewhere to go. diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaint.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaint.kt index baf0b919e0..d814e40445 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaint.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaint.kt @@ -1,6 +1,6 @@ package com.flipcash.app.userprofile.internal.username -import com.flipcash.services.models.MaxUsernameLength +import com.flipcash.services.models.MaxClaimableUsernameLength import com.flipcash.services.models.MinUsernameLength /** @@ -13,12 +13,12 @@ import com.flipcash.services.models.MinUsernameLength */ internal sealed class LengthComplaint(message: String) : IllegalArgumentException(message) { class TooShort : LengthComplaint("Username shorter than $MinUsernameLength characters") - class TooLong : LengthComplaint("Username longer than $MaxUsernameLength characters") + class TooLong : LengthComplaint("Username longer than $MaxClaimableUsernameLength characters") } /** The complaint [username] would earn on submit, or null when its length is acceptable. */ internal fun lengthComplaint(username: String): LengthComplaint? = when { username.length < MinUsernameLength -> LengthComplaint.TooShort() - username.length > MaxUsernameLength -> LengthComplaint.TooLong() + username.length > MaxClaimableUsernameLength -> LengthComplaint.TooLong() else -> null } diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt index 9f9149844e..e5ff8fb536 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt @@ -9,7 +9,7 @@ import com.flipcash.app.core.extensions.onResult import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.userprofile.R import com.flipcash.services.controllers.ProfileController -import com.flipcash.services.models.MaxUsernameLength +import com.flipcash.services.models.MaxClaimableUsernameLength import com.flipcash.services.models.MinUsernameLength import com.flipcash.services.models.ModerationResult import com.flipcash.services.models.SetUsernameError @@ -188,7 +188,7 @@ class UsernameEntryViewModel @Inject constructor( title = resources.getString(R.string.error_title_usernameTooLong), message = resources.getString( R.string.error_description_usernameTooLong, - MaxUsernameLength, + MaxClaimableUsernameLength, ), ) diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaintTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaintTest.kt index b23e6c9fd2..f8b5f876b8 100644 --- a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaintTest.kt +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaintTest.kt @@ -1,6 +1,6 @@ package com.flipcash.app.userprofile.internal.username -import com.flipcash.services.models.MaxUsernameLength +import com.flipcash.services.models.MaxClaimableUsernameLength import com.flipcash.services.models.MinUsernameLength import kotlin.test.Test import kotlin.test.assertIs @@ -25,12 +25,12 @@ class LengthComplaintTest { @Test fun `exactly the maximum passes`() { - assertNull(lengthComplaint("a".repeat(MaxUsernameLength))) + assertNull(lengthComplaint("a".repeat(MaxClaimableUsernameLength))) } @Test fun `one character past the maximum is too long`() { - assertIs(lengthComplaint("a".repeat(MaxUsernameLength + 1))) + assertIs(lengthComplaint("a".repeat(MaxClaimableUsernameLength + 1))) } // The charset is the input transformation's job, not this one's — a handle of the right length diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionDetectionTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionDetectionTest.kt index fc577b5119..629c22c347 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionDetectionTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionDetectionTest.kt @@ -59,10 +59,16 @@ class MentionDetectionTest { } @Test - fun `handles outside 2 to 15 characters are not mentions`() { + fun `handles outside 2 to 30 characters are not mentions`() { assertEquals(emptyList(), handles("@a")) - assertEquals(emptyList(), handles("@abcdefghijklmnop")) - assertEquals(listOf("abcdefghijklmno"), handles("@abcdefghijklmno")) + assertEquals(emptyList(), handles("@" + "a".repeat(31))) + assertEquals(listOf("a".repeat(30)), handles("@" + "a".repeat(30))) + } + + @Test + fun `a handle longer than 15 characters is a mention`() { + assertEquals(listOf("ted_livingston_2"), handles("hi @ted_livingston_2!")) + assertEquals(listOf("ted_livingston_2"), handles("hi @Ted_Livingston_2")) } @Test diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt index 71168d897e..a10ccadd33 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt @@ -1,6 +1,7 @@ package com.flipcash.shared.chat.ui import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.isUsernameShaped /** * One `@handle` found in message text: the span it occupies, covering the `@` and the handle, and @@ -33,14 +34,20 @@ fun detectMentions(text: String, links: List): List(type) + assertEquals(ProfileAddress.ByUsername("ted_livingston_2"), type.address) + } + @Test fun `classify ignores a multi-segment path on the vanity host`() { assertNull(router.classify(DeepLink("https://flipcash.com/sally/streamer"))) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt index e45000ad21..5304f1e2bd 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt @@ -1,32 +1,43 @@ package com.flipcash.services.models +import com.codeinc.flipcash.gen.common.v1.Common +import com.codeinc.flipcash.gen.common.v1.validate +import dev.bmcreations.protovalidate.ValidationResult + /** * The `@` a handle is shown with. * - * Usernames are stored, sent and matched bare — the server's `^[a-z0-9_]{2,15}$` charset has no room + * Usernames are stored, sent and matched bare — the contract's `^[a-z0-9_]{2,30}$` charset has no room * for an `@`, and [ProfileIdentifier.Username] / [ResolveIdentifier.Username] carry them that way. * The prefix is presentation, so it is added here rather than at each surface that shows one. */ const val HandlePrefix = "@" -/** Mirrors the server's `^[a-z0-9_]{2,15}$` validation on `common.v1.Username`. */ +/** + * The bounds `common.v1.Username` allows (`^[a-z0-9_]{2,30}$`). What a handle may be on the wire, so + * what the app must be able to read, deeplink and mention; [isUsernameShaped] asks the generated + * validator rather than restating them. + */ const val MinUsernameLength = 2 -const val MaxUsernameLength = 15 +const val MaxUsernameLength = 30 /** - * The server's own charset and bounds. Kept here rather than at the one screen that types a - * username, because two other places have to recognise one without being able to ask the server: - * the `flipcash.com/` deeplink, which must not mistake `/download` for a handle, and the - * tip card's link row, which decides whether a URL's last segment is readable or an opaque id. + * The longest username the claim screen lets someone pick. A product limit tighter than the + * contract's [MaxUsernameLength]: handles the server assigns can run longer than this. */ -private val UsernamePattern = Regex("^[a-z0-9_]{$MinUsernameLength,$MaxUsernameLength}$") +const val MaxClaimableUsernameLength = 15 /** - * Whether this could be a username — the charset and length the server accepts, with or without a - * leading `@`. A yes means only "shaped like one"; whether it is actually claimed is the server's - * answer, not ours. + * Whether this could be a username — what `common.v1.Username` accepts, with or without a leading + * `@`. A yes means only "shaped like one"; whether it is actually claimed is the server's answer. + * + * Delegates to the contract's generated validator so the protos stay the single source of truth. + * Two places rely on it without being able to ask the server: the `flipcash.com/` + * deeplink, which must not mistake `/download` for a handle, and the tip card's link row, which + * decides whether a URL's last segment is readable or an opaque id. */ -fun String.isUsernameShaped(): Boolean = UsernamePattern.matches(removePrefix(HandlePrefix)) +fun String.isUsernameShaped(): Boolean = + Common.Username.newBuilder().setValue(removePrefix(HandlePrefix)).build().validate() is ValidationResult.Valid /** * This username as a handle — `sally_streamer` becomes `@sally_streamer`. diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt index 87c2b4c77c..fbfa3b78ed 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt @@ -7,7 +7,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue /** - * The shape rule stands in for the server's `^[a-z0-9_]{2,15}$`, and three places rely on it without + * The shape rule stands in for the contract's `^[a-z0-9_]{2,30}$`, and three places rely on it without * being able to ask: the vanity deeplink, the tip card's link row, and the entry screen's own * pre-submit check. Mirrors iOS `UsernameValidatorTests`. */ @@ -35,6 +35,15 @@ class HandleTest { assertTrue("a".repeat(MaxUsernameLength).isUsernameShaped()) } + @Test + fun `handles past the claim cap are still username shaped`() { + assertTrue("ted_livingston_2".isUsernameShaped()) + assertTrue("@ted_livingston_2".isUsernameShaped()) + assertTrue("a".repeat(MaxClaimableUsernameLength + 1).isUsernameShaped()) + assertTrue("a".repeat(30).isUsernameShaped()) + assertFalse("a".repeat(31).isUsernameShaped()) + } + @Test fun `either side of the length range is not`() { assertFalse("a".repeat(MinUsernameLength - 1).isUsernameShaped())