Skip to content

Commit ba2ccc5

Browse files
committed
fix(username): accept handles up to the contract's 30 characters
Handle shape now delegates to the generated common.v1.Username validator instead of a hand-copied {2,15} regex, so server-assigned handles such as ted_livingston_2 are readable, deeplinkable and mentionable. The claim screen keeps its 15-character cap as MaxClaimableUsernameLength.
1 parent ad85521 commit ba2ccc5

10 files changed

Lines changed: 77 additions & 35 deletions

File tree

‎apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/AbbreviatedLinkTest.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class AbbreviatedLinkTest {
99
fun `an id link keeps the first five characters of the id`() {
1010
assertEquals(
1111
"app.flipcash.com/tip/b0ced…",
12-
"https://app.flipcash.com/tip/b0ced1f2a3b4c5d6".abbreviatedLink(),
12+
"https://app.flipcash.com/tip/b0ced1f2-a3b4-c5d6-e7f8-091a2b3c4d5e".abbreviatedLink(),
1313
)
1414
}
1515

@@ -41,7 +41,7 @@ class AbbreviatedLinkTest {
4141
fun `a segment too long to be a handle is abbreviated`() {
4242
assertEquals(
4343
"flipcash.com/abcde…",
44-
"https://flipcash.com/abcdefghijklmnop".abbreviatedLink(),
44+
"https://flipcash.com/${"abcde" + "f".repeat(26)}".abbreviatedLink(),
4545
)
4646
}
4747

‎apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/FindByUsernameViewModel.kt‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import com.flipcash.features.tipping.R
1010
import com.flipcash.libs.coroutines.DispatcherProvider
1111
import com.flipcash.services.controllers.ProfileController
1212
import com.flipcash.services.models.GetUserProfileError
13+
import com.flipcash.services.models.MaxUsernameLength
14+
import com.flipcash.services.models.MinUsernameLength
1315
import com.flipcash.services.user.UserManager
1416
import com.getcode.manager.BottomBarManager
1517
import com.getcode.util.resources.ResourceHelper
@@ -23,12 +25,12 @@ import kotlinx.coroutines.flow.onEach
2325
import javax.inject.Inject
2426

2527
/**
26-
* The server's handle bounds — `^[a-z0-9_]{2,15}$`, the same pattern the claim screen writes against.
27-
* Enforced as the field is typed so a lookup can't fail request validation, which surfaces only as a
28-
* generic error.
28+
* The contract's handle bounds — `common.v1.Username`, `^[a-z0-9_]{2,30}$`. Wider than what the claim
29+
* screen lets someone pick, since this looks up handles the server assigned. Enforced as the field is
30+
* typed so a lookup can't fail request validation, which surfaces only as a generic error.
2931
*/
30-
internal const val MinHandleLength = 2
31-
internal const val MaxHandleLength = 15
32+
internal const val MinHandleLength = MinUsernameLength
33+
internal const val MaxHandleLength = MaxUsernameLength
3234

3335
/**
3436
* Turning a typed `@handle` into somewhere to go.

‎apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaint.kt‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.flipcash.app.userprofile.internal.username
22

3-
import com.flipcash.services.models.MaxUsernameLength
3+
import com.flipcash.services.models.MaxClaimableUsernameLength
44
import com.flipcash.services.models.MinUsernameLength
55

66
/**
@@ -13,12 +13,12 @@ import com.flipcash.services.models.MinUsernameLength
1313
*/
1414
internal sealed class LengthComplaint(message: String) : IllegalArgumentException(message) {
1515
class TooShort : LengthComplaint("Username shorter than $MinUsernameLength characters")
16-
class TooLong : LengthComplaint("Username longer than $MaxUsernameLength characters")
16+
class TooLong : LengthComplaint("Username longer than $MaxClaimableUsernameLength characters")
1717
}
1818

1919
/** The complaint [username] would earn on submit, or null when its length is acceptable. */
2020
internal fun lengthComplaint(username: String): LengthComplaint? = when {
2121
username.length < MinUsernameLength -> LengthComplaint.TooShort()
22-
username.length > MaxUsernameLength -> LengthComplaint.TooLong()
22+
username.length > MaxClaimableUsernameLength -> LengthComplaint.TooLong()
2323
else -> null
2424
}

‎apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import com.flipcash.app.core.extensions.onResult
99
import com.flipcash.app.userflags.UserFlagsCoordinator
1010
import com.flipcash.features.userprofile.R
1111
import com.flipcash.services.controllers.ProfileController
12-
import com.flipcash.services.models.MaxUsernameLength
12+
import com.flipcash.services.models.MaxClaimableUsernameLength
1313
import com.flipcash.services.models.MinUsernameLength
1414
import com.flipcash.services.models.ModerationResult
1515
import com.flipcash.services.models.SetUsernameError
@@ -188,7 +188,7 @@ class UsernameEntryViewModel @Inject constructor(
188188
title = resources.getString(R.string.error_title_usernameTooLong),
189189
message = resources.getString(
190190
R.string.error_description_usernameTooLong,
191-
MaxUsernameLength,
191+
MaxClaimableUsernameLength,
192192
),
193193
)
194194

‎apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/username/LengthComplaintTest.kt‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.flipcash.app.userprofile.internal.username
22

3-
import com.flipcash.services.models.MaxUsernameLength
3+
import com.flipcash.services.models.MaxClaimableUsernameLength
44
import com.flipcash.services.models.MinUsernameLength
55
import kotlin.test.Test
66
import kotlin.test.assertIs
@@ -25,12 +25,12 @@ class LengthComplaintTest {
2525

2626
@Test
2727
fun `exactly the maximum passes`() {
28-
assertNull(lengthComplaint("a".repeat(MaxUsernameLength)))
28+
assertNull(lengthComplaint("a".repeat(MaxClaimableUsernameLength)))
2929
}
3030

3131
@Test
3232
fun `one character past the maximum is too long`() {
33-
assertIs<LengthComplaint.TooLong>(lengthComplaint("a".repeat(MaxUsernameLength + 1)))
33+
assertIs<LengthComplaint.TooLong>(lengthComplaint("a".repeat(MaxClaimableUsernameLength + 1)))
3434
}
3535

3636
// The charset is the input transformation's job, not this one's — a handle of the right length

‎apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionDetectionTest.kt‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,16 @@ class MentionDetectionTest {
5959
}
6060

6161
@Test
62-
fun `handles outside 2 to 15 characters are not mentions`() {
62+
fun `handles outside 2 to 30 characters are not mentions`() {
6363
assertEquals(emptyList(), handles("@a"))
64-
assertEquals(emptyList(), handles("@abcdefghijklmnop"))
65-
assertEquals(listOf("abcdefghijklmno"), handles("@abcdefghijklmno"))
64+
assertEquals(emptyList(), handles("@" + "a".repeat(31)))
65+
assertEquals(listOf("a".repeat(30)), handles("@" + "a".repeat(30)))
66+
}
67+
68+
@Test
69+
fun `a handle longer than 15 characters is a mention`() {
70+
assertEquals(listOf("ted_livingston_2"), handles("hi @ted_livingston_2!"))
71+
assertEquals(listOf("ted_livingston_2"), handles("hi @Ted_Livingston_2"))
6672
}
6773

6874
@Test

‎apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.flipcash.shared.chat.ui
22

33
import com.flipcash.services.models.chat.MessageContent
4+
import com.flipcash.services.models.isUsernameShaped
45

56
/**
67
* 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<DetectedUrl>): List<DetectedMention
3334
val start = mention.range.first
3435
val end = mention.range.last + 1
3536
if (links.any { it.start < end && start < it.end }) return@mapNotNull null
36-
DetectedMention(start = start, end = end, username = handle.value.lowercase())
37+
val username = handle.value.lowercase()
38+
if (!username.isUsernameShaped()) return@mapNotNull null
39+
DetectedMention(start = start, end = end, username = username)
3740
}.toList()
3841
}
3942

4043
// The preceding character is matched and left out of the mention's range, as iOS does for want of
4144
// a lookbehind in Swift Regex, so the two platforms run one pattern.
45+
//
46+
// The handle's length is not bounded here: the candidate is any run of handle characters, and
47+
// `detectMentions` confirms each against `common.v1.Username` via `isUsernameShaped()` (on the
48+
// lowercased text, since usernames are stored lowercase), so the contract owns the 2..30 bound.
4249
private val MENTION_PATTERN =
43-
Regex("""(?:^|[^A-Za-z0-9_.@])(@([A-Za-z0-9_]{2,15}))(?![A-Za-z0-9_@])""")
50+
Regex("""(?:^|[^A-Za-z0-9_.@])(@([A-Za-z0-9_]+))(?![A-Za-z0-9_@])""")
4451

4552
/**
4653
* The mentions in the text this content's bubble draws, as offsets into that text: the body of a

‎apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -771,12 +771,19 @@ class AppRouterTest {
771771

772772
@Test
773773
fun `classify ignores a vanity path that isn't shaped like a handle`() {
774-
// Too short, too long, and outside the server's charset.
774+
// Too short, too long, and outside the contract's charset.
775775
assertNull(router.classify(DeepLink("https://flipcash.com/a")))
776-
assertNull(router.classify(DeepLink("https://flipcash.com/sixteencharacter")))
776+
assertNull(router.classify(DeepLink("https://flipcash.com/${"a".repeat(31)}")))
777777
assertNull(router.classify(DeepLink("https://flipcash.com/sally.streamer")))
778778
}
779779

780+
@Test
781+
fun `classify opens a vanity handle past the claim cap`() {
782+
val type = router.classify(DeepLink("https://flipcash.com/ted_livingston_2"))
783+
assertIs<DeeplinkType.Profile>(type)
784+
assertEquals(ProfileAddress.ByUsername("ted_livingston_2"), type.address)
785+
}
786+
780787
@Test
781788
fun `classify ignores a multi-segment path on the vanity host`() {
782789
assertNull(router.classify(DeepLink("https://flipcash.com/sally/streamer")))

‎services/flipcash/src/main/kotlin/com/flipcash/services/models/Handle.kt‎

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,43 @@
11
package com.flipcash.services.models
22

3+
import com.codeinc.flipcash.gen.common.v1.Common
4+
import com.codeinc.flipcash.gen.common.v1.validate
5+
import dev.bmcreations.protovalidate.ValidationResult
6+
37
/**
48
* The `@` a handle is shown with.
59
*
6-
* Usernames are stored, sent and matched bare — the server's `^[a-z0-9_]{2,15}$` charset has no room
10+
* Usernames are stored, sent and matched bare — the contract's `^[a-z0-9_]{2,30}$` charset has no room
711
* for an `@`, and [ProfileIdentifier.Username] / [ResolveIdentifier.Username] carry them that way.
812
* The prefix is presentation, so it is added here rather than at each surface that shows one.
913
*/
1014
const val HandlePrefix = "@"
1115

12-
/** Mirrors the server's `^[a-z0-9_]{2,15}$` validation on `common.v1.Username`. */
16+
/**
17+
* The bounds `common.v1.Username` allows (`^[a-z0-9_]{2,30}$`). What a handle may be on the wire, so
18+
* what the app must be able to read, deeplink and mention; [isUsernameShaped] asks the generated
19+
* validator rather than restating them.
20+
*/
1321
const val MinUsernameLength = 2
14-
const val MaxUsernameLength = 15
22+
const val MaxUsernameLength = 30
1523

1624
/**
17-
* The server's own charset and bounds. Kept here rather than at the one screen that types a
18-
* username, because two other places have to recognise one without being able to ask the server:
19-
* the `flipcash.com/<username>` deeplink, which must not mistake `/download` for a handle, and the
20-
* tip card's link row, which decides whether a URL's last segment is readable or an opaque id.
25+
* The longest username the claim screen lets someone pick. A product limit tighter than the
26+
* contract's [MaxUsernameLength]: handles the server assigns can run longer than this.
2127
*/
22-
private val UsernamePattern = Regex("^[a-z0-9_]{$MinUsernameLength,$MaxUsernameLength}$")
28+
const val MaxClaimableUsernameLength = 15
2329

2430
/**
25-
* Whether this could be a username — the charset and length the server accepts, with or without a
26-
* leading `@`. A yes means only "shaped like one"; whether it is actually claimed is the server's
27-
* answer, not ours.
31+
* Whether this could be a username — what `common.v1.Username` accepts, with or without a leading
32+
* `@`. A yes means only "shaped like one"; whether it is actually claimed is the server's answer.
33+
*
34+
* Delegates to the contract's generated validator so the protos stay the single source of truth.
35+
* Two places rely on it without being able to ask the server: the `flipcash.com/<username>`
36+
* deeplink, which must not mistake `/download` for a handle, and the tip card's link row, which
37+
* decides whether a URL's last segment is readable or an opaque id.
2838
*/
29-
fun String.isUsernameShaped(): Boolean = UsernamePattern.matches(removePrefix(HandlePrefix))
39+
fun String.isUsernameShaped(): Boolean =
40+
Common.Username.newBuilder().setValue(removePrefix(HandlePrefix)).build().validate() is ValidationResult.Valid
3041

3142
/**
3243
* This username as a handle — `sally_streamer` becomes `@sally_streamer`.

‎services/flipcash/src/test/kotlin/com/flipcash/services/models/HandleTest.kt‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import kotlin.test.assertNull
77
import kotlin.test.assertTrue
88

99
/**
10-
* The shape rule stands in for the server's `^[a-z0-9_]{2,15}$`, and three places rely on it without
10+
* The shape rule stands in for the contract's `^[a-z0-9_]{2,30}$`, and three places rely on it without
1111
* being able to ask: the vanity deeplink, the tip card's link row, and the entry screen's own
1212
* pre-submit check. Mirrors iOS `UsernameValidatorTests`.
1313
*/
@@ -35,6 +35,15 @@ class HandleTest {
3535
assertTrue("a".repeat(MaxUsernameLength).isUsernameShaped())
3636
}
3737

38+
@Test
39+
fun `handles past the claim cap are still username shaped`() {
40+
assertTrue("ted_livingston_2".isUsernameShaped())
41+
assertTrue("@ted_livingston_2".isUsernameShaped())
42+
assertTrue("a".repeat(MaxClaimableUsernameLength + 1).isUsernameShaped())
43+
assertTrue("a".repeat(30).isUsernameShaped())
44+
assertFalse("a".repeat(31).isUsernameShaped())
45+
}
46+
3847
@Test
3948
fun `either side of the length range is not`() {
4049
assertFalse("a".repeat(MinUsernameLength - 1).isUsernameShaped())

0 commit comments

Comments
 (0)