Skip to content

Commit 41b8062

Browse files
authored
Integrate Gravatar Quick Editor (#23729)
* Add remote FF gravatar_quick_editor * Add forceRefresh option for image download methods * Force refresh on gravatar update notification * Add `GravatarQuickEditorPresenter` * Display QuickEditor instead of the avatar upload menu * Move listenForGravatarChanges to upper level Add forceRefresh option * Add one more FF check * Adjust the obj-c call * Listen to changes on the MeHeaderView * Await the image download to update the cached image * Listen to gravatar changes on the signup epilogue * Add one more FF check * Separate the notification name for the QE Add email check when handling the notification `GravatarQEAvatarUpdateNotification` * Add a release note * Revert whitespace change * Use overrideImageCache` * Fix the old avatar issue * Update unit test * Move `addObserver` to viewDidLoad * Mode addObserver to init` * Add unit tests for `appendingGravatarCacheBusterParam` and handle the canonical URL as well * Fix "unused var" warning * Update release notes * Revert "Update release notes" This reverts commit f0b3eb9.
1 parent ba5f4af commit 41b8062

23 files changed

+329
-48
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import Foundation
2+
3+
public enum GravatarQEAvatarUpdateNotificationKeys: String {
4+
case email
5+
}
6+
7+
public extension NSNotification.Name {
8+
/// Gravatar Quick Editor updated the avatar
9+
static let GravatarQEAvatarUpdateNotification = NSNotification.Name(rawValue: "GravatarQEAvatarUpdateNotification")
10+
}
11+
12+
extension Foundation.Notification {
13+
public func userInfoHasEmail(_ email: String) -> Bool {
14+
guard let userInfo = userInfo,
15+
let notificationEmail = userInfo[GravatarQEAvatarUpdateNotificationKeys.email.rawValue] as? String else {
16+
return false
17+
}
18+
return email == notificationEmail
19+
}
20+
}

RELEASE-NOTES.txt

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
25.6
22
-----
33
* [*] [internal] Update Gravatar SDK to 3.0.0 [#23701]
4+
* [*] Use the Gravatar Quick Editor to update the avatar [#23729]
45
* [*] (Hidden under a feature flag) User Management for self-hosted sites. [#23768]
56

67
25.5

WordPress/Classes/Extensions/URL+Helpers.swift

+11
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,15 @@ extension URL {
151151
components?.queryItems = queryItems
152152
return components?.url ?? self
153153
}
154+
155+
/// Gravatar doesn't support "Cache-Control: none" header. So we add a random query parameter to
156+
/// bypass the backend cache and get the latest image.
157+
public func appendingGravatarCacheBusterParam() -> URL {
158+
var urlComponents = URLComponents(url: self, resolvingAgainstBaseURL: false)
159+
if urlComponents?.queryItems == nil {
160+
urlComponents?.queryItems = []
161+
}
162+
urlComponents?.queryItems?.append(.init(name: "_", value: "\(NSDate().timeIntervalSince1970)"))
163+
return urlComponents?.url ?? self
164+
}
154165
}

WordPress/Classes/Utility/BuildInformation/RemoteFeatureFlag.swift

+7
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ enum RemoteFeatureFlag: Int, CaseIterable {
2727
case inAppRating
2828
case siteMonitoring
2929
case inAppUpdates
30+
case gravatarQuickEditor
3031
case dotComWebLogin
3132

3233
var defaultValue: Bool {
@@ -81,6 +82,8 @@ enum RemoteFeatureFlag: Int, CaseIterable {
8182
return false
8283
case .inAppUpdates:
8384
return false
85+
case .gravatarQuickEditor:
86+
return BuildConfiguration.current ~= [.localDeveloper, .a8cBranchTest, .a8cPrereleaseTesting]
8487
case .dotComWebLogin:
8588
return false
8689
}
@@ -139,6 +142,8 @@ enum RemoteFeatureFlag: Int, CaseIterable {
139142
return "site_monitoring"
140143
case .inAppUpdates:
141144
return "in_app_updates"
145+
case .gravatarQuickEditor:
146+
return "gravatar_quick_editor"
142147
case .dotComWebLogin:
143148
return "jp_wpcom_web_login"
144149
}
@@ -196,6 +201,8 @@ enum RemoteFeatureFlag: Int, CaseIterable {
196201
return "Site Monitoring"
197202
case .inAppUpdates:
198203
return "In-App Updates"
204+
case .gravatarQuickEditor:
205+
return "Gravatar Quick Editor"
199206
case .dotComWebLogin:
200207
return "Log in to WordPress.com from web browser"
201208
}

WordPress/Classes/Utility/Media/ImageDownloader+Gravatar.swift

+7-4
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,22 @@ import Gravatar
44

55
extension ImageDownloader {
66

7-
nonisolated func downloadGravatarImage(with email: String, completion: @escaping (UIImage?) -> Void) {
7+
nonisolated func downloadGravatarImage(with email: String, forceRefresh: Bool = false, completion: @escaping (UIImage?) -> Void) {
88

99
guard let url = AvatarURL.url(for: email) else {
1010
completion(nil)
1111
return
1212
}
1313

14-
if let cachedImage = ImageCache.shared.getImage(forKey: url.absoluteString) {
14+
if !forceRefresh, let cachedImage = ImageCache.shared.getImage(forKey: url.absoluteString) {
1515
completion(cachedImage)
1616
return
1717
}
18-
19-
downloadImage(at: url) { image, _ in
18+
var urlToDownload = url
19+
if forceRefresh {
20+
urlToDownload = url.appendingGravatarCacheBusterParam()
21+
}
22+
downloadImage(at: urlToDownload) { image, _ in
2023
DispatchQueue.main.async {
2124

2225
guard let image else {

WordPress/Classes/Utility/Media/ImageDownloader.swift

+9
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,15 @@ actor ImageDownloader {
102102
return imageURL.absoluteString + (size.map { "?size=\($0)" } ?? "")
103103
}
104104

105+
func clearURLSessionCache() {
106+
urlSessionWithCache.configuration.urlCache?.removeAllCachedResponses()
107+
urlSession.configuration.urlCache?.removeAllCachedResponses()
108+
}
109+
110+
func clearMemoryCache() {
111+
self.cache.removeAllObjects()
112+
}
113+
105114
// MARK: - Networking
106115

107116
private func data(for request: URLRequest, options: ImageRequestOptions) async throws -> Data {

WordPress/Classes/Utility/Media/ImageViewController.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ final class ImageViewController {
66
var downloader: ImageDownloader = .shared
77
var onStateChanged: (State) -> Void = { _ in }
88

9-
private var task: Task<Void, Never>?
9+
private(set) var task: Task<Void, Never>?
1010

1111
enum State {
1212
case loading

WordPress/Classes/Utility/Media/MemoryCache.swift

+6
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import WordPressUI
44

55
protocol MemoryCacheProtocol: AnyObject {
66
subscript(key: String) -> UIImage? { get set }
7+
func removeAllObjects()
78
}
89

910
/// - note: The type is thread-safe because it uses thread-safe `NSCache`.
1011
final class MemoryCache: MemoryCacheProtocol, @unchecked Sendable {
12+
1113
/// A shared image cache used by the entire system.
1214
static let shared = MemoryCache()
1315

@@ -23,6 +25,10 @@ final class MemoryCache: MemoryCacheProtocol, @unchecked Sendable {
2325
cache.removeAllObjects()
2426
}
2527

28+
func removeAllObjects() {
29+
cache.removeAllObjects()
30+
}
31+
2632
// MARK: - UIImage
2733

2834
subscript(key: String) -> UIImage? {

WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Me.swift

+10-2
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import Gravatar
44

55
extension BlogDetailsViewController {
66

7-
@objc func downloadGravatarImage(for row: BlogDetailsRow) {
7+
@objc func downloadGravatarImage(for row: BlogDetailsRow, forceRefresh: Bool = false) {
88
guard let email = blog.account?.email else {
99
return
1010
}
1111

12-
ImageDownloader.shared.downloadGravatarImage(with: email) { [weak self] image in
12+
ImageDownloader.shared.downloadGravatarImage(with: email, forceRefresh: forceRefresh) { [weak self] image in
1313
guard let image,
1414
let gravatarIcon = image.gravatarIcon(size: Metrics.iconSize) else {
1515
return
@@ -21,9 +21,17 @@ extension BlogDetailsViewController {
2121
}
2222

2323
@objc func observeGravatarImageUpdate() {
24+
NotificationCenter.default.addObserver(self, selector: #selector(refreshAvatar(_:)), name: .GravatarQEAvatarUpdateNotification, object: nil)
2425
NotificationCenter.default.addObserver(self, selector: #selector(updateGravatarImage(_:)), name: .GravatarImageUpdateNotification, object: nil)
2526
}
2627

28+
@objc private func refreshAvatar(_ notification: Foundation.Notification) {
29+
guard let meRow,
30+
let email = blog.account?.email,
31+
notification.userInfoHasEmail(email) else { return }
32+
downloadGravatarImage(for: meRow, forceRefresh: true)
33+
}
34+
2735
@objc private func updateGravatarImage(_ notification: Foundation.Notification) {
2836
guard let userInfo = notification.userInfo,
2937
let email = userInfo["email"] as? String,

WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m

+1-1
Original file line numberDiff line numberDiff line change
@@ -1350,7 +1350,7 @@ - (BlogDetailsSection *)configurationSectionViewModel
13501350
callback:^{
13511351
[weakSelf showMe];
13521352
}];
1353-
[self downloadGravatarImageFor:row];
1353+
[self downloadGravatarImageFor:row forceRefresh: NO];
13541354
self.meRow = row;
13551355
[rows addObject:row];
13561356
}

WordPress/Classes/ViewRelated/Gravatar/UIImageView+Gravatar.swift

+26-7
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,18 @@ extension UIImageView {
3434
/// - email: The user's email
3535
/// - gravatarRating: Expected image rating
3636
/// - placeholderImage: Image to be used as Placeholder
37+
/// - forceRefresh: Skip the cache and fetch the latest version of the avatar.
3738
public func downloadGravatar(
3839
for email: String,
3940
gravatarRating: Rating = .general,
40-
placeholderImage: UIImage = .gravatarPlaceholderImage
41+
placeholderImage: UIImage = .gravatarPlaceholderImage,
42+
forceRefresh: Bool = false
4143
) {
4244
let avatarURL = AvatarURL.url(for: email, preferredSize: .pixels(gravatarDefaultSize()), gravatarRating: gravatarRating)
43-
downloadGravatar(fullURL: avatarURL, placeholder: placeholderImage, animate: false)
45+
downloadGravatar(fullURL: avatarURL, placeholder: placeholderImage, animate: false, forceRefresh: forceRefresh)
4446
}
4547

46-
public func downloadGravatar(_ gravatar: AvatarURL?, placeholder: UIImage, animate: Bool) {
48+
public func downloadGravatar(_ gravatar: AvatarURL?, placeholder: UIImage, animate: Bool, forceRefresh: Bool = false) {
4749
guard let gravatar = gravatar else {
4850
self.image = placeholder
4951
return
@@ -56,14 +58,31 @@ extension UIImageView {
5658
layoutIfNeeded()
5759

5860
let size = Int(ceil(frame.width * min(2, UIScreen.main.scale)))
59-
let url = gravatar.replacing(options: .init(preferredSize: .pixels(size)))?.url
60-
downloadGravatar(fullURL: url, placeholder: placeholder, animate: animate)
61+
guard let url = gravatar.replacing(options: .init(preferredSize: .pixels(size)))?.url else { return }
62+
downloadGravatar(fullURL: url, placeholder: placeholder, animate: animate, forceRefresh: forceRefresh)
6163
}
6264

63-
private func downloadGravatar(fullURL: URL?, placeholder: UIImage, animate: Bool) {
65+
private func downloadGravatar(fullURL: URL?, placeholder: UIImage, animate: Bool, forceRefresh: Bool = false) {
6466
wp.prepareForReuse()
6567
if let fullURL {
66-
wp.setImage(with: fullURL)
68+
var urlToDownload = fullURL
69+
if forceRefresh {
70+
urlToDownload = fullURL.appendingGravatarCacheBusterParam()
71+
}
72+
73+
wp.setImage(with: urlToDownload)
74+
75+
if forceRefresh {
76+
// If this is a `forceRefresh`, the cache for the original URL should be updated too.
77+
// Because the cache buster parameter modifies the download URL.
78+
Task {
79+
await wp.controller.task?.value // Wait until setting the new image is done.
80+
if let image {
81+
ImageCache.shared.setImage(image, forKey: fullURL.absoluteString) // Update the cache for the original URL
82+
}
83+
}
84+
}
85+
6786
if image == nil { // If image wasn't found synchronously in memory cache
6887
image = placeholder
6988
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import Foundation
2+
import GravatarUI
3+
import WordPressShared
4+
import WordPressAuthenticator
5+
6+
@MainActor
7+
struct GravatarQuickEditorPresenter {
8+
let email: String
9+
let authToken: String
10+
11+
init?(email: String) {
12+
let context = ContextManager.sharedInstance().mainContext
13+
guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) else {
14+
return nil
15+
}
16+
self.email = email
17+
self.authToken = account.authToken
18+
}
19+
20+
func presentQuickEditor(on presentingViewController: UIViewController) {
21+
let presenter = QuickEditorPresenter(
22+
email: Email(email),
23+
scope: .avatarPicker(AvatarPickerConfiguration(contentLayout: .horizontal())),
24+
configuration: .init(
25+
interfaceStyle: presentingViewController.traitCollection.userInterfaceStyle
26+
),
27+
token: authToken
28+
)
29+
presenter.present(
30+
in: presentingViewController,
31+
onAvatarUpdated: {
32+
AuthenticatorAnalyticsTracker.shared.track(click: .selectAvatar)
33+
Task {
34+
// Purge the cache otherwise the old avatars remain around.
35+
await ImageDownloader.shared.clearURLSessionCache()
36+
await ImageDownloader.shared.clearMemoryCache()
37+
NotificationCenter.default.post(name: .GravatarQEAvatarUpdateNotification,
38+
object: self,
39+
userInfo: [GravatarQEAvatarUpdateNotificationKeys.email.rawValue: email])
40+
}
41+
}, onDismiss: {
42+
// No op.
43+
}
44+
)
45+
}
46+
}

WordPress/Classes/ViewRelated/Me/My Profile/MyProfileHeaderView.swift

+30-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ class MyProfileHeaderView: UITableViewHeaderFooterView {
55
// MARK: - Public Properties and Outlets
66
@IBOutlet var gravatarImageView: CircularImageView!
77
@IBOutlet var gravatarButton: UIButton!
8-
8+
weak var presentingViewController: UIViewController?
99
// A fake button displayed on top of gravatarImageView.
1010
let imageViewButton = UIButton(type: .system)
1111

@@ -24,8 +24,8 @@ class MyProfileHeaderView: UITableViewHeaderFooterView {
2424
}
2525
var gravatarEmail: String? = nil {
2626
didSet {
27-
if let email = gravatarEmail {
28-
gravatarImageView.downloadGravatar(for: email, gravatarRating: .x)
27+
if gravatarEmail != nil {
28+
downloadAvatar()
2929
}
3030
}
3131
}
@@ -51,10 +51,23 @@ class MyProfileHeaderView: UITableViewHeaderFooterView {
5151
configureGravatarButton()
5252
}
5353

54+
private func downloadAvatar(forceRefresh: Bool = false) {
55+
if let email = gravatarEmail {
56+
gravatarImageView.downloadGravatar(for: email, gravatarRating: .x, forceRefresh: forceRefresh)
57+
}
58+
}
59+
60+
@objc private func refreshAvatar(_ notification: Foundation.Notification) {
61+
guard let email = gravatarEmail,
62+
notification.userInfoHasEmail(email) else { return }
63+
downloadAvatar(forceRefresh: true)
64+
}
65+
5466
/// Overrides the current Gravatar Image (set via Email) with a given image reference.
5567
/// Plus, the internal image cache is updated, to prevent undesired glitches upon refresh.
5668
///
5769
func overrideGravatarImage(_ image: UIImage) {
70+
guard !RemoteFeatureFlag.gravatarQuickEditor.enabled() else { return }
5871
gravatarImageView.image = image
5972

6073
// Note:
@@ -81,9 +94,23 @@ class MyProfileHeaderView: UITableViewHeaderFooterView {
8194
gravatarImageView.addSubview(imageViewButton)
8295
imageViewButton.translatesAutoresizingMaskIntoConstraints = false
8396
imageViewButton.pinSubviewToAllEdges(gravatarImageView)
97+
if RemoteFeatureFlag.gravatarQuickEditor.enabled() {
98+
imageViewButton.addTarget(self, action: #selector(gravatarButtonTapped), for: .touchUpInside)
99+
NotificationCenter.default.addObserver(self, selector: #selector(refreshAvatar), name: .GravatarQEAvatarUpdateNotification, object: nil)
100+
}
84101
}
85102

86103
private func configureGravatarButton() {
87104
gravatarButton.tintColor = UIAppColor.primary
105+
if RemoteFeatureFlag.gravatarQuickEditor.enabled() {
106+
gravatarButton.addTarget(self, action: #selector(gravatarButtonTapped), for: .touchUpInside)
107+
}
108+
}
109+
110+
@objc private func gravatarButtonTapped() {
111+
guard let email = gravatarEmail,
112+
let presenter = GravatarQuickEditorPresenter(email: email),
113+
let presentingViewController else { return }
114+
presenter.presentQuickEditor(on: presentingViewController)
88115
}
89116
}

0 commit comments

Comments
 (0)