// // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import SignalUI class AppSettingsViewController: OWSTableViewController2 { private let appReadiness: AppReadinessSetter init(appReadiness: AppReadinessSetter) { self.appReadiness = appReadiness super.init() } class func inModalNavigationController(appReadiness: AppReadinessSetter) -> OWSNavigationController { OWSNavigationController(rootViewController: AppSettingsViewController(appReadiness: appReadiness)) } private var localUsernameState: Usernames.LocalUsernameState! override func viewDidLoad() { super.viewDidLoad() SSKEnvironment.shared.databaseStorageRef.read { tx in localUsernameState = DependenciesBridge.shared.localUsernameManager .usernameState(tx: tx.asV2Read) } title = OWSLocalizedString("SETTINGS_NAV_BAR_TITLE", comment: "Title for settings activity") navigationItem.leftBarButtonItem = .doneButton(dismissingFrom: self) defaultSeparatorInsetLeading = Self.cellHInnerMargin + 24 + OWSTableItem.iconSpacing updateHasExpiredGiftBadge() updateTableContents() let tsAccountManager = DependenciesBridge.shared.tsAccountManager if let localAci = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci { Task { let profileFetcher = SSKEnvironment.shared.profileFetcherRef _ = try? await profileFetcher.fetchProfile(for: localAci, options: [.opportunistic]) } } NotificationCenter.default.addObserver( self, selector: #selector(localProfileDidChange), name: UserProfileNotifications.localProfileDidChange, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(localNumberDidChange), name: .localNumberDidChange, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(subscriptionStateDidChange), name: DonationReceiptCredentialRedemptionJob.didSucceedNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(hasExpiredGiftBadgeDidChange), name: .hasExpiredGiftBadgeDidChangeNotification, object: nil ) } @objc private func localProfileDidChange() { AssertIsOnMainThread() updateTableContents() } @objc private func localNumberDidChange() { AssertIsOnMainThread() updateTableContents() } @objc private func subscriptionStateDidChange() { AssertIsOnMainThread() updateTableContents() } private var hasExpiredGiftBadge: Bool = false private func updateHasExpiredGiftBadge() { self.hasExpiredGiftBadge = DonationSettingsViewController.shouldShowExpiredGiftBadgeSheetWithSneakyTransaction() } @objc private func hasExpiredGiftBadgeDidChange() { AssertIsOnMainThread() let oldValue = self.hasExpiredGiftBadge self.updateHasExpiredGiftBadge() if oldValue != self.hasExpiredGiftBadge { self.updateTableContents() } } override func themeDidChange() { super.themeDidChange() updateTableContents() } func updateTableContents() { let contents = OWSTableContents() let profileSection = OWSTableSection(items: [ OWSTableItem( customCellBlock: { [weak self] in guard let self = self else { return UITableViewCell() } return self.profileCell() }, actionBlock: { [weak self] in guard let self else { return } let vc = ProfileSettingsViewController( usernameChangeDelegate: self, usernameLinkScanDelegate: self ) self.navigationController?.pushViewController(vc, animated: true) } ) ]) contents.add(profileSection) let section1 = OWSTableSection() section1.add(.disclosureItem( icon: .settingsAccount, withText: OWSLocalizedString("SETTINGS_ACCOUNT", comment: "Title for the 'account' link in settings."), actionBlock: { [weak self, appReadiness] in let vc = AccountSettingsViewController(appReadiness: appReadiness) self?.navigationController?.pushViewController(vc, animated: true) } )) if DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isPrimaryDevice == true { section1.add(.disclosureItem( icon: .settingsLinkedDevices, withText: OWSLocalizedString("LINKED_DEVICES_TITLE", comment: "Menu item and navbar title for the device manager"), actionBlock: { [weak self] in let vc = if FeatureFlags.linkedDevicesRedesign { LinkedDevicesHostingController() } else { LinkedDevicesTableViewController() } self?.navigationController?.pushViewController(vc, animated: true) } )) } section1.add(.init(customCellBlock: { [weak self] in guard let self = self else { return UITableViewCell() } let accessoryContentView: UIView? if self.hasExpiredGiftBadge { let imageView = UIImageView(image: UIImage(imageLiteralResourceName: "info-fill")) imageView.tintColor = Theme.accentBlueColor imageView.autoSetDimensions(to: CGSize(square: 24)) accessoryContentView = imageView } else { accessoryContentView = nil } return OWSTableItem.buildCell( icon: .settingsDonate, itemName: OWSLocalizedString("SETTINGS_DONATE", comment: "Title for the 'donate to signal' link in settings."), accessoryType: .disclosureIndicator, accessoryContentView: accessoryContentView, accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "donate") ) }, actionBlock: { [weak self] in self?.didTapDonate() })) contents.add(section1) let section2 = OWSTableSection() section2.add(.disclosureItem( icon: .settingsAppearance, withText: OWSLocalizedString("SETTINGS_APPEARANCE_TITLE", comment: "The title for the appearance settings."), actionBlock: { [weak self] in let vc = AppearanceSettingsTableViewController() self?.navigationController?.pushViewController(vc, animated: true) } )) section2.add(.disclosureItem( icon: .settingsChats, withText: OWSLocalizedString("SETTINGS_CHATS", comment: "Title for the 'chats' link in settings."), actionBlock: { [weak self] in let vc = ChatsSettingsViewController() self?.navigationController?.pushViewController(vc, animated: true) } )) section2.add(.disclosureItem( icon: .settingsStories, withText: OWSLocalizedString( "STORY_SETTINGS_TITLE", comment: "Label for the stories section of the settings view" ), actionBlock: { [weak self] in let vc = StoryPrivacySettingsViewController() self?.navigationController?.pushViewController(vc, animated: true) } )) section2.add(.disclosureItem( icon: .settingsNotifications, withText: OWSLocalizedString("SETTINGS_NOTIFICATIONS", comment: "The title for the notification settings."), actionBlock: { [weak self] in let vc = NotificationSettingsViewController() self?.navigationController?.pushViewController(vc, animated: true) } )) section2.add(.disclosureItem( icon: .settingsPrivacy, withText: OWSLocalizedString("SETTINGS_PRIVACY_TITLE", comment: "The title for the privacy settings."), actionBlock: { [weak self] in let vc = PrivacySettingsViewController() self?.navigationController?.pushViewController(vc, animated: true) } )) section2.add(.disclosureItem( icon: .settingsDataUsage, withText: OWSLocalizedString("SETTINGS_DATA", comment: "Label for the 'data' section of the app settings."), actionBlock: { [weak self] in let vc = DataSettingsTableViewController() self?.navigationController?.pushViewController(vc, animated: true) } )) contents.add(section2) if SUIEnvironment.shared.paymentsRef.shouldShowPaymentsUI { let paymentsSection = OWSTableSection() paymentsSection.add(.init( customCellBlock: { let cell = OWSTableItem.newCell() cell.preservesSuperviewLayoutMargins = true cell.contentView.preservesSuperviewLayoutMargins = true var subviews = [UIView]() let iconView = OWSTableItem.imageView(forIcon: .settingsPayments, tintColor: nil, iconSize: OWSTableItem.iconSize) iconView.setCompressionResistanceHorizontalHigh() subviews.append(iconView) subviews.append(UIView.spacer(withWidth: OWSTableItem.iconSpacing)) let nameLabel = UILabel() nameLabel.text = OWSLocalizedString("SETTINGS_PAYMENTS_TITLE", comment: "Label for the 'payments' section of the app settings.") nameLabel.textColor = Theme.primaryTextColor nameLabel.font = OWSTableItem.primaryLabelFont nameLabel.adjustsFontForContentSizeCategory = true nameLabel.numberOfLines = 0 nameLabel.lineBreakMode = .byWordWrapping nameLabel.setContentHuggingLow() nameLabel.setCompressionResistanceHigh() subviews.append(nameLabel) subviews.append(UIView.hStretchingSpacer()) let unreadPaymentsCount = SSKEnvironment.shared.databaseStorageRef.read { transaction in PaymentFinder.unreadCount(transaction: transaction) } if unreadPaymentsCount > 0 { let unreadLabel = UILabel() unreadLabel.text = OWSFormat.formatUInt(min(9, unreadPaymentsCount)) unreadLabel.font = .dynamicTypeBody2Clamped unreadLabel.textColor = .ows_white let unreadBadge = OWSLayerView.circleView() unreadBadge.backgroundColor = .ows_accentBlue unreadBadge.addSubview(unreadLabel) unreadLabel.autoCenterInSuperview() unreadLabel.autoPinEdge(toSuperviewEdge: .top, withInset: 3) unreadLabel.autoPinEdge(toSuperviewEdge: .bottom, withInset: 3) unreadBadge.autoPinToSquareAspectRatio() unreadBadge.setContentHuggingHorizontalHigh() unreadBadge.setCompressionResistanceHorizontalHigh() subviews.append(unreadBadge) } let contentRow = UIStackView(arrangedSubviews: subviews) contentRow.alignment = .center cell.contentView.addSubview(contentRow) contentRow.setContentHuggingHigh() contentRow.autoPinEdgesToSuperviewMargins() contentRow.autoSetDimension(.height, toSize: OWSTableItem.iconSize, relation: .greaterThanOrEqual) cell.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "payments") cell.accessoryType = .disclosureIndicator return cell }, actionBlock: { [weak self, appReadiness] in let vc = PaymentsSettingsViewController(mode: .inAppSettings, appReadiness: appReadiness) self?.navigationController?.pushViewController(vc, animated: true) } )) contents.add(paymentsSection) } let section3 = OWSTableSection() section3.add(.disclosureItem( icon: .settingsHelp, withText: CommonStrings.help, actionBlock: { [weak self] in let vc = HelpViewController() self?.navigationController?.pushViewController(vc, animated: true) } )) section3.add(.item( icon: .settingsInvite, name: OWSLocalizedString("SETTINGS_INVITE_TITLE", comment: "Settings table view cell label"), actionBlock: { [weak self] in self?.showInviteFlow() } )) contents.add(section3) if DebugFlags.internalSettings { let internalSection = OWSTableSection() internalSection.add(.disclosureItem( icon: .settingsAdvanced, withText: "Internal", actionBlock: { [weak self, appReadiness] in let vc = InternalSettingsViewController(appReadiness: appReadiness) self?.navigationController?.pushViewController(vc, animated: true) } )) contents.add(internalSection) } self.contents = contents } private var inviteFlow: InviteFlow? private func showInviteFlow() { let inviteFlow = InviteFlow(presentingViewController: self) self.inviteFlow = inviteFlow inviteFlow.present(isAnimated: true, completion: nil) } private func profileCell() -> UITableViewCell { let cell = OWSTableItem.newCell() let avatarImageView = profileCellAvatarImageView() let infoStack = profileCellProfileInfoStack() cell.contentView.addSubview(avatarImageView) cell.contentView.addSubview(infoStack) avatarImageView.autoPinLeadingToSuperviewMargin() avatarImageView.autoPinHeightToSuperviewMargins(relation: .lessThanOrEqual) avatarImageView.autoVCenterInSuperview() avatarImageView.autoPinTrailing(toLeadingEdgeOf: infoStack, offset: 12) infoStack.autoPinHeightToSuperviewMargins(relation: .lessThanOrEqual) infoStack.autoVCenterInSuperview() infoStack.autoPinTrailingToSuperviewMargin() if let usernameLinkButton = profileCellUsernameLinkButton() { cell.accessoryView = usernameLinkButton } else { cell.accessoryType = .disclosureIndicator } return cell } private func profileCellAvatarImageView() -> UIView { let avatarImageView = ConversationAvatarView( sizeClass: .customDiameter(72), localUserDisplayMode: .asUser ) if let localAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aciAddress { avatarImageView.updateWithSneakyTransactionIfNecessary { config in config.dataSource = .address(localAddress) } } return avatarImageView } /// A view presenting quick info about the user's profile. private func profileCellProfileInfoStack() -> UIView { let snapshot = SSKEnvironment.shared.profileManagerImplRef.localProfileSnapshot(shouldIncludeAvatar: false) let profileInfoStack = UIStackView() profileInfoStack.axis = .vertical profileInfoStack.spacing = 0 let nameLabel = UILabel() profileInfoStack.addArrangedSubview(nameLabel) nameLabel.font = UIFont.dynamicTypeTitle2Clamped.medium() if let fullName = snapshot.fullName, !fullName.isEmpty { nameLabel.text = fullName nameLabel.textColor = Theme.primaryTextColor } else { nameLabel.text = OWSLocalizedString( "APP_SETTINGS_EDIT_PROFILE_NAME_PROMPT", comment: "Text prompting user to edit their profile name." ) nameLabel.textColor = Theme.accentBlueColor } @discardableResult func addSubtitleLabel( text: String, textColor: UIColor ) -> UIView? { guard !text.isEmpty else { return nil } let label = UILabel() label.font = .dynamicTypeFootnoteClamped label.text = text label.textColor = textColor let containerView = UIView() containerView.layoutMargins = UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0) containerView.addSubview(label) label.autoPinEdgesToSuperviewMargins() profileInfoStack.addArrangedSubview(containerView) return containerView } if let phoneNumber = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.phoneNumber { addSubtitleLabel( text: PhoneNumber.bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber(phoneNumber), textColor: Theme.primaryTextColor ) } else { owsFailDebug("Missing local number") } if let localUsernameState { switch localUsernameState { case let .available(username, _): addSubtitleLabel( text: username, textColor: Theme.primaryTextColor ) case .unset, .usernameAndLinkCorrupted, .linkCorrupted: break } } if let bioText = OWSUserProfile.bioForDisplay(bio: snapshot.bio, bioEmoji: snapshot.bioEmoji) { let bioLabel = addSubtitleLabel( text: bioText, textColor: Theme.secondaryTextAndIconColor ) bioLabel?.layoutMargins.top = 8 } profileInfoStack.arrangedSubviews.last?.layoutMargins.bottom = 2 return profileInfoStack } /// If we have a username, produces a button that takes the user to their /// username link QR code. /// /// Note that this button does not use autolayout, so as to play nice with /// ``UITableViewCell``'s accessory view. private func profileCellUsernameLinkButton() -> UIButton? { let localUsername: String let localUsernameLink: Usernames.UsernameLink switch localUsernameState { case nil, .unset, .usernameAndLinkCorrupted, .linkCorrupted: return nil case let .available(username, usernameLink): localUsername = username localUsernameLink = usernameLink } let usernameLinkButton = OWSRoundedButton { [weak self] in guard let self else { return } let usernameLinkController = UsernameLinkQRCodeContentController( db: DependenciesBridge.shared.db, localUsernameManager: DependenciesBridge.shared.localUsernameManager, schedulers: DependenciesBridge.shared.schedulers, username: localUsername, usernameLink: localUsernameLink, changeDelegate: self, scanDelegate: self ) let navController = OWSNavigationController(rootViewController: usernameLinkController) self.present(navController, animated: true) } if Theme.isDarkThemeEnabled { usernameLinkButton.backgroundColor = .ows_gray65 usernameLinkButton.setTemplateImage(Theme.iconImage(.qrCode), tintColor: .ows_gray15) } else { usernameLinkButton.backgroundColor = .ows_gray05 usernameLinkButton.setImage(Theme.iconImage(.qrCode), for: .normal) } usernameLinkButton.bounds = CGRect(origin: .zero, size: .square(36)) usernameLinkButton.imageView?.autoSetDimensions(to: .square(20)) return usernameLinkButton } private func didTapDonate() { navigationController?.pushViewController( DonationSettingsViewController(), animated: true ) } } extension AppSettingsViewController: UsernameChangeDelegate { func usernameStateDidChange(newState: Usernames.LocalUsernameState) { localUsernameState = newState updateTableContents() } } extension AppSettingsViewController: UsernameLinkScanDelegate { func usernameLinkScanned(_ usernameLink: Usernames.UsernameLink) { guard let presentingViewController else { owsFailDebug("Missing presenting view controller!") return } presentingViewController.dismiss(animated: true) { SSKEnvironment.shared.databaseStorageRef.read { tx in UsernameQuerier().queryForUsernameLink( link: usernameLink, fromViewController: presentingViewController, tx: tx ) { _, aci in SignalApp.shared.presentConversationForAddress( SignalServiceAddress(aci), animated: true ) } } } } }