TM-SGNL-iOS/Signal/Usernames/Selection/UsernameSelectionViewController+UsernameTextField.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

380 lines
14 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
protocol DiscriminatorTextFieldDelegate: AnyObject {
func didManuallyChangeDiscriminator()
}
extension UsernameSelectionViewController {
class UsernameTextFieldWrapper: UIView {
let textField: UsernameTextField
init(username: ParsedUsername?) {
textField = UsernameTextField(forUsername: username)
super.init(frame: .zero)
addSubview(textField)
textField.autoPinEdgesToSuperviewMargins()
layoutMargins = UIEdgeInsets(hMargin: 16, vMargin: 14)
layer.cornerRadius = 10
}
required init?(coder: NSCoder) {
owsFail("Not implemented!")
}
func updateFontsForCurrentPreferredContentSize() {
textField.updateFontsForCurrentPreferredContentSize()
}
func setColorsForCurrentTheme() {
backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray80 : .ows_white
textField.setColorsForCurrentTheme()
}
}
class UsernameTextField: OWSTextField {
/// Presents state related to a username discriminator, such as the
/// discriminator itself and state indicators such as a spinner.
class DiscriminatorView: OWSStackView {
private enum Constants {
static let spacing: CGFloat = 16
static let separatorWidth: CGFloat = 2
}
enum Mode {
case empty
case spinning
case spinningWithDiscriminator(value: String)
case discriminator(value: String)
}
// MARK: Init
weak var delegate: (any DiscriminatorTextFieldDelegate)?
/// The mode for which this view should be configured.
var mode: Mode {
didSet {
AssertIsOnMainThread()
configureForCurrentMode()
}
}
fileprivate var isUsingCustomDiscriminator = false
init(inMode mode: Mode) {
self.mode = mode
super.init(
name: "Username discriminator view",
arrangedSubviews: []
)
addArrangedSubviews([
spinnerView,
separatorView,
discriminatorTextField,
])
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapDiscriminator))
tapGesture.delegate = self
discriminatorTextField.addGestureRecognizer(tapGesture)
spacing = Constants.spacing
isLayoutMarginsRelativeArrangement = true
layoutMargins.leading = Constants.spacing
configureForCurrentMode()
}
// MARK: Views
private lazy var spinnerView: UIActivityIndicatorView = {
let spinner = UIActivityIndicatorView(style: .medium)
spinner.startAnimating()
return spinner
}()
private lazy var separatorView: UIView = {
let lineView = UIView()
lineView.backgroundColor = .ows_gray15
lineView.layer.cornerRadius = Constants.separatorWidth / 2
lineView.autoSetDimension(.width, toSize: Constants.separatorWidth)
return lineView
}()
private lazy var discriminatorTextField: UITextField = {
let textField = UITextField()
textField.placeholder = "00"
textField.delegate = self
textField.keyboardType = .numberPad
return textField
}()
// MARK: Configure views
func setColorsForCurrentTheme() {
separatorView.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray45 : .ows_gray15
discriminatorTextField.textColor = Theme.primaryTextColor
}
func updateFontsForCurrentPreferredContentSize() {
let discriminatorFont = UIFont.dynamicTypeBodyClamped
discriminatorTextField.font = discriminatorFont
}
/// Configure which subviews to present based on the current mode.
private func configureForCurrentMode() {
AssertIsOnMainThread()
switch mode {
case .empty:
isHidden = true
case .spinning:
isHidden = false
spinnerView.isHiddenInStackView = false
separatorView.isHiddenInStackView = true
discriminatorTextField.isHiddenInStackView = true
case let .spinningWithDiscriminator(discriminatorValue):
isHidden = false
spinnerView.isHiddenInStackView = false
separatorView.isHiddenInStackView = false
discriminatorTextField.isHiddenInStackView = false
setDiscriminatorValue(to: discriminatorValue)
case let .discriminator(discriminatorValue):
isHidden = false
spinnerView.isHidden = true
separatorView.isHiddenInStackView = false
discriminatorTextField.isHiddenInStackView = false
setDiscriminatorValue(to: discriminatorValue)
}
}
private func setDiscriminatorValue(to value: String) {
discriminatorTextField.text = "\(value)"
}
// MARK: Getters
var currentDiscriminatorString: String? {
discriminatorTextField.text
}
/// Returns `currentDiscriminatorString` if a discriminator was manually
/// set. Otherwise `nil`.
var customDiscriminator: String? {
isUsingCustomDiscriminator ? currentDiscriminatorString : nil
}
// MARK: Actions
@discardableResult
override func becomeFirstResponder() -> Bool {
guard !discriminatorTextField.isHidden else {
return false
}
return discriminatorTextField.becomeFirstResponder()
}
/// Tapping the discriminator when it is not focused should start
/// editing with the cursor at the end of the text content. Manually
/// calling `becomeFirstResponder()` from a tap gesture accomplishes
/// this if the tap gesture is recognized alongside the text field's
/// own tap gesture in
/// `gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:)`.
@objc
private func didTapDiscriminator() {
discriminatorTextField.becomeFirstResponder()
}
}
/// The last-known reserved/confirmed discriminator. While showing a
/// spinner for an in-progress reservation, we continue to show the
/// last-known-good discriminator value.
private var lastKnownGoodDiscriminator: String?
let discriminatorView: DiscriminatorView = .init(inMode: .empty)
init(forUsername username: ParsedUsername?) {
if let username {
lastKnownGoodDiscriminator = username.discriminator
discriminatorView.mode = .discriminator(value: username.discriminator)
} else {
discriminatorView.mode = .empty
}
super.init(frame: .zero)
if let username {
text = username.nickname
}
placeholder = OWSLocalizedString(
"USERNAME_SELECTION_TEXT_FIELD_PLACEHOLDER",
comment: "The placeholder for a text field into which users can type their desired username."
)
returnKeyType = .next
autocapitalizationType = .none
autocorrectionType = .no
spellCheckingType = .no
accessibilityIdentifier = "username_textfield"
rightView = discriminatorView
rightViewMode = .always
setColorsForCurrentTheme()
updateFontsForCurrentPreferredContentSize()
}
@available(*, unavailable, message: "Use other constructor")
required init?(coder: NSCoder) {
fatalError("Use other constructor!")
}
// MARK: - Configure views
fileprivate func updateFontsForCurrentPreferredContentSize() {
font = .dynamicTypeBodyClamped
discriminatorView.updateFontsForCurrentPreferredContentSize()
}
fileprivate func setColorsForCurrentTheme() {
textColor = Theme.primaryTextColor
discriminatorView.setColorsForCurrentTheme()
}
/// Configure the text field for an in-progress reservation.
func configureForSomethingPending() {
if let customDiscriminator = discriminatorView.customDiscriminator {
setDiscriminatorViewMode(to: .spinningWithDiscriminator(value: customDiscriminator))
} else if let lastKnownGoodDiscriminator {
setDiscriminatorViewMode(to: .spinningWithDiscriminator(value: lastKnownGoodDiscriminator))
} else {
setDiscriminatorViewMode(to: .spinning)
}
}
/// Configure the text field for an error state.
func configureForError() {
if let customDiscriminator = discriminatorView.customDiscriminator {
setDiscriminatorViewMode(to: .discriminator(value: customDiscriminator))
} else if let lastKnownGoodDiscriminator {
setDiscriminatorViewMode(to: .discriminator(value: lastKnownGoodDiscriminator))
} else {
setDiscriminatorViewMode(to: .empty)
}
}
/// Configure the text field for a confirmed username. If `nil`,
/// configures for the intentional absence of a username.
func configure(forConfirmedUsername confirmedUsername: ParsedUsername?) {
if confirmedUsername?.discriminator != discriminatorView.customDiscriminator {
discriminatorView.isUsingCustomDiscriminator = false
}
if let confirmedUsername {
lastKnownGoodDiscriminator = confirmedUsername.discriminator
setDiscriminatorViewMode(to: .discriminator(value: confirmedUsername.discriminator))
} else {
lastKnownGoodDiscriminator = nil
setDiscriminatorViewMode(to: .empty)
}
}
private func setDiscriminatorViewMode(to mode: DiscriminatorView.Mode) {
discriminatorView.mode = mode
// Because the discriminator view's visible subviews may change
// after setting the mode, we should re-layout ourselves to make
// sure we size the overall view appropriately.
setNeedsLayout()
}
// MARK: - Getters
/// Returns the user-visible, case-sensitive nickname contents. Note
/// that nicknames by definition do not contain the discriminator.
///
/// - Returns
/// The case-sensitive contents of the text field. If the returned value
/// would be empty, returns `nil` instead.
var nickname: String? {
return text?.nilIfEmpty
}
/// Returns the current user-visible discriminator value. This value is
/// for display purposes only, and cannot be assumed to be valid or
/// confirmed.
var currentDiscriminatorString: String? {
discriminatorView.currentDiscriminatorString
}
}
}
// MARK: - DiscriminatorView + UITextFieldDelegate
extension UsernameSelectionViewController.UsernameTextField.DiscriminatorView: UITextFieldDelegate {
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
let oldValue = textField.text
// Filter to only ascii numbers
let shouldChangeCharacters = FormattedNumberField.textField(
textField,
shouldChangeCharactersIn: range,
replacementString: string,
allowedCharacters: .numbers,
maxCharacters: 9,
format: { $0 }
)
// Remove execess leading zeroes
if
let text = textField.text,
text.count > 2
{
// Minimum discriminator length is 2, so ignore last 2 digits before
// removing leading zeroes. For example, 01 is valid, so don't
// remove that leading 0. Do so by dropping the last 2 digits,
// removing leading zeroes, then re-adding them.
textField.text = text
.dropLast(2)
.replacingOccurrences(
// Find any amount of `0`s at the start of the string
of: "^0*",
with: "",
options: .regularExpression
)
.appending(text.suffix(2))
}
if textField.text != oldValue {
isUsingCustomDiscriminator = true
delegate?.didManuallyChangeDiscriminator()
}
return shouldChangeCharacters
}
}
// MARK: - DiscriminatorView + UIGestureRecognizerDelegate
extension UsernameSelectionViewController.UsernameTextField.DiscriminatorView: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
// See didTapDiscriminator
true
}
}