Skip to content

Commit b4777e6

Browse files
authored
Merge branch 'trunk' into tonyli-use-post-repository-post-search
2 parents 62316e8 + 75f4d22 commit b4777e6

File tree

89 files changed

+1561
-547
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

89 files changed

+1561
-547
lines changed

API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/Shared/wpcom_sites_blogging_prompts.json

Lines changed: 238 additions & 237 deletions
Large diffs are not rendered by default.

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
-----
33
* [*] Bug fix: Reader now scrolls to the top when tapping the status bar. [#21914]
44
* [*] [internal] Refactor sending the API requests for searching posts and pages. [#21976]
5+
* [*] Fix an issue in Menu screen where it fails to create default menu items. [#21949]
56

67
23.6
78
-----
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import UIKit
2+
3+
extension UIBarButtonItem {
4+
/// Returns a bar button item with a spinner activity indicator.
5+
static var activityIndicator: UIBarButtonItem {
6+
let activityIndicator = UIActivityIndicatorView(style: .medium)
7+
activityIndicator.sizeToFit()
8+
activityIndicator.startAnimating()
9+
return UIBarButtonItem(customView: activityIndicator)
10+
}
11+
}

WordPress/Classes/Extensions/UINavigationController+Helpers.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,18 @@ extension UINavigationController {
5151
self.pushViewController(viewController, animated: animated)
5252
}
5353
}
54+
55+
extension UIViewController {
56+
func configureDefaultNavigationBarAppearance() {
57+
let standardAppearance = UINavigationBarAppearance()
58+
standardAppearance.configureWithDefaultBackground()
59+
60+
let scrollEdgeAppearance = UINavigationBarAppearance()
61+
scrollEdgeAppearance.configureWithTransparentBackground()
62+
63+
navigationItem.standardAppearance = standardAppearance
64+
navigationItem.compactAppearance = standardAppearance
65+
navigationItem.scrollEdgeAppearance = scrollEdgeAppearance
66+
navigationItem.compactScrollEdgeAppearance = scrollEdgeAppearance
67+
}
68+
}

WordPress/Classes/Models/BloggingPrompt+CoreDataClass.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,17 @@ public class BloggingPrompt: NSManagedObject {
2121
BloggingPromptsAttribution(rawValue: attribution.lowercased())
2222
}
2323

24-
/// Convenience method to map properties from `RemoteBloggingPrompt`.
24+
/// Convenience method to map properties from `BloggingPromptRemoteObject`.
2525
///
2626
/// - Parameters:
2727
/// - remotePrompt: The remote prompt model to convert
2828
/// - siteID: The ID of the site that the prompt is intended for
29-
func configure(with remotePrompt: RemoteBloggingPrompt, for siteID: Int32) {
29+
func configure(with remotePrompt: BloggingPromptRemoteObject, for siteID: Int32) {
3030
self.promptID = Int32(remotePrompt.promptID)
3131
self.siteID = siteID
3232
self.text = remotePrompt.text
33-
self.title = remotePrompt.title
34-
self.content = remotePrompt.content
33+
self.title = String() // TODO: Remove
34+
self.content = String() // TODO: Remove
3535
self.attribution = remotePrompt.attribution
3636
self.date = remotePrompt.date
3737
self.answered = remotePrompt.answered
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/// Encapsulates a single blogging prompt object from the v3 API.
2+
struct BloggingPromptRemoteObject {
3+
let promptID: Int
4+
let text: String
5+
let attribution: String
6+
let date: Date
7+
let answered: Bool
8+
let answeredUsersCount: Int
9+
let answeredUserAvatarURLs: [URL]
10+
let answeredLink: URL?
11+
let answeredLinkText: String
12+
}
13+
14+
// MARK: - Decodable
15+
16+
extension BloggingPromptRemoteObject: Decodable {
17+
enum CodingKeys: String, CodingKey {
18+
case id
19+
case text
20+
case attribution
21+
case date
22+
case answered
23+
case answeredUsersCount = "answered_users_count"
24+
case answeredUserAvatarURLs = "answered_users_sample"
25+
case answeredLink = "answered_link"
26+
case answeredLinkText = "answered_link_text"
27+
}
28+
29+
/// meta structure to simplify decoding logic for user avatar objects.
30+
/// this is intended to be private.
31+
private struct UserAvatar: Codable {
32+
var avatar: String
33+
}
34+
35+
/// Used to format the fetched object's date string to a date.
36+
private static var dateFormatter: DateFormatter = {
37+
let formatter = DateFormatter()
38+
formatter.locale = .init(identifier: "en_US_POSIX")
39+
formatter.timeZone = .init(secondsFromGMT: 0)
40+
formatter.dateFormat = "yyyy-MM-dd"
41+
return formatter
42+
}()
43+
44+
init(from decoder: Decoder) throws {
45+
let container = try decoder.container(keyedBy: CodingKeys.self)
46+
47+
self.promptID = try container.decode(Int.self, forKey: .id)
48+
self.text = try container.decode(String.self, forKey: .text)
49+
self.attribution = try container.decode(String.self, forKey: .attribution)
50+
self.answered = try container.decode(Bool.self, forKey: .answered)
51+
self.date = Self.dateFormatter.date(from: try container.decode(String.self, forKey: .date)) ?? Date()
52+
self.answeredUsersCount = try container.decode(Int.self, forKey: .answeredUsersCount)
53+
54+
let userAvatars = try container.decode([UserAvatar].self, forKey: .answeredUserAvatarURLs)
55+
self.answeredUserAvatarURLs = userAvatars.compactMap { URL(string: $0.avatar) }
56+
57+
if let linkURLString = try? container.decode(String.self, forKey: .answeredLink),
58+
let answeredLinkURL = URL(string: linkURLString) {
59+
self.answeredLink = answeredLinkURL
60+
} else {
61+
self.answeredLink = nil
62+
}
63+
64+
self.answeredLinkText = try container.decode(String.self, forKey: .answeredLinkText)
65+
}
66+
}

WordPress/Classes/Services/BloggingPromptsService.swift renamed to WordPress/Classes/Services/BloggingPrompts/BloggingPromptsService.swift

Lines changed: 118 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import CoreData
22
import WordPressKit
33

44
class BloggingPromptsService {
5-
private let contextManager: CoreDataStackSwift
65
let siteID: NSNumber
7-
private let remote: BloggingPromptsServiceRemote
6+
7+
private let contextManager: CoreDataStackSwift
8+
private let remote: BloggingPromptsServiceRemote // TODO: Remove once the settings logic is ported.
9+
private let api: WordPressComRestApi
810
private let calendar: Calendar = .autoupdatingCurrent
911
private let maxListPrompts = 11
1012

@@ -27,6 +29,14 @@ class BloggingPromptsService {
2729
return formatter
2830
}()
2931

32+
/// A JSON decoder that can parse date strings that matches `JSONDecoder.DateDecodingStrategy.DateFormat` into `Date`.
33+
private static var jsonDecoder: JSONDecoder = {
34+
let decoder = JSONDecoder()
35+
decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.supportMultipleDateFormats
36+
37+
return decoder
38+
}()
39+
3040
/// Convenience computed variable that returns today's prompt from local store.
3141
///
3242
var localTodaysPrompt: BloggingPrompt? {
@@ -48,7 +58,8 @@ class BloggingPromptsService {
4858
success: (([BloggingPrompt]) -> Void)? = nil,
4959
failure: ((Error?) -> Void)? = nil) {
5060
let fromDate = startDate ?? defaultStartDate
51-
remote.fetchPrompts(for: siteID, number: number, fromDate: fromDate) { result in
61+
62+
fetchRemotePrompts(number: number, fromDate: fromDate, ignoresYear: true) { result in
5263
switch result {
5364
case .success(let remotePrompts):
5465
self.upsert(with: remotePrompts) { innerResult in
@@ -181,11 +192,14 @@ class BloggingPromptsService {
181192
/// Otherwise, a remote service with the default account's credentials will be used.
182193
/// - blog: When supplied, the service will perform blogging prompts requests for this specified blog.
183194
/// Otherwise, this falls back to the default account's primary blog.
195+
/// - api: When supplied, the WordPressComRestApi instance to use to fetch the prompts.
196+
/// Otherwise, an default or anonymous instance will be computed based on whether there is an account available.
184197
required init?(contextManager: CoreDataStackSwift = ContextManager.shared,
198+
api: WordPressComRestApi? = nil,
185199
remote: BloggingPromptsServiceRemote? = nil,
186200
blog: Blog? = nil) {
187201
let blogObjectID = blog?.objectID
188-
let (siteID, remoteInstance) = contextManager.performQuery { mainContext in
202+
let (siteID, remoteInstance, api) = contextManager.performQuery { mainContext in
189203
// if a blog exists, then try to use the blog's ID.
190204
var blogInContext: Blog? = nil
191205
if let blogObjectID {
@@ -194,12 +208,18 @@ class BloggingPromptsService {
194208

195209
// fetch the default account and fall back to default values as needed.
196210
guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: mainContext) else {
197-
return (blogInContext?.dotComID, remote)
211+
return (
212+
blogInContext?.dotComID,
213+
remote,
214+
api ?? WordPressComRestApi.anonymousApi(userAgent: WPUserAgent.wordPress(),
215+
localeKey: WordPressComRestApi.LocaleKeyV2)
216+
)
198217
}
199218

200219
return (
201220
blogInContext?.dotComID ?? account.primaryBlogID,
202-
remote ?? .init(wordPressComRestApi: account.wordPressComRestV2Api)
221+
remote ?? .init(wordPressComRestApi: api ?? account.wordPressComRestV2Api),
222+
api ?? account.wordPressComRestV2Api
203223
)
204224
}
205225

@@ -211,6 +231,7 @@ class BloggingPromptsService {
211231
self.contextManager = contextManager
212232
self.siteID = siteID
213233
self.remote = remoteInstance
234+
self.api = api
214235
}
215236
}
216237

@@ -260,6 +281,64 @@ private extension BloggingPromptsService {
260281
return Self.utcDateFormatter.date(from: dateString)
261282
}
262283

284+
// MARK: Prompts
285+
286+
/// Fetches a number of blogging prompts for the specified site from the v3 endpoint.
287+
///
288+
/// - Parameters:
289+
/// - number: The number of prompts to query. When not specified, this will default to remote implementation.
290+
/// - fromDate: When specified, this will fetch prompts from the given date. When not specified, this will default to remote implementation.
291+
/// - ignoresYear: When set to true, this will convert the date to a custom format that ignores the year part. Defaults to true.
292+
/// - forceYear: Forces the year value on the prompt's date to the specified value. Defaults to the current year.
293+
/// - completion: A closure that will be called when the fetch request completes.
294+
func fetchRemotePrompts(number: Int? = nil,
295+
fromDate: Date? = nil,
296+
ignoresYear: Bool = true,
297+
forceYear: Int? = nil,
298+
completion: @escaping (Result<[BloggingPromptRemoteObject], Error>) -> Void) {
299+
let path = "wpcom/v3/sites/\(siteID)/blogging-prompts"
300+
let requestParameter: [String: AnyHashable] = {
301+
var params = [String: AnyHashable]()
302+
303+
if let number, number > 0 {
304+
params["per_page"] = number
305+
}
306+
307+
if let fromDate {
308+
// convert to yyyy-MM-dd format in local timezone so users would see the same prompt throughout their day.
309+
var dateString = Self.localDateFormatter.string(from: fromDate)
310+
311+
// when the year needs to be ignored, we'll transform the dateString to match the "--mm-dd" format.
312+
if ignoresYear, !dateString.isEmpty {
313+
dateString = "-" + dateString.dropFirst(4)
314+
}
315+
316+
params["after"] = dateString
317+
}
318+
319+
if let forceYear = forceYear ?? fromDate?.dateAndTimeComponents().year {
320+
params["force_year"] = forceYear
321+
}
322+
323+
return params
324+
}()
325+
326+
api.GET(path, parameters: requestParameter as [String: AnyObject]) { result, _ in
327+
switch result {
328+
case .success(let responseObject):
329+
do {
330+
let data = try JSONSerialization.data(withJSONObject: responseObject, options: [])
331+
let remotePrompts = try Self.jsonDecoder.decode([BloggingPromptRemoteObject].self, from: data)
332+
completion(.success(remotePrompts))
333+
} catch {
334+
completion(.failure(error))
335+
}
336+
case .failure(let error):
337+
completion(.failure(error))
338+
}
339+
}
340+
}
341+
263342
/// Loads local prompts based on the given parameters.
264343
///
265344
/// - Parameters:
@@ -292,45 +371,64 @@ private extension BloggingPromptsService {
292371
/// - Parameters:
293372
/// - remotePrompts: An array containing prompts obtained from remote.
294373
/// - completion: Closure to be called after the process completes. Returns an array of prompts when successful.
295-
func upsert(with remotePrompts: [RemoteBloggingPrompt], completion: @escaping (Result<Void, Error>) -> Void) {
374+
func upsert(with remotePrompts: [BloggingPromptRemoteObject], completion: @escaping (Result<Void, Error>) -> Void) {
296375
if remotePrompts.isEmpty {
297376
completion(.success(()))
298377
return
299378
}
300379

301-
let remoteIDs = Set(remotePrompts.map { Int32($0.promptID) })
302-
let remotePromptsDictionary = remotePrompts.reduce(into: [Int32: RemoteBloggingPrompt]()) { partialResult, remotePrompt in
303-
partialResult[Int32(remotePrompt.promptID)] = remotePrompt
380+
// incoming remote prompts should have unique dates.
381+
// fetch requests require the date to be `NSDate` specifically, hence the cast.
382+
let incomingDates = Set(remotePrompts.map(\.date))
383+
let promptsByDate = remotePrompts.reduce(into: [Date: BloggingPromptRemoteObject]()) { partialResult, remotePrompt in
384+
partialResult[remotePrompt.date] = remotePrompt
304385
}
305386

306-
let predicate = NSPredicate(format: "\(#keyPath(BloggingPrompt.siteID)) = %@ AND \(#keyPath(BloggingPrompt.promptID)) IN %@", siteID, remoteIDs)
387+
let predicate = NSPredicate(format: "\(#keyPath(BloggingPrompt.siteID)) = %@ AND \(#keyPath(BloggingPrompt.date)) IN %@",
388+
siteID,
389+
incomingDates.map { $0 as NSDate })
307390
let fetchRequest = BloggingPrompt.fetchRequest()
308391
fetchRequest.predicate = predicate
309392

310393
contextManager.performAndSave({ derivedContext in
311-
var foundExistingIDs = [Int32]()
394+
/// Try to overwrite prompts that have the same dates.
395+
///
396+
/// Perf. notes: since we're at most updating 25 entries, it should be acceptable to update them one by one.
397+
/// However, if requirements change and we need to work through a larger data set, consider switching to
398+
/// a drop-and-replace strategy with `NSBatchDeleteRequest` as it's more performant.
399+
var updatedExistingDates = Set<Date>()
312400
let results = try derivedContext.fetch(fetchRequest)
313401
results.forEach { prompt in
314-
guard let remotePrompt = remotePromptsDictionary[prompt.promptID] else {
402+
guard let incoming = promptsByDate[prompt.date] else {
315403
return
316404
}
317405

318-
foundExistingIDs.append(prompt.promptID)
319-
prompt.configure(with: remotePrompt, for: self.siteID.int32Value)
406+
// ensure that there's only one prompt for each date.
407+
// if the prompt with this date has been updated before, then it's a duplicate. Let's delete it.
408+
if updatedExistingDates.contains(prompt.date) {
409+
derivedContext.deleteObject(prompt)
410+
return
411+
}
412+
413+
// otherwise, we can update the prompt matching the date with the incoming prompt.
414+
prompt.configure(with: incoming, for: self.siteID.int32Value)
415+
updatedExistingDates.insert(incoming.date)
320416
}
321417

322-
// Insert new prompts
323-
let newPromptIDs = remoteIDs.subtracting(foundExistingIDs)
324-
newPromptIDs.forEach { newPromptID in
325-
guard let remotePrompt = remotePromptsDictionary[newPromptID],
418+
// process the remaining new prompts.
419+
let datesToInsert = incomingDates.subtracting(updatedExistingDates)
420+
datesToInsert.forEach { date in
421+
guard let incoming = promptsByDate[date],
326422
let newPrompt = BloggingPrompt.newObject(in: derivedContext) else {
327423
return
328424
}
329-
newPrompt.configure(with: remotePrompt, for: self.siteID.int32Value)
425+
newPrompt.configure(with: incoming, for: self.siteID.int32Value)
330426
}
331427
}, completion: completion, on: .main)
332428
}
333429

430+
// MARK: Prompt Settings
431+
334432
/// Updates existing settings or creates new settings from the remote prompt settings.
335433
///
336434
/// - Parameters:

WordPress/Classes/Services/MenusService.h

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,12 @@ typedef void(^MenusServiceFailureBlock)(NSError *error);
7272
failure:(nullable MenusServiceFailureBlock)failure;
7373

7474
/**
75-
* @brief Generate a list MenuItems from the blog's top-level pages.
75+
* @brief Create a list MenuItems from the given page.
7676
*
77-
* @param blog The blog to use for pages. Cannot be nil.
78-
* @param success The success handler. Can be nil.
79-
* @param failure The failure handler. Can be nil.
77+
* @return A MenuItem instance for the page if it's a top-level page. Otherwise, nil.
8078
*
8179
*/
82-
- (void)generateDefaultMenuItemsForBlog:(Blog *)blog
83-
success:(nullable void(^)(NSArray <MenuItem *> * _Nullable defaultItems))success
84-
failure:(nullable MenusServiceFailureBlock)failure;
80+
- (nullable MenuItem *)createItemWithPageID:(NSManagedObjectID *)pageObjectID inContext:(NSManagedObjectContext *)context;
8581

8682
@end
8783

0 commit comments

Comments
 (0)