diff --git a/Modules/Sources/WordPressKit/ReaderFeed.swift b/Modules/Sources/WordPressKit/ReaderFeed.swift index fb4971f73d72..1875c2ede838 100644 --- a/Modules/Sources/WordPressKit/ReaderFeed.swift +++ b/Modules/Sources/WordPressKit/ReaderFeed.swift @@ -5,8 +5,8 @@ import Foundation /// (read/feed?q=query) /// public struct ReaderFeed: Decodable { - public let url: URL - public let title: String + public let url: URL? + public let title: String? public let feedDescription: String? public let feedID: String? public let blogID: String? @@ -26,15 +26,7 @@ public struct ReaderFeed: Decodable { private enum DataKeys: CodingKey { case site - } - - private enum SiteKeys: CodingKey { - case description - case icon - } - - private enum IconKeys: CodingKey { - case img + case feed } public init(from decoder: Decoder) throws { @@ -44,32 +36,77 @@ public struct ReaderFeed: Decodable { // - We want to decode whatever we can get, and not fail if neither of those exist let rootContainer = try decoder.container(keyedBy: CodingKeys.self) - url = try rootContainer.decode(URL.self, forKey: .url) - title = try rootContainer.decode(String.self, forKey: .title) + var feedURL = try? rootContainer.decodeIfPresent(URL.self, forKey: .url) + var title = try? rootContainer.decodeIfPresent(String.self, forKey: .title) feedID = try? rootContainer.decode(String.self, forKey: .feedID) blogID = try? rootContainer.decode(String.self, forKey: .blogID) var feedDescription: String? var blavatarURL: URL? + // Try to parse both site and feed data from meta.data do { let metaContainer = try rootContainer.nestedContainer(keyedBy: MetaKeys.self, forKey: .meta) let dataContainer = try metaContainer.nestedContainer(keyedBy: DataKeys.self, forKey: .data) - let siteContainer = try dataContainer.nestedContainer(keyedBy: SiteKeys.self, forKey: .site) - feedDescription = try? siteContainer.decode(String.self, forKey: .description) - let iconContainer = try siteContainer.nestedContainer(keyedBy: IconKeys.self, forKey: .icon) - blavatarURL = try? iconContainer.decode(URL.self, forKey: .img) + let siteData = try? dataContainer.decode(SiteOrFeedData.self, forKey: .site) + let feedData = try? dataContainer.decode(SiteOrFeedData.self, forKey: .feed) + + // Use data from either source, preferring site data when both are available + feedDescription = siteData?.description ?? feedData?.description + blavatarURL = siteData?.iconURL ?? feedData?.iconURL + + // Fixes CMM-1002: in some cases, the backend fails to embed certain fields + // directly in the feed object + if feedURL == nil { + feedURL = siteData?.url ?? feedData?.url + } + if title == nil { + title = siteData?.title ?? feedData?.title + } } catch { } + self.url = feedURL + self.title = title self.feedDescription = feedDescription self.blavatarURL = blavatarURL } } +private struct SiteOrFeedData: Decodable { + var title: String? + var description: String? + var iconURL: URL? + var url: URL? + + enum CodingKeys: String, CodingKey { + case description + case icon + case url = "URL" + case name + } + + private enum IconKeys: CodingKey { + case img + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + title = try? container.decodeIfPresent(String.self, forKey: .name) + description = try? container.decodeIfPresent(String.self, forKey: .description) + url = try? container.decodeIfPresent(URL.self, forKey: .url) + + // Try to decode the icon URL from the nested icon dictionary + if let iconContainer = try? container.nestedContainer(keyedBy: IconKeys.self, forKey: .icon) { + iconURL = try? iconContainer.decode(URL.self, forKey: .img) + } + } +} + extension ReaderFeed: CustomStringConvertible { public var description: String { - return "" + return "" } } diff --git a/Modules/Sources/WordPressKit/ReaderSiteSearchServiceRemote.swift b/Modules/Sources/WordPressKit/ReaderSiteSearchServiceRemote.swift index 03d4739cffef..7e8beb4b153b 100644 --- a/Modules/Sources/WordPressKit/ReaderSiteSearchServiceRemote.swift +++ b/Modules/Sources/WordPressKit/ReaderSiteSearchServiceRemote.swift @@ -20,7 +20,7 @@ public class ReaderSiteSearchServiceRemote: ServiceRemoteWordPressComREST { public func performSearch(_ query: String, offset: Int = 0, count: Int, - success: @escaping (_ results: [ReaderFeed], _ hasMore: Bool, _ feedCount: Int) -> Void, + success: @escaping (_ results: [ReaderFeed], _ hasMore: Bool, _ total: Int?) -> Void, failure: @escaping (Error) -> Void) { let endpoint = "read/feed" let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) @@ -29,7 +29,7 @@ public class ReaderSiteSearchServiceRemote: ServiceRemoteWordPressComREST { "offset": offset as AnyObject, "exclude_followed": false as AnyObject, "sort": "relevance" as AnyObject, - "meta": "site" as AnyObject, + "meta": "site,feed" as AnyObject, "q": query as AnyObject ] @@ -38,7 +38,7 @@ public class ReaderSiteSearchServiceRemote: ServiceRemoteWordPressComREST { success: { response, _ in do { let (results, total) = try self.mapSearchResponse(response) - let hasMore = total > (offset + count) + let hasMore = (total ?? 0) > (offset + count) success(results, hasMore, total) } catch { failure(error) @@ -52,7 +52,7 @@ public class ReaderSiteSearchServiceRemote: ServiceRemoteWordPressComREST { private extension ReaderSiteSearchServiceRemote { - func mapSearchResponse(_ response: Any) throws -> ([ReaderFeed], Int) { + func mapSearchResponse(_ response: Any) throws -> ([ReaderFeed], Int?) { do { let decoder = JSONDecoder() let data = try JSONSerialization.data(withJSONObject: response, options: []) @@ -73,9 +73,9 @@ private extension ReaderSiteSearchServiceRemote { /// The Reader feed search endpoint returns feeds in a key named `feeds` key. /// This entity allows us to do parse that and the total feed count using JSONDecoder. /// -private struct ReaderFeedEnvelope: Decodable { +struct ReaderFeedEnvelope: Decodable { let feeds: [ReaderFeed] - let total: Int + let total: Int? private enum CodingKeys: String, CodingKey { case feeds = "feeds" diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index d5aafeb03e21..abc925c6a546 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -14,6 +14,7 @@ * [*] Add "Email to Subscribers" row to "Publishing" sheet [#24946] * [*] Add permalink preview in the slug editor and make other improvements [#24949] * [*] Add two accessible font sizes to Reader display settings [#25013] +* [*] Fix an issue with Reader failing to parse some search results [#25022] * [*] Add "Taxonomies" to Site Settings [#24955] * [*] Update "Categories" picker to indicate multiple selection [#24952] * [*] Fix overly long related post titles in Reader [#25011] diff --git a/Tests/WordPressKitTests/WordPressKitTests/Mock Data/reader-site-search-failure.json b/Tests/WordPressKitTests/WordPressKitTests/Mock Data/reader-site-search-failure.json deleted file mode 100644 index eedb77ad5b19..000000000000 --- a/Tests/WordPressKitTests/WordPressKitTests/Mock Data/reader-site-search-failure.json +++ /dev/null @@ -1,192 +0,0 @@ -{ - "feeds": [ - { - "subscribe_URL": "http://dailypost.wordpress.com", - "blog_ID": "489937", - "title": "The Daily Post", - "railcar": { - "railcar": "*dPbMa8*fdh#", - "fetch_algo": "reader/manage/search:0", - "fetch_position": 0, - "rec_blog_id": 489937, - "fetch_lang": "en", - "fetch_query": "dailypost.wordpress.com" - }, - "meta": { - "links": { - "feed": "https://public-api.wordpress.com/rest/v1.1/read/feed/27030", - "site": "https://public-api.wordpress.com/rest/v1.1/read/sites/489937" - }, - "data": { - "site": { - "ID": 489937, - "name": "The Daily Post", - "description": "The Art and Craft of Blogging", - "URL": "https://dailypost.wordpress.com", - "jetpack": false, - "subscribers_count": 36055068, - "lang": false, - "icon": { - "img": "https://secure.gravatar.com/blavatar/7eb290aaccb7d769c6a84369a0a83f3d", - "ico": "https://secure.gravatar.com/blavatar/7eb290aaccb7d769c6a84369a0a83f3d" - }, - "logo": { - "id": 0, - "sizes": [], - "url": "" - }, - "visible": null, - "is_private": false, - "is_following": false, - "meta": { - "links": { - "self": "https://public-api.wordpress.com/rest/v1.1/read/sites/489937", - "help": "https://public-api.wordpress.com/rest/v1.1/read/sites/489937/help", - "posts": "https://public-api.wordpress.com/rest/v1.1/read/sites/489937/posts/", - "comments": "https://public-api.wordpress.com/rest/v1.1/sites/489937/comments/", - "xmlrpc": "https://dailypost.wordpress.com/xmlrpc.php" - } - }, - "capabilities": { - "edit_pages": false, - "edit_posts": false, - "edit_others_posts": false, - "edit_theme_options": false, - "list_users": false, - "manage_categories": false, - "manage_options": false, - "publish_posts": false, - "upload_files": false, - "view_stats": false - }, - "is_multi_author": true, - "feed_ID": 27030, - "feed_URL": "http://dailypost.wordpress.com", - "header_image": false, - "owner": { - "ID": 26957695, - "login": "a8cuser", - "name": "Automattic", - "first_name": "Automattic", - "last_name": "", - "nice_name": "a8cuser", - "URL": "", - "avatar_URL": "https://1.gravatar.com/avatar/a64c4a50f3f38f02cd27a9bfb3f11b62?s=96&d=identicon&r=G", - "profile_URL": "https://en.gravatar.com/a8cuser", - "ip_address": false, - "site_visible": true, - "has_avatar": true - }, - "subscription": { - "delivery_methods": { - "email": null, - "notification": { - "send_posts": false - } - } - }, - "is_blocked": false - } - } - }, - "subscribers_count": 37418087 - }, - { - "URL": "https://discover.wordpress.com", - "subscribe_URL": "http://discover.wordpress.com", - "blog_ID": "53424024", - "title": "Discover", - "railcar": { - "railcar": "Ulg!Pst)TwI2", - "fetch_algo": "reader/manage/search:0", - "fetch_position": 0, - "rec_blog_id": 53424024, - "fetch_lang": "en", - "fetch_query": "discover.wordpress.com" - }, - "meta": { - "links": { - "feed": "https://public-api.wordpress.com/rest/v1.1/read/feed/41325786", - "site": "https://public-api.wordpress.com/rest/v1.1/read/sites/53424024" - }, - "data": { - "site": { - "ID": 53424024, - "name": "Discover", - "description": "A daily selection of the best content published on WordPress, collected for you by humans who love to read.", - "URL": "https://discover.wordpress.com", - "jetpack": false, - "subscribers_count": 23332327, - "lang": false, - "icon": { - "img": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace", - "ico": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace" - }, - "logo": { - "id": 0, - "sizes": [], - "url": "" - }, - "visible": null, - "is_private": false, - "is_following": false, - "meta": { - "links": { - "self": "https://public-api.wordpress.com/rest/v1.1/read/sites/53424024", - "help": "https://public-api.wordpress.com/rest/v1.1/read/sites/53424024/help", - "posts": "https://public-api.wordpress.com/rest/v1.1/read/sites/53424024/posts/", - "comments": "https://public-api.wordpress.com/rest/v1.1/sites/53424024/comments/", - "xmlrpc": "https://discover.wordpress.com/xmlrpc.php", - "featured": "https://public-api.wordpress.com/rest/v1.1/read/sites/53424024/featured" - } - }, - "capabilities": { - "edit_pages": false, - "edit_posts": false, - "edit_others_posts": false, - "edit_theme_options": false, - "list_users": false, - "manage_categories": false, - "manage_options": false, - "publish_posts": false, - "upload_files": false, - "view_stats": false - }, - "is_multi_author": true, - "feed_ID": 41325786, - "feed_URL": "http://discover.wordpress.com", - "header_image": false, - "owner": { - "ID": 26957695, - "login": "a8cuser", - "name": "Automattic", - "first_name": "Automattic", - "last_name": "", - "nice_name": "a8cuser", - "URL": "", - "avatar_URL": "https://1.gravatar.com/avatar/a64c4a50f3f38f02cd27a9bfb3f11b62?s=96&d=identicon&r=G", - "profile_URL": "https://en.gravatar.com/a8cuser", - "ip_address": false, - "site_visible": true, - "has_avatar": true - }, - "subscription": { - "delivery_methods": { - "email": null, - "notification": { - "send_posts": false - } - } - }, - "is_blocked": false - } - } - }, - "feed_ID": "41325786", - "subscribers_count": 24623045 - } - ], - "total": 2, - "algorithm": "reader/manage/search:0", - "next_page": "offset=10&algorithm=reader/manage/search:0" -} diff --git a/Tests/WordPressKitTests/WordPressKitTests/Tests/ReaderSiteSearchServiceRemoteTests.swift b/Tests/WordPressKitTests/WordPressKitTests/Tests/ReaderSiteSearchServiceRemoteTests.swift index a5bda22dd081..161a845c8259 100644 --- a/Tests/WordPressKitTests/WordPressKitTests/Tests/ReaderSiteSearchServiceRemoteTests.swift +++ b/Tests/WordPressKitTests/WordPressKitTests/Tests/ReaderSiteSearchServiceRemoteTests.swift @@ -10,7 +10,6 @@ class ReaderSiteSearchServiceRemoteTests: RemoteTestCase, RESTTestable { let performSearchSuccessNoIconFilename = "reader-site-search-success-no-icon.json" let performSearchSuccessNoDataFilename = "reader-site-search-success-no-data.json" let performSearchSuccessHasMoreFilename = "reader-site-search-success-hasmore.json" - let performSearchFailureFilename = "reader-site-search-failure.json" let performSearchBlogIDFallbackFilename = "reader-site-search-blog-id-fallback.json" let performSearchFailsWithNoBlogOrFeedIDFilename = "reader-site-search-no-blog-or-feed-id.json" @@ -148,27 +147,6 @@ class ReaderSiteSearchServiceRemoteTests: RemoteTestCase, RESTTestable { waitForExpectations(timeout: timeout, handler: nil) } - func testPerformSearchFailure() { - let expect = expectation(description: "Perform Reader site search fails if no URL is present") - - stubRemoteResponse(performSearchEndpoint, filename: performSearchFailureFilename, contentType: .ApplicationJSON) - remote.performSearch("discover", - count: 10, success: { (_, _, _) in - XCTFail("This callback shouldn't get called") - expect.fulfill() - }, failure: { error in - typealias ResponseError = ReaderSiteSearchServiceRemote.ResponseError - guard case ResponseError.decodingFailure? = error as? ResponseError else { - XCTFail("Expected a decodingFailure error") - expect.fulfill() - return - } - - expect.fulfill() - }) - waitForExpectations(timeout: timeout, handler: nil) - } - func testPerformSearchBlogIDFallback() { let expect = expectation(description: "Perform Reader site search falls back to parsing blog ID if no feed ID is present") diff --git a/Tests/WordPressKitTests/WordPressKitTests/Tests/ReederFeedTests.swift b/Tests/WordPressKitTests/WordPressKitTests/Tests/ReederFeedTests.swift new file mode 100644 index 000000000000..8b2a3aa1d6ad --- /dev/null +++ b/Tests/WordPressKitTests/WordPressKitTests/Tests/ReederFeedTests.swift @@ -0,0 +1,145 @@ +import Foundation +import Testing + +@testable import WordPressKit + +struct ReaderFeedTests { + + @Test func decodesReaderFeedEnvelopeWithSiteFallbacks() throws { + // GIVEN: JSON response where URL and title are not embedded at root level + let jsonData = try #require(readerFeedJSON.data(using: .utf8)) + + // WHEN: Decoding the envelope + let decoder = JSONDecoder() + let envelope = try decoder.decode(ReaderFeedEnvelope.self, from: jsonData) + + // THEN: Envelope contains feeds array + #expect(envelope.feeds.count == 1) + + let feed = try #require(envelope.feeds.first) + + // THEN: Feed ID is decoded from root level + #expect(feed.feedID == "188407") + + // THEN: URL falls back to data.site.URL since not present at root + #expect(feed.url?.absoluteString == "https://ma.tt") + + // THEN: Title falls back to data.site.name since not present at root + #expect(feed.title == "Matt Mullenweg") + + // THEN: Description is decoded from data.site.description + #expect(feed.feedDescription == "Unlucky in Cards") + + // THEN: Blavatar URL is decoded from data.site.icon.img + #expect(feed.blavatarURL?.absoluteString == "https://ma.tt/files/2024/01/cropped-matt-favicon.png") + } +} + +// MARK: - Test Data + +private let readerFeedJSON = """ +{ + "feeds": [ + { + "subscribe_URL": "https://ma.tt/feed/", + "feed_ID": "188407", + "meta": { + "links": { + "feed": "https://public-api.wordpress.com/rest/v1.1/read/feed/188407", + "site": "https://public-api.wordpress.com/rest/v1.1/read/sites/1047865" + }, + "data": { + "site": { + "ID": 1047865, + "name": "Matt Mullenweg", + "description": "Unlucky in Cards", + "URL": "https://ma.tt", + "jetpack": true, + "jetpack_connection": true, + "post_count": 5599, + "subscribers_count": 4520, + "lang": "en-US", + "icon": { + "img": "https://ma.tt/files/2024/01/cropped-matt-favicon.png", + "ico": "https://ma.tt/files/2024/01/cropped-matt-favicon.png?w=16" + }, + "logo": { + "id": 0, + "sizes": [], + "url": "" + }, + "visible": true, + "is_private": false, + "is_coming_soon": false, + "is_following": false, + "organization_id": 0, + "meta": { + "links": { + "self": "https://public-api.wordpress.com/rest/v1.1/read/sites/1047865", + "help": "https://public-api.wordpress.com/rest/v1.1/read/sites/1047865/help", + "posts": "https://public-api.wordpress.com/rest/v1.1/read/sites/1047865/posts/", + "comments": "https://public-api.wordpress.com/rest/v1.1/sites/1047865/comments/", + "xmlrpc": "https://ma.tt/blog/xmlrpc.php" + } + }, + "launch_status": false, + "site_migration": { + "is_complete": false, + "in_progress": false + }, + "is_fse_active": false, + "is_fse_eligible": false, + "is_core_site_editor_enabled": false, + "is_wpcom_atomic": false, + "is_wpcom_staging_site": false, + "is_deleted": false, + "is_a4a_client": false, + "is_a4a_dev_site": false, + "is_wpcom_flex": false, + "capabilities": { + "edit_pages": false, + "edit_posts": false, + "edit_others_posts": false, + "edit_theme_options": false, + "list_users": false, + "manage_categories": false, + "manage_options": false, + "publish_posts": false, + "upload_files": false, + "view_stats": false + }, + "is_multi_author": true, + "feed_ID": 188407, + "feed_URL": "http://ma.tt/feed", + "header_image": false, + "owner": { + "ID": 5, + "login": "matt", + "name": "Matt", + "first_name": "Matt", + "last_name": "Mullenweg", + "nice_name": "matt", + "URL": "https://matt.blog/", + "avatar_URL": "https://0.gravatar.com/avatar/33252cd1f33526af53580fcb1736172f06e6716f32afdd1be19ec3096d15dea5?s=96&d=retro&r=G", + "profile_URL": "https://gravatar.com/matt", + "ip_address": false, + "site_visible": true, + "has_avatar": true + }, + "subscription": { + "delivery_methods": { + "email": null, + "notification": { + "send_posts": false + } + } + }, + "is_blocked": false, + "unseen_count": 0 + } + } + } + } + ] +} +""" diff --git a/WordPress/Classes/Services/ReaderSiteSearchService.swift b/WordPress/Classes/Services/ReaderSiteSearchService.swift index 77a1533ac656..67a959737058 100644 --- a/WordPress/Classes/Services/ReaderSiteSearchService.swift +++ b/WordPress/Classes/Services/ReaderSiteSearchService.swift @@ -3,7 +3,7 @@ import WordPressData import WordPressShared import WordPressKit -typealias ReaderSiteSearchSuccessBlock = (_ feeds: [ReaderFeed], _ hasMore: Bool, _ feedCount: Int) -> Void +typealias ReaderSiteSearchSuccessBlock = (_ feeds: [ReaderFeed], _ hasMore: Bool, _ total: Int?) -> Void typealias ReaderSiteSearchFailureBlock = (_ error: Error?) -> Void /// Allows searching for sites / feeds in the Reader. @@ -50,8 +50,8 @@ class ReaderSiteSearchService { remote.performSearch(query, offset: page * Constants.pageSize, count: Constants.pageSize, - success: { (feeds, hasMore, feedCount) in - success(feeds, hasMore, feedCount) + success: { (feeds, hasMore, total) in + success(feeds, hasMore, total) }, failure: { error in DDLogError("Error while performing Reader site search: \(String(describing: error))") failure(error) diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderFeedCell.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderFeedCell.swift index c119194141f2..6e0df7b8d884 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderFeedCell.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderFeedCell.swift @@ -15,23 +15,24 @@ struct ReaderFeedCell: View { .font(.body) .lineLimit(1) - Text(subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(2) + if let subtitle { + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + } } } } var title: String { - let title = feed.title.stringByDecodingXMLCharacters() - if !title.isEmpty { + if let title = feed.title?.stringByDecodingXMLCharacters(), !title.isEmpty { return title } - return feed.urlForDisplay + return feed.urlForDisplay ?? "–" } - var subtitle: String { + var subtitle: String? { if let description = feed.feedDescription, !description.isEmpty { return description.stringByDecodingXMLCharacters() } @@ -51,7 +52,10 @@ extension SiteIconViewModel { private extension ReaderFeed { /// Strips the protocol and query from the URL. /// - var urlForDisplay: String { + var urlForDisplay: String? { + guard let url else { + return nil + } guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), let host = components.host else { return url.absoluteString diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderSiteSearchViewController.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderSiteSearchViewController.swift index e4111780617d..c7f8e7811cde 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderSiteSearchViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderSiteSearchViewController.swift @@ -25,13 +25,10 @@ class ReaderSiteSearchViewController: UITableViewController { reloadData() } } - fileprivate var totalFeedCount: Int = 0 var searchQuery: String? = nil { didSet { feeds = [] - totalFeedCount = 0 - syncHelper.syncContentWithUserInteraction(false) } } @@ -77,9 +74,8 @@ class ReaderSiteSearchViewController: UITableViewController { let service = ReaderSiteSearchService(coreDataStack: ContextManager.shared) service.performSearch(with: query, page: page, - success: { [weak self] (feeds, hasMore, totalFeeds) in + success: { [weak self] feeds, hasMore, _ in self?.feeds.append(contentsOf: feeds) - self?.totalFeedCount = totalFeeds self?.reloadData(hasMoreResults: hasMore) success?(hasMore) }, failure: { [weak self] error in @@ -175,7 +171,7 @@ private extension ReaderSiteSearchViewController { } func showLoadingView() { - configureAndDisplayStatus(title: StatusText.loadingTitle, accessoryView: NoResultsViewController.loadingAccessoryView()) + configureAndDisplayStatus(title: "", accessoryView: NoResultsViewController.loadingAccessoryView()) } func showLoadingFailedView() { @@ -208,11 +204,6 @@ private extension ReaderSiteSearchViewController { } struct StatusText { - static let loadingTitle = NSLocalizedString( - "reader.blog.search.loading.title", - value: "Fetching blogs...", - comment: "A brief prompt when the user is searching for blogs in the Reader." - ) static let loadingFailedTitle = NSLocalizedString( "reader.blog.search.loading.error", value: "Problem loading blogs",