Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/ThreadSettings/NicknameEditorViewController.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import SignalUI

class NicknameEditorViewController: OWSTableViewController2 {

    // MARK: Properties

    static let maxNoteLengthGlyphs: Int = 240
    static let maxNoteLengthBytes: Int = 2048

    struct Context {
        let db: any DB
        let nicknameManager: any NicknameManager
    }

    private let context: Context

    /// The Nickname that was already set when the view appeared.
    private let initialNickname: ProfileName?
    /// The note that was already set when the view appeared.
    private var initialNote: String? {
        self.initialNicknameRecord?.note
    }

    private let recipient: SignalRecipient
    private let initialNicknameRecord: NicknameRecord?

    static func create(for address: SignalServiceAddress, context: Context, tx: DBReadTransaction) -> NicknameEditorViewController? {
        guard
            let recipient = DependenciesBridge.shared.recipientDatabaseTable.fetchRecipient(
                address: address,
                tx: tx,
            )
        else {
            owsFailDebug("Could not find recipient for address")
            return nil
        }

        let nickname = context.nicknameManager.fetchNickname(for: recipient, tx: tx)

        return NicknameEditorViewController(
            recipient: recipient,
            existingNickname: nickname,
            context: context,
        )
    }

    init(
        recipient: SignalRecipient,
        existingNickname: NicknameRecord?,
        context: Context,
    ) {
        self.recipient = recipient
        self.context = context

        self.initialNickname = .init(nicknameRecord: existingNickname)
        self.initialNicknameRecord = existingNickname

        super.init()

        if let initialNickname {
            self.givenNameTextField.text = initialNickname.givenName
            self.familyNameTextField.text = initialNickname.familyName
        }
        self.noteTextView.text = initialNote
    }

    /// The given name entered into the text field.
    private var givenName: String? {
        self.givenNameTextField.text?.nilIfEmpty
    }

    /// The family name entered into the text field.
    private var familyName: String? {
        self.familyNameTextField.text?.nilIfEmpty
    }

    /// The note entered into the text view.
    private var note: String? {
        self.noteTextView.text?.nilIfEmpty
    }

    /// The Nickname, if valid, that is entered into the text fields.
    private var enteredNickname: ProfileName? {
        ProfileName(givenName: self.givenName, familyName: self.familyName)
    }

    /// Whether there are unsaved changes to the Nickname or note.
    private var hasUnsavedChanges: Bool {
        self.note != self.initialNote ||
            // `enteredNickname` only has a value when the nickname is valid, so we can't just check against that.
            self.givenName != self.initialNickname?.givenName ||
            self.familyName != self.initialNickname?.familyName
    }

    /// Whether the entered nickname is in a state that can be saved, either valid or empty.
    private var enteredNicknameCanBeSaved: Bool {
        (familyName == nil && givenName == nil) || enteredNickname != nil
    }

    /// Whether both there are unsaved changes and those changes can be saved.
    private var canSaveChanges: Bool {
        hasUnsavedChanges && enteredNicknameCanBeSaved
    }

    // MARK: Views

    private lazy var avatarView: ConversationAvatarView = {
        let avatarView = ConversationAvatarView(
            sizeClass: .eightyEight,
            localUserDisplayMode: .asUser,
            badged: false,
        )
        avatarView.updateWithSneakyTransactionIfNecessary { config in
            config.dataSource = .address(self.recipient.address)
        }
        return avatarView
    }()

    private lazy var avatarViewContainer: UIView = {
        let container = UIView.container()
        container.addSubview(avatarView)
        avatarView.autoCenterInSuperview()
        avatarView.autoPinWidthToSuperview(relation: .lessThanOrEqual)
        avatarView.autoPinHeightToSuperview(withMargin: 24, relation: .lessThanOrEqual)
        return container
    }()

    private lazy var givenNameTextField = self.createNameTextField(
        placeholder: OWSLocalizedString(
            "NICKNAME_EDITOR_GIVEN_NAME_PLACEHOLDER",
            comment: "Placeholder text it the text field for the given name in the profile nickname editor.",
        ),
    )
    private lazy var familyNameTextField = self.createNameTextField(
        placeholder: OWSLocalizedString(
            "NICKNAME_EDITOR_FAMILY_NAME_PLACEHOLDER",
            comment: "Placeholder text it the text field for the family name in the profile nickname editor.",
        ),
    )

    private lazy var noteTextView: TextViewWithPlaceholder = {
        let textView = TextViewWithPlaceholder()
        textView.placeholderText = OWSLocalizedString(
            "NICKNAME_EDITOR_NOTE_PLACEHOLDER",
            comment: "Placeholder text it the text box for the note in the profile nickname editor.",
        )
        textView.delegate = self

        textView.addSubview(noteCharacterLimitLabel)
        noteCharacterLimitLabel.autoPinEdge(toSuperviewEdge: .trailing)
        noteCharacterLimitLabel.autoPinEdge(toSuperviewEdge: .bottom)

        return textView
    }()

    private lazy var noteCharacterLimitLabel: UILabel = {
        let label = UILabel()
        label.font = .dynamicTypeSubheadline
        label.isHidden = true
        return label
    }()

    private func createNameTextField(placeholder: String) -> OWSTextField {
        OWSTextField(
            placeholder: placeholder,
            spellCheckingType: .no,
            autocorrectionType: .no,
            clearButtonMode: .whileEditing,
            delegate: self,
            editingChanged: { [weak self] in
                self?.editingChanged()
            },
        )
    }

    private func editingChanged() {
        navigationItem.rightBarButtonItem?.isEnabled = canSaveChanges

        let glyphCount = noteTextView.text?.glyphCount ?? 0
        if glyphCount < 140 {
            noteCharacterLimitLabel.isHidden = true
        } else {
            noteCharacterLimitLabel.isHidden = false
            let remainingCharacters = Self.maxNoteLengthGlyphs - glyphCount
            noteCharacterLimitLabel.text = "\(remainingCharacters)"
            if remainingCharacters > 5 {
                noteCharacterLimitLabel.textColor = Theme.secondaryTextAndIconColor
            } else {
                noteCharacterLimitLabel.textColor = .ows_accentRed
            }
        }
    }

    private func updateFonts() {
        let font = UIFont.dynamicTypeSubheadlineClamped
        noteCharacterLimitLabel.font = font
        noteTextView.textContainerInset.trailing = NSAttributedString(
            string: noteCharacterLimitLabel.text ?? "",
            attributes: [.font: font],
        ).size().width + 8
    }

    // MARK: Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()

        shouldAvoidKeyboard = true
        self.updateTableContents()
        self.updateFonts()

        self.title = OWSLocalizedString(
            "NICKNAME_EDITOR_TITLE",
            comment: "The title for the profile nickname editor view.",
        )
        navigationItem.leftBarButtonItem = .cancelButton(
            dismissingFrom: self,
            hasUnsavedChanges: { [weak self] in self?.hasUnsavedChanges },
        )
        navigationItem.rightBarButtonItem = .doneButton { [weak self] in
            self?.saveChanges()
        }
        editingChanged()

        if self.initialNicknameRecord == nil, self.initialNote == nil {
            givenNameTextField.becomeFirstResponder()
        }
    }

    override func contentSizeCategoryDidChange() {
        super.contentSizeCategoryDidChange()
        self.updateFonts()
    }

    private func updateTableContents() {
        let contents = OWSTableContents()

        let titleSection = OWSTableSection(
            title: nil,
            items: [],
            footerTitle: OWSLocalizedString(
                "NICKNAME_EDITOR_DESCRIPTION",
                comment: "The description below the title on the profile nickname editor view.",
            ),
        )
        contents.add(titleSection)

        let namesSectionItems: [OWSTableItem]

        let givenNameItem = OWSTableItem.textFieldItem(
            self.givenNameTextField,
            textColor: Theme.primaryTextColor,
        )

        let familyNameItem = OWSTableItem.textFieldItem(
            self.familyNameTextField,
            textColor: Theme.primaryTextColor,
        )

        if NSLocale.current.isCJKV {
            namesSectionItems = [familyNameItem, givenNameItem]
        } else {
            namesSectionItems = [givenNameItem, familyNameItem]
        }

        let namesSection = OWSTableSection(
            items: namesSectionItems,
            headerView: self.avatarViewContainer,
        )
        contents.add(namesSection)

        contents.add(.init(items: [
            self.textViewItem(self.noteTextView),
        ]))

        if self.initialNicknameRecord != nil {
            contents.add(.init(items: [
                .item(
                    name: CommonStrings.deleteButton,
                    textColor: .ows_accentRed,
                    actionBlock: { [weak self] in
                        self?.showDeleteNicknameConfirmation()
                    },
                ),
            ]))
        }

        self.contents = contents
    }

    // MARK: Handlers

    private func saveChanges() {
        guard self.canSaveChanges else {
            owsFail("Shouldn't be able to save")
        }

        let nickname = self.enteredNickname

        if nickname == nil, self.note == nil {
            // All fields are empty
            deleteNickname()
            return
        }

        let nicknameRecord = NicknameRecord(
            recipient: self.recipient,
            givenName: nickname?.givenName,
            familyName: nickname?.familyName,
            note: self.note,
        )

        self.context.db.write { tx in
            // A Storage Service sync might have happened while this was open,
            // so we can't make assumptions about whether to create or update
            // based only on `self.initialNicknameRecord`.
            context.nicknameManager.createOrUpdate(
                nicknameRecord: nicknameRecord,
                updateStorageServiceFor: self.recipient.uniqueId,
                tx: tx,
            )
        }

        self.dismiss(animated: true)
    }

    private func showDeleteNicknameConfirmation() {
        OWSActionSheets.showConfirmationAlert(
            title: OWSLocalizedString(
                "NICKNAME_EDITOR_DELETE_CONFIRMATION_TITLE",
                comment: "The title for a prompt confirming that the user wants to delete the nickname and note.",
            ),
            message: OWSLocalizedString(
                "NICKNAME_EDITOR_DELETE_CONFIRMATION_MESSAGE",
                comment: "The message for a prompt confirming that the user wants to delete the nickname and note.",
            ),
            proceedTitle: CommonStrings.deleteButton,
            proceedStyle: .destructive,
            proceedAction: { [weak self] _ in
                self?.deleteNickname()
            },
            fromViewController: self,
        )
    }

    private func deleteNickname() {
        self.context.db.write { tx in
            self.context.nicknameManager.deleteNickname(
                recipientRowID: self.recipient.id,
                updateStorageServiceFor: self.recipient.uniqueId,
                tx: tx,
            )
        }

        self.dismiss(animated: true)
    }
}

extension NicknameEditorViewController: UITextFieldDelegate {
    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        TextFieldHelper.textField(
            textField,
            shouldChangeCharactersInRange: range,
            replacementString: string,
            maxByteCount: OWSUserProfile.Constants.maxNameLengthBytes,
            maxGlyphCount: OWSUserProfile.Constants.maxNameLengthGlyphs,
        )
    }
}

// MARK: - TextViewWithPlaceholderDelegate

extension NicknameEditorViewController: TextViewWithPlaceholderDelegate {
    func textViewDidUpdateText(_ textView: TextViewWithPlaceholder) {
        self.editingChanged()
        _textViewDidUpdateText(textView)
    }

    func textView(_ textView: TextViewWithPlaceholder, uiTextView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        TextViewHelper.textView(
            textView,
            shouldChangeTextIn: range,
            replacementText: text,
            maxByteCount: Self.maxNoteLengthBytes,
            maxGlyphCount: Self.maxNoteLengthGlyphs,
        )
    }
}