diff --git a/Localization/app.json b/Localization/app.json index c40c0a39e..30566d8d6 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -448,6 +448,10 @@ "placeholder": { "label": "Label", "content": "Content" + }, + "verified": { + "short": "Verified on %s", + "long": "Ownership of this link was checked on %s" } }, "segmented_control": { diff --git a/Mastodon/Diffable/Profile/ProfileFieldItem.swift b/Mastodon/Diffable/Profile/ProfileFieldItem.swift index 47848cc01..e33a2f883 100644 --- a/Mastodon/Diffable/Profile/ProfileFieldItem.swift +++ b/Mastodon/Diffable/Profile/ProfileFieldItem.swift @@ -23,6 +23,7 @@ extension ProfileFieldItem { var name: CurrentValueSubject var value: CurrentValueSubject + var verifiedAt: CurrentValueSubject let emojiMeta: MastodonContent.Emojis @@ -30,11 +31,13 @@ extension ProfileFieldItem { id: UUID = UUID(), name: String, value: String, + verifiedAt: Date?, emojiMeta: MastodonContent.Emojis ) { self.id = id self.name = CurrentValueSubject(name) self.value = CurrentValueSubject(value) + self.verifiedAt = CurrentValueSubject(verifiedAt) self.emojiMeta = emojiMeta } @@ -45,6 +48,7 @@ extension ProfileFieldItem { return lhs.id == rhs.id && lhs.name.value == rhs.name.value && lhs.value.value == rhs.value.value + && lhs.verifiedAt.value == rhs.verifiedAt.value && lhs.emojiMeta == rhs.emojiMeta } diff --git a/Mastodon/Diffable/Profile/ProfileFieldSection.swift b/Mastodon/Diffable/Profile/ProfileFieldSection.swift index 19771b5db..6e57e6af9 100644 --- a/Mastodon/Diffable/Profile/ProfileFieldSection.swift +++ b/Mastodon/Diffable/Profile/ProfileFieldSection.swift @@ -8,6 +8,7 @@ import os import UIKit import Combine +import MastodonAsset import MastodonCore import MastodonMeta import MastodonLocalization @@ -48,6 +49,10 @@ extension ProfileFieldSection { do { let mastodonContent = MastodonContent(content: field.value.value, emojis: field.emojiMeta) let metaContent = try MastodonMetaContent.convert(document: mastodonContent) + cell.valueMetaLabel.linkAttributes[.foregroundColor] = Asset.Colors.brand.color + if field.verifiedAt.value != nil { + cell.valueMetaLabel.linkAttributes[.foregroundColor] = Asset.Scene.Profile.About.bioAboutFieldVerifiedLink.color + } cell.valueMetaLabel.configure(content: metaContent) } catch { let content = PlaintextMetaContent(string: field.value.value) @@ -57,7 +62,23 @@ extension ProfileFieldSection { // set background var backgroundConfiguration = UIBackgroundConfiguration.listPlainCell() backgroundConfiguration.backgroundColor = UIColor.secondarySystemBackground + if (field.verifiedAt.value != nil) { + backgroundConfiguration.backgroundColor = Asset.Scene.Profile.About.bioAboutFieldVerifiedBackground.color + } cell.backgroundConfiguration = backgroundConfiguration + + // set checkmark and edit menu label + cell.checkmark.isHidden = true + cell.checkmarkPopoverString = nil + if let verifiedAt = field.verifiedAt.value { + cell.checkmark.isHidden = false + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + let dateString = formatter.string(from: verifiedAt) + cell.checkmark.accessibilityLabel = L10n.Scene.Profile.Fields.Verified.long(dateString) + cell.checkmarkPopoverString = L10n.Scene.Profile.Fields.Verified.short(dateString) + } cell.delegate = configuration.profileFieldCollectionViewCellDelegate } diff --git a/Mastodon/Scene/Profile/About/Cell/ProfileFieldCollectionViewCell.swift b/Mastodon/Scene/Profile/About/Cell/ProfileFieldCollectionViewCell.swift index ed6f68fec..1ed76a485 100644 --- a/Mastodon/Scene/Profile/About/Cell/ProfileFieldCollectionViewCell.swift +++ b/Mastodon/Scene/Profile/About/Cell/ProfileFieldCollectionViewCell.swift @@ -26,6 +26,16 @@ final class ProfileFieldCollectionViewCell: UICollectionViewCell { let keyMetaLabel = MetaLabel(style: .profileFieldName) let valueMetaLabel = MetaLabel(style: .profileFieldValue) + let checkmark = UIImageView(image: Asset.Editing.checkmark.image.withRenderingMode(.alwaysTemplate)) + var checkmarkPopoverString: String? = nil; + let tapGesture = UITapGestureRecognizer(); + private var _editMenuInteraction: Any? = nil + @available(iOS 16, *) + fileprivate var editMenuInteraction: UIEditMenuInteraction { + _editMenuInteraction = _editMenuInteraction ?? UIEditMenuInteraction(delegate: self) + return _editMenuInteraction as! UIEditMenuInteraction + } + override func prepareForReuse() { super.prepareForReuse() @@ -47,6 +57,17 @@ final class ProfileFieldCollectionViewCell: UICollectionViewCell { extension ProfileFieldCollectionViewCell { private func _init() { + // Setup colors + checkmark.tintColor = Asset.Scene.Profile.About.bioAboutFieldVerifiedCheckmark.color; + + // Setup gestures + tapGesture.addTarget(self, action: #selector(ProfileFieldCollectionViewCell.didTapCheckmark(_:))) + checkmark.addGestureRecognizer(tapGesture) + checkmark.isUserInteractionEnabled = true + if #available(iOS 16, *) { + checkmark.addInteraction(editMenuInteraction) + } + // containerStackView: V - [ metaContainer | plainContainer ] let containerStackView = UIStackView() containerStackView.axis = .vertical @@ -63,19 +84,62 @@ extension ProfileFieldCollectionViewCell { bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor, constant: 11), ]) - // metaContainer: V - [ keyMetaLabel | valueMetaLabel ] + // metaContainer: V - [ keyMetaLabel | valueContainer ] let metaContainer = UIStackView() metaContainer.axis = .vertical metaContainer.spacing = 2 containerStackView.addArrangedSubview(metaContainer) + // valueContainer: H - [ valueMetaLabel | checkmark ] + let valueContainer = UIStackView() + valueContainer.axis = .horizontal + valueContainer.spacing = 2 + metaContainer.addArrangedSubview(keyMetaLabel) - metaContainer.addArrangedSubview(valueMetaLabel) + valueContainer.addArrangedSubview(valueMetaLabel) + valueContainer.addArrangedSubview(checkmark) + metaContainer.addArrangedSubview(valueContainer) keyMetaLabel.linkDelegate = self valueMetaLabel.linkDelegate = self } + @objc public func didTapCheckmark(_ recognizer: UITapGestureRecognizer) { + if #available(iOS 16, *) { + editMenuInteraction.presentEditMenu(with: UIEditMenuConfiguration(identifier: nil, sourcePoint: recognizer.location(in: checkmark))) + } else { + guard let editMenuLabel = checkmarkPopoverString else { return } + + self.isUserInteractionEnabled = true + self.becomeFirstResponder() + + UIMenuController.shared.menuItems = [ + UIMenuItem( + title: editMenuLabel, + action: #selector(dismissVerifiedMenu) + ) + ] + UIMenuController.shared.showMenu(from: checkmark, rect: checkmark.bounds) + } + } +} + +// UIMenuController boilerplate +@available(iOS, deprecated: 16, message: "Can be removed when target version is >=16 -- boilerplate to maintain compatibility with UIMenuController") +extension ProfileFieldCollectionViewCell { + override var canBecomeFirstResponder: Bool { true } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(dismissVerifiedMenu) { + return true + } + + return super.canPerformAction(action, withSender: sender) + } + + @objc public func dismissVerifiedMenu() { + UIMenuController.shared.hideMenu() + } } // MARK: - MetaLabelDelegate @@ -85,3 +149,16 @@ extension ProfileFieldCollectionViewCell: MetaLabelDelegate { delegate?.profileFieldCollectionViewCell(self, metaLebel: metaLabel, didSelectMeta: meta) } } + +// MARK: UIEditMenuInteractionDelegate +@available(iOS 16.0, *) +extension ProfileFieldCollectionViewCell: UIEditMenuInteractionDelegate { + func editMenuInteraction(_ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration, suggestedActions: [UIMenuElement]) -> UIMenu? { + guard let editMenuLabel = checkmarkPopoverString else { return UIMenu(children: []) } + return UIMenu(children: [UIAction(title: editMenuLabel) { _ in return }]) + } + + func editMenuInteraction(_ interaction: UIEditMenuInteraction, targetRectFor configuration: UIEditMenuConfiguration) -> CGRect { + return checkmark.frame + } +} diff --git a/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift b/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift index 68a3d0fea..044894b8a 100644 --- a/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift +++ b/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift @@ -52,7 +52,7 @@ final class ProfileAboutViewModel { $emojiMeta ) .map { fields, emojiMeta in - fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value, emojiMeta: emojiMeta) } + fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value, verifiedAt: $0.verifiedAt, emojiMeta: emojiMeta) } } .assign(to: &profileInfo.$fields) @@ -72,6 +72,7 @@ final class ProfileAboutViewModel { ProfileFieldItem.FieldValue( name: field.name, value: field.value, + verifiedAt: field.verifiedAt, emojiMeta: [:] // no use for editing ) } ?? [] @@ -92,7 +93,7 @@ extension ProfileAboutViewModel { func appendFieldItem() { var fields = profileInfoEditing.fields guard fields.count < ProfileHeaderViewModel.maxProfileFieldCount else { return } - fields.append(ProfileFieldItem.FieldValue(name: "", value: "", emojiMeta: [:])) + fields.append(ProfileFieldItem.FieldValue(name: "", value: "", verifiedAt: nil, emojiMeta: [:])) profileInfoEditing.fields = fields } @@ -112,7 +113,7 @@ extension ProfileAboutViewModel: ProfileViewModelEditable { let isFieldsEqual: Bool = { let originalFields = self.accountForEdit?.source?.fields?.compactMap { field in - ProfileFieldItem.FieldValue(name: field.name, value: field.value, emojiMeta: [:]) + ProfileFieldItem.FieldValue(name: field.name, value: field.value, verifiedAt: nil, emojiMeta: [:]) } ?? [] let editFields = profileInfoEditing.fields guard editFields.count == originalFields.count else { return false } diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/About/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/About/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/About/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/About/bio.about.field.verified.background.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/About/bio.about.field.verified.background.colorset/Contents.json new file mode 100644 index 000000000..86944ced3 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/About/bio.about.field.verified.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.852", + "green" : "0.894", + "red" : "0.835" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.354", + "green" : "0.353", + "red" : "0.268" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/About/bio.about.field.verified.checkmark.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/About/bio.about.field.verified.checkmark.colorset/Contents.json new file mode 100644 index 000000000..f5112f04f --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/About/bio.about.field.verified.checkmark.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.371", + "green" : "0.565", + "red" : "0.290" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.603", + "green" : "0.742", + "red" : "0.476" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/About/bio.about.field.verified.link.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/About/bio.about.field.verified.link.colorset/Contents.json new file mode 100644 index 000000000..f5112f04f --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Profile/About/bio.about.field.verified.link.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.371", + "green" : "0.565", + "red" : "0.290" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.603", + "green" : "0.742", + "red" : "0.476" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift index 53d81edb6..25dcc168e 100644 --- a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift +++ b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift @@ -171,6 +171,11 @@ public enum Asset { public static let textFieldBackground = ColorAsset(name: "Scene/Onboarding/textField.background") } public enum Profile { + public enum About { + public static let bioAboutFieldVerifiedBackground = ColorAsset(name: "Scene/Profile/About/bio.about.field.verified.background") + public static let bioAboutFieldVerifiedCheckmark = ColorAsset(name: "Scene/Profile/About/bio.about.field.verified.checkmark") + public static let bioAboutFieldVerifiedLink = ColorAsset(name: "Scene/Profile/About/bio.about.field.verified.link") + } public enum Banner { public static let bioEditBackgroundGray = ColorAsset(name: "Scene/Profile/Banner/bio.edit.background.gray") public static let nameEditBackgroundGray = ColorAsset(name: "Scene/Profile/Banner/name.edit.background.gray") diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index f3d69950e..65a97c615 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -741,6 +741,16 @@ public enum L10n { /// Label public static let label = L10n.tr("Localizable", "Scene.Profile.Fields.Placeholder.Label", fallback: "Label") } + public enum Verified { + /// Ownership of this link was checked on %s + public static func long(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "Scene.Profile.Fields.Verified.Long", p1) + } + /// Verified at %s + public static func short(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "Scene.Profile.Fields.Verified.Short", p1) + } + } } public enum Header { /// Follows You diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings index 07ccd2c1b..e269a45a7 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/en.lproj/Localizable.strings @@ -263,6 +263,8 @@ uploaded to Mastodon."; "Scene.Profile.Fields.AddRow" = "Add Row"; "Scene.Profile.Fields.Placeholder.Content" = "Content"; "Scene.Profile.Fields.Placeholder.Label" = "Label"; +"Scene.Profile.Fields.Verified.Short" = "Verified at %s"; +"Scene.Profile.Fields.Verified.Long" = "Ownership of this link was checked on %s"; "Scene.Profile.Header.FollowsYou" = "Follows You"; "Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Message" = "Confirm to block %@"; "Scene.Profile.RelationshipActionAlert.ConfirmBlockUser.Title" = "Block Account"; @@ -454,4 +456,4 @@ uploaded to Mastodon."; back in your hands."; "Scene.Wizard.AccessibilityHint" = "Double tap to dismiss this wizard"; "Scene.Wizard.MultipleAccountSwitchIntroDescription" = "Switch between multiple accounts by holding the profile button."; -"Scene.Wizard.NewInMastodon" = "New in Mastodon"; \ No newline at end of file +"Scene.Wizard.NewInMastodon" = "New in Mastodon";