Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ final class OnboardingNameViewModel {

@ObservationIgnored private let flipClient: FlipClient
@ObservationIgnored private let owner: KeyPair
@ObservationIgnored private let validator = DisplayNameValidator()
@ObservationIgnored private let validator = DisplayNameValidator(asciiOnly: true)

/// Fires once the name is saved; the onboarding flow advances from here.
@ObservationIgnored var onComplete: (@MainActor () -> Void)?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@ import Foundation

/// Validates a profile display name: 1–64 Unicode scalars of any script.
///
/// Returns the name trimmed of leading and trailing whitespace.
/// Returns the name trimmed of leading and trailing whitespace. With `asciiOnly`,
/// the trimmed name may also contain only `A-Z`, `a-z`, `0-9`, and U+0020.
public struct DisplayNameValidator: Validator {

/// PGV `max_len` from `FlipcashAPI/Core/proto/profile/v1/profile_service.proto`
/// counts Unicode scalars, not grapheme clusters — one ZWJ emoji spends seven.
public static let maxScalars = 64

public init() {}
private let asciiOnly: Bool

/// Creates a validator; `asciiOnly` restricts names to ASCII letters, digits, and spaces.
public init(asciiOnly: Bool = false) {
self.asciiOnly = asciiOnly
}

public func validate(_ input: String) -> String? {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
Expand All @@ -23,6 +29,10 @@ public struct DisplayNameValidator: Validator {
return nil
}

if asciiOnly, !trimmed.unicodeScalars.allSatisfy(Self.isASCIIAlphanumericOrSpace) {
return nil
}

return trimmed
}

Expand All @@ -32,4 +42,11 @@ public struct DisplayNameValidator: Validator {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
return Self.maxScalars - trimmed.unicodeScalars.count
}

private static func isASCIIAlphanumericOrSpace(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
case 0x20, 0x30...0x39, 0x41...0x5A, 0x61...0x7A: true
default: false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,37 @@ struct DisplayNameValidatorTests {
func remainingIgnoresSurroundingWhitespace() {
#expect(validator.remaining(in: " Ted ") == DisplayNameValidator.maxScalars - 3)
}

// MARK: - ASCII-only (onboarding) -

private let asciiValidator = DisplayNameValidator(asciiOnly: true)

@Test("ASCII-only accepts letters, digits, and spaces, trimmed",
arguments: [
(input: "Taylor Smith 2", expected: "Taylor Smith 2"),
(input: " Taylor ", expected: "Taylor"),
(input: "A B", expected: "A B"),
])
func asciiOnlyAccepts(input: String, expected: String) {
#expect(asciiValidator.validate(input) == expected)
}

@Test("ASCII-only rejects accents, punctuation, emoji, other whitespace, and over-limit",
arguments: [
"Jos\u{00E9}",
"Taylor!",
"\u{1F600}",
"Tay\tlor",
"Tay\u{00A0}lor",
" ",
String(repeating: "a", count: 65),
])
func asciiOnlyRejects(input: String) {
#expect(asciiValidator.validate(input) == nil)
}

@Test("Default validator still accepts accented names")
func defaultAcceptsAccents() {
#expect(validator.validate("Jos\u{00E9}") == "Jos\u{00E9}")
}
}
Loading