Merge branch 'upstream-ios-candidate' into previewing-articles

This commit is contained in:
Mihael Cholakov 2020-01-06 14:45:10 +02:00
commit d1004569b2
52 changed files with 1908 additions and 1134 deletions

View File

@ -120,7 +120,7 @@ class FeedlyAddNewFeedOperationTests: XCTestCase {
XCTAssert(progress.isComplete) XCTAssert(progress.isComplete)
} }
func testAddNewFeedSuccess() { func testAddNewFeedSuccess() throws {
guard let folder = getFolderByLoadingInitialContent() else { guard let folder = getFolderByLoadingInitialContent() else {
return return
} }
@ -163,7 +163,7 @@ class FeedlyAddNewFeedOperationTests: XCTestCase {
XCTAssert(progress.isComplete) XCTAssert(progress.isComplete)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "feedStream", subdirectory: subdirectory) try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "feedStream", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self)
} }

View File

@ -50,7 +50,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: false) } let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: false) }
let insertExpectation = expectation(description: "Inserted Statuses") let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) { container.database.insertStatuses(statuses) { error in
XCTAssertNil(error)
insertExpectation.fulfill() insertExpectation.fulfill()
} }
@ -74,7 +75,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), 0) let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
container.database.selectPendingCount { result in
do {
let statusCount = try result.get()
XCTAssertEqual(statusCount, 0)
selectPendingCountExpectation.fulfill()
} catch {
XCTFail("Error unwrapping database result: \(error)")
}
}
waitForExpectations(timeout: 2)
} }
func testSendUnreadFailure() { func testSendUnreadFailure() {
@ -82,7 +93,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: false) } let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: false) }
let insertExpectation = expectation(description: "Inserted Statuses") let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) { container.database.insertStatuses(statuses) { error in
XCTAssertNil(error)
insertExpectation.fulfill() insertExpectation.fulfill()
} }
@ -106,7 +118,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), statuses.count) let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
container.database.selectPendingCount { result in
do {
let statusCount = try result.get()
XCTAssertEqual(statusCount, statuses.count)
selectPendingCountExpectation.fulfill()
} catch {
XCTFail("Error unwrapping database result: \(error)")
}
}
waitForExpectations(timeout: 2)
} }
func testSendReadSuccess() { func testSendReadSuccess() {
@ -114,7 +136,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: true) } let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: true) }
let insertExpectation = expectation(description: "Inserted Statuses") let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) { container.database.insertStatuses(statuses) { error in
XCTAssertNil(error)
insertExpectation.fulfill() insertExpectation.fulfill()
} }
@ -138,7 +161,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), 0) let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
container.database.selectPendingCount { result in
do {
let statusCount = try result.get()
XCTAssertEqual(statusCount, 0)
selectPendingCountExpectation.fulfill()
} catch {
XCTFail("Error unwrapping database result: \(error)")
}
}
waitForExpectations(timeout: 2)
} }
func testSendReadFailure() { func testSendReadFailure() {
@ -146,7 +179,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: true) } let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: true) }
let insertExpectation = expectation(description: "Inserted Statuses") let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) { container.database.insertStatuses(statuses) { error in
XCTAssertNil(error)
insertExpectation.fulfill() insertExpectation.fulfill()
} }
@ -170,7 +204,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), statuses.count) let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
container.database.selectPendingCount { result in
do {
let statusCount = try result.get()
XCTAssertEqual(statusCount, statuses.count)
selectPendingCountExpectation.fulfill()
} catch {
XCTFail("Error unwrapping database result: \(error)")
}
}
waitForExpectations(timeout: 2)
} }
func testSendStarredSuccess() { func testSendStarredSuccess() {
@ -178,7 +222,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: true) } let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: true) }
let insertExpectation = expectation(description: "Inserted Statuses") let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) { container.database.insertStatuses(statuses) { error in
XCTAssertNil(error)
insertExpectation.fulfill() insertExpectation.fulfill()
} }
@ -202,7 +247,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), 0) let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
container.database.selectPendingCount { result in
do {
let statusCount = try result.get()
XCTAssertEqual(statusCount, 0)
selectPendingCountExpectation.fulfill()
} catch {
XCTFail("Error unwrapping database result: \(error)")
}
}
waitForExpectations(timeout: 2)
} }
func testSendStarredFailure() { func testSendStarredFailure() {
@ -210,7 +265,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: true) } let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: true) }
let insertExpectation = expectation(description: "Inserted Statuses") let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) { container.database.insertStatuses(statuses) { error in
XCTAssertNil(error)
insertExpectation.fulfill() insertExpectation.fulfill()
} }
@ -234,7 +290,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), statuses.count) let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
container.database.selectPendingCount { result in
do {
let statusCount = try result.get()
XCTAssertEqual(statusCount, statuses.count)
selectPendingCountExpectation.fulfill()
} catch {
XCTFail("Error unwrapping database result: \(error)")
}
}
waitForExpectations(timeout: 2)
} }
func testSendUnstarredSuccess() { func testSendUnstarredSuccess() {
@ -242,7 +308,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: false) } let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: false) }
let insertExpectation = expectation(description: "Inserted Statuses") let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) { container.database.insertStatuses(statuses) { error in
XCTAssertNil(error)
insertExpectation.fulfill() insertExpectation.fulfill()
} }
@ -266,7 +333,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), 0) let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
container.database.selectPendingCount { result in
do {
let statusCount = try result.get()
XCTAssertEqual(statusCount, 0)
selectPendingCountExpectation.fulfill()
} catch {
XCTFail("Error unwrapping database result: \(error)")
}
}
waitForExpectations(timeout: 2)
} }
func testSendUnstarredFailure() { func testSendUnstarredFailure() {
@ -274,7 +351,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: false) } let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: false) }
let insertExpectation = expectation(description: "Inserted Statuses") let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) { container.database.insertStatuses(statuses) { error in
XCTAssertNil(error)
insertExpectation.fulfill() insertExpectation.fulfill()
} }
@ -298,7 +376,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), statuses.count) let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
container.database.selectPendingCount { result in
do {
let expectedCount = try result.get()
XCTAssertEqual(expectedCount, statuses.count)
selectPendingCountExpectation.fulfill()
} catch {
XCTFail("Error unwrapping database result: \(error)")
}
}
waitForExpectations(timeout: 2)
} }
func testSendAllSuccess() { func testSendAllSuccess() {
@ -313,7 +401,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
} }
let insertExpectation = expectation(description: "Inserted Statuses") let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) { container.database.insertStatuses(statuses) { error in
XCTAssertNil(error)
insertExpectation.fulfill() insertExpectation.fulfill()
} }
@ -346,7 +435,18 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
OperationQueue.main.addOperation(send) OperationQueue.main.addOperation(send)
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), 0)
let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
container.database.selectPendingCount { result in
do {
let statusCount = try result.get()
XCTAssertEqual(statusCount, 0)
selectPendingCountExpectation.fulfill()
} catch {
XCTFail("Error unwrapping database result: \(error)")
}
}
waitForExpectations(timeout: 2)
} }
func testSendAllFailure() { func testSendAllFailure() {
@ -361,7 +461,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
} }
let insertExpectation = expectation(description: "Inserted Statuses") let insertExpectation = expectation(description: "Inserted Statuses")
container.database.insertStatuses(statuses) { container.database.insertStatuses(statuses) { error in
XCTAssertNil(error)
insertExpectation.fulfill() insertExpectation.fulfill()
} }
@ -396,6 +497,16 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
XCTAssertEqual(container.database.selectPendingCount(), statuses.count) let selectPendingCountExpectation = expectation(description: "Did Select Pending Count")
container.database.selectPendingCount { result in
do {
let statusCount = try result.get()
XCTAssertEqual(statusCount, statuses.count)
selectPendingCountExpectation.fulfill()
} catch {
XCTFail("Error unwrapping database result: \(error)")
}
}
waitForExpectations(timeout: 2)
} }
} }

View File

@ -49,10 +49,15 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchStarredArticleIDs { accountArticlesIDs in account.fetchStarredArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertTrue(accountArticlesIDs.isEmpty) XCTAssertTrue(accountArticlesIDs.isEmpty)
XCTAssertEqual(accountArticlesIDs, testIds) XCTAssertEqual(accountArticlesIDs, testIds)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking articles IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -73,9 +78,14 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchStarredArticleIDs { accountArticlesIDs in account.fetchStarredArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertEqual(accountArticlesIDs.count, testIds.count) XCTAssertEqual(accountArticlesIDs.count, testIds.count)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking articles IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -96,9 +106,14 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchStarredArticleIDs { accountArticlesIDs in account.fetchStarredArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertEqual(accountArticlesIDs.count, testIds.count) XCTAssertEqual(accountArticlesIDs.count, testIds.count)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking articles IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -134,9 +149,14 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchStarredArticleIDs { remainingAccountArticlesIDs in account.fetchStarredArticleIDs { remainingAccountArticlesIDsResult in
do {
let remainingAccountArticlesIDs = try remainingAccountArticlesIDsResult.get()
XCTAssertEqual(remainingAccountArticlesIDs, remainingStarredIds) XCTAssertEqual(remainingAccountArticlesIDs, remainingStarredIds)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking articles IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -172,9 +192,14 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchStarredArticleIDs { remainingAccountArticlesIDs in account.fetchStarredArticleIDs { remainingAccountArticlesIDsResult in
do {
let remainingAccountArticlesIDs = try remainingAccountArticlesIDsResult.get()
XCTAssertEqual(remainingAccountArticlesIDs, remainingStarredIds) XCTAssertEqual(remainingAccountArticlesIDs, remainingStarredIds)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking articles IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -221,15 +246,21 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchStarredArticleIDs { accountArticlesIDs in account.fetchStarredArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertEqual(accountArticlesIDs, remainingStarredIds) XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
let idsOfStarredArticles = Set(self.account let idsOfStarredArticles = Set(try self.account
.fetchArticles(.articleIDs(remainingStarredIds)) .fetchArticles(.articleIDs(remainingStarredIds))
.filter { $0.status.boolStatus(forKey: .starred) == true } .filter { $0.status.boolStatus(forKey: .starred) == true }
.map { $0.articleID }) .map { $0.articleID })
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds) XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking articles IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -274,15 +305,21 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchStarredArticleIDs { accountArticlesIDs in account.fetchStarredArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertEqual(accountArticlesIDs, remainingStarredIds) XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
let idsOfStarredArticles = Set(self.account let idsOfStarredArticles = Set(try self.account
.fetchArticles(.articleIDs(remainingStarredIds)) .fetchArticles(.articleIDs(remainingStarredIds))
.filter { $0.status.boolStatus(forKey: .starred) == true } .filter { $0.status.boolStatus(forKey: .starred) == true }
.map { $0.articleID }) .map { $0.articleID })
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds) XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking articles IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -321,16 +358,21 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchStarredArticleIDs { accountArticlesIDs in account.fetchStarredArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertEqual(accountArticlesIDs, remainingStarredIds) XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
let idsOfStarredArticles = Set(self.account let idsOfStarredArticles = Set(try self.account
.fetchArticles(.articleIDs(remainingStarredIds)) .fetchArticles(.articleIDs(remainingStarredIds))
.filter { $0.status.boolStatus(forKey: .starred) == true } .filter { $0.status.boolStatus(forKey: .starred) == true }
.map { $0.articleID }) .map { $0.articleID })
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds) XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking articles IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -368,16 +410,21 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchStarredArticleIDs { accountArticlesIDs in account.fetchStarredArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertEqual(accountArticlesIDs, remainingStarredIds) XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
let idsOfStarredArticles = Set(self.account let idsOfStarredArticles = Set(try self.account
.fetchArticles(.articleIDs(remainingStarredIds)) .fetchArticles(.articleIDs(remainingStarredIds))
.filter { $0.status.boolStatus(forKey: .starred) == true } .filter { $0.status.boolStatus(forKey: .starred) == true }
.map { $0.articleID }) .map { $0.articleID })
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds) XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking articles IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -418,12 +465,14 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchStarredArticleIDs { accountArticlesIDs in account.fetchStarredArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertEqual(accountArticlesIDs, remainingStarredIds) XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
let someTestItems = Set(someItemsAndFeeds.flatMap { $0.value }) let someTestItems = Set(someItemsAndFeeds.flatMap { $0.value })
let someRemainingStarredIdsOfIngestedArticles = Set(someTestItems.compactMap { $0.syncServiceID }) let someRemainingStarredIdsOfIngestedArticles = Set(someTestItems.compactMap { $0.syncServiceID })
let idsOfStarredArticles = Set(self.account let idsOfStarredArticles = Set(try self.account
.fetchArticles(.articleIDs(someRemainingStarredIdsOfIngestedArticles)) .fetchArticles(.articleIDs(someRemainingStarredIdsOfIngestedArticles))
.filter { $0.status.boolStatus(forKey: .starred) == true } .filter { $0.status.boolStatus(forKey: .starred) == true }
.map { $0.articleID }) .map { $0.articleID })
@ -431,6 +480,9 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
XCTAssertEqual(idsOfStarredArticles, someRemainingStarredIdsOfIngestedArticles) XCTAssertEqual(idsOfStarredArticles, someRemainingStarredIdsOfIngestedArticles)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking articles IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }

View File

@ -49,10 +49,15 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids") let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
account.fetchUnreadArticleIDs { accountArticlesIDs in account.fetchUnreadArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertTrue(accountArticlesIDs.isEmpty) XCTAssertTrue(accountArticlesIDs.isEmpty)
XCTAssertEqual(accountArticlesIDs.count, testIds.count) XCTAssertEqual(accountArticlesIDs.count, testIds.count)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking account articles IDs result: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
@ -74,9 +79,14 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids") let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
account.fetchUnreadArticleIDs { accountArticlesIDs in account.fetchUnreadArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertEqual(accountArticlesIDs.count, testIds.count) XCTAssertEqual(accountArticlesIDs.count, testIds.count)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking account articles IDs result: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -97,9 +107,14 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids") let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
account.fetchUnreadArticleIDs { accountArticlesIDs in account.fetchUnreadArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertEqual(accountArticlesIDs.count, testIds.count) XCTAssertEqual(accountArticlesIDs.count, testIds.count)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking account articles IDs result: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -135,9 +150,14 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids") let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
account.fetchUnreadArticleIDs { remainingAccountArticlesIDs in account.fetchUnreadArticleIDs { remainingAccountArticlesIDsResult in
do {
let remainingAccountArticlesIDs = try remainingAccountArticlesIDsResult.get()
XCTAssertEqual(remainingAccountArticlesIDs, remainingUnreadIds) XCTAssertEqual(remainingAccountArticlesIDs, remainingUnreadIds)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking account articles IDs result: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -173,9 +193,14 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids") let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
account.fetchUnreadArticleIDs { remainingAccountArticlesIDs in account.fetchUnreadArticleIDs { remainingAccountArticlesIDsResult in
do {
let remainingAccountArticlesIDs = try remainingAccountArticlesIDsResult.get()
XCTAssertEqual(remainingAccountArticlesIDs, remainingUnreadIds) XCTAssertEqual(remainingAccountArticlesIDs, remainingUnreadIds)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking account articles IDs result: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -222,15 +247,20 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids") let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
account.fetchUnreadArticleIDs { accountArticlesIDs in account.fetchUnreadArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds) XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
let idsOfUnreadArticles = Set(self.account let idsOfUnreadArticles = Set(try self.account
.fetchArticles(.articleIDs(remainingUnreadIds)) .fetchArticles(.articleIDs(remainingUnreadIds))
.filter { $0.status.boolStatus(forKey: .read) == false } .filter { $0.status.boolStatus(forKey: .read) == false }
.map { $0.articleID }) .map { $0.articleID })
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds) XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking account articles IDs result: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -275,16 +305,21 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids") let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
account.fetchUnreadArticleIDs { accountArticlesIDs in account.fetchUnreadArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds) XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
let idsOfUnreadArticles = Set(self.account let idsOfUnreadArticles = Set(try self.account
.fetchArticles(.articleIDs(remainingUnreadIds)) .fetchArticles(.articleIDs(remainingUnreadIds))
.filter { $0.status.boolStatus(forKey: .read) == false } .filter { $0.status.boolStatus(forKey: .read) == false }
.map { $0.articleID }) .map { $0.articleID })
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds) XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking account articles IDs result: \(error)")
}
} }
} }
@ -322,16 +357,21 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids") let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
account.fetchUnreadArticleIDs { accountArticlesIDs in account.fetchUnreadArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds) XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
let idsOfUnreadArticles = Set(self.account let idsOfUnreadArticles = Set(try self.account
.fetchArticles(.articleIDs(remainingUnreadIds)) .fetchArticles(.articleIDs(remainingUnreadIds))
.filter { $0.status.boolStatus(forKey: .read) == false } .filter { $0.status.boolStatus(forKey: .read) == false }
.map { $0.articleID }) .map { $0.articleID })
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds) XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking account articles IDs result: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -369,16 +409,21 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids") let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
account.fetchUnreadArticleIDs { accountArticlesIDs in account.fetchUnreadArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds) XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
let idsOfUnreadArticles = Set(self.account let idsOfUnreadArticles = Set(try self.account
.fetchArticles(.articleIDs(remainingUnreadIds)) .fetchArticles(.articleIDs(remainingUnreadIds))
.filter { $0.status.boolStatus(forKey: .read) == false } .filter { $0.status.boolStatus(forKey: .read) == false }
.map { $0.articleID }) .map { $0.articleID })
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds) XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking account articles IDs result: \(error)")
}
} }
} }
@ -418,18 +463,23 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids") let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
account.fetchUnreadArticleIDs { accountArticlesIDs in account.fetchUnreadArticleIDs { accountArticlesIDsResult in
do {
let accountArticlesIDs = try accountArticlesIDsResult.get()
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds) XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
let someTestItems = Set(someItemsAndFeeds.flatMap { $0.value }) let someTestItems = Set(someItemsAndFeeds.flatMap { $0.value })
let someRemainingUnreadIdsOfIngestedArticles = Set(someTestItems.compactMap { $0.syncServiceID }) let someRemainingUnreadIdsOfIngestedArticles = Set(someTestItems.compactMap { $0.syncServiceID })
let idsOfUnreadArticles = Set(self.account let idsOfUnreadArticles = Set(try self.account
.fetchArticles(.articleIDs(someRemainingUnreadIdsOfIngestedArticles)) .fetchArticles(.articleIDs(someRemainingUnreadIdsOfIngestedArticles))
.filter { $0.status.boolStatus(forKey: .read) == false } .filter { $0.status.boolStatus(forKey: .read) == false }
.map { $0.articleID }) .map { $0.articleID })
XCTAssertEqual(idsOfUnreadArticles, someRemainingUnreadIdsOfIngestedArticles) XCTAssertEqual(idsOfUnreadArticles, someRemainingUnreadIdsOfIngestedArticles)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking account articles IDs result: \(error)")
}
} }
} }
} }

View File

@ -114,18 +114,18 @@ class FeedlySyncAllOperationTests: XCTestCase {
return caller return caller
}() }()
func testSyncing() { func testSyncing() throws {
performInitialSync() performInitialSync()
verifyInitialSync() try verifyInitialSync()
performChangeStatuses() performChangeStatuses()
verifyChangeStatuses() try verifyChangeStatuses()
performChangeStatusesAgain() performChangeStatusesAgain()
verifyChangeStatusesAgain() try verifyChangeStatusesAgain()
performAddFeedsAndFolders() performAddFeedsAndFolders()
verifyAddFeedsAndFolders() try verifyAddFeedsAndFolders()
} }
// MARK: 1 - Initial Sync // MARK: 1 - Initial Sync
@ -166,15 +166,15 @@ class FeedlySyncAllOperationTests: XCTestCase {
loadMockData(inSubdirectoryNamed: "feedly-1-initial") loadMockData(inSubdirectoryNamed: "feedly-1-initial")
} }
func verifyInitialSync() { func verifyInitialSync() throws {
let subdirectory = "feedly-1-initial" let subdirectory = "feedly-1-initial"
support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory) support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory) try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all@MTZkOTdkZWQ1NzM6NTE2OjUzYjgyNmEy", subdirectory: subdirectory) try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all@MTZkOTdkZWQ1NzM6NTE2OjUzYjgyNmEy", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOTRhOTNhZTQ6MzExOjUzYjgyNmEy", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOTRhOTNhZTQ6MzExOjUzYjgyNmEy", subdirectory: subdirectory, testCase: self)
support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory, testCase: self) support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory, testCase: self)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory) try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory)
} }
// MARK: 2 - Change Statuses // MARK: 2 - Change Statuses
@ -183,14 +183,14 @@ class FeedlySyncAllOperationTests: XCTestCase {
loadMockData(inSubdirectoryNamed: "feedly-2-changestatuses") loadMockData(inSubdirectoryNamed: "feedly-2-changestatuses")
} }
func verifyChangeStatuses() { func verifyChangeStatuses() throws {
let subdirectory = "feedly-2-changestatuses" let subdirectory = "feedly-2-changestatuses"
support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory) support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory) try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOTJkNjIwM2Q6MTEzYjpkNDUwNjA3MQ==", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOTJkNjIwM2Q6MTEzYjpkNDUwNjA3MQ==", subdirectory: subdirectory, testCase: self)
support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory, testCase: self) support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory, testCase: self)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory) try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory)
} }
// MARK: 3 - Change Statuses Again // MARK: 3 - Change Statuses Again
@ -199,14 +199,14 @@ class FeedlySyncAllOperationTests: XCTestCase {
loadMockData(inSubdirectoryNamed: "feedly-3-changestatusesagain") loadMockData(inSubdirectoryNamed: "feedly-3-changestatusesagain")
} }
func verifyChangeStatusesAgain() { func verifyChangeStatusesAgain() throws {
let subdirectory = "feedly-3-changestatusesagain" let subdirectory = "feedly-3-changestatusesagain"
support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory) support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory) try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOGRlMjVmM2M6M2YyOmQ0NTA2MDcx", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOGRlMjVmM2M6M2YyOmQ0NTA2MDcx", subdirectory: subdirectory, testCase: self)
support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory, testCase: self) support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory, testCase: self)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory) try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory)
} }
// MARK: 4 - Add Feeds and Folders // MARK: 4 - Add Feeds and Folders
@ -215,14 +215,14 @@ class FeedlySyncAllOperationTests: XCTestCase {
loadMockData(inSubdirectoryNamed: "feedly-4-addfeedsandfolders") loadMockData(inSubdirectoryNamed: "feedly-4-addfeedsandfolders")
} }
func verifyAddFeedsAndFolders() { func verifyAddFeedsAndFolders() throws {
let subdirectory = "feedly-4-addfeedsandfolders" let subdirectory = "feedly-4-addfeedsandfolders"
support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory) support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory) try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOTE3YTRlMzQ6YWZjOmQ0NTA2MDcx", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOTE3YTRlMzQ6YWZjOmQ0NTA2MDcx", subdirectory: subdirectory, testCase: self)
support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory, testCase: self) support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory, testCase: self)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory) try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory)
} }
// MARK: 5 - Remove Feeds and Folders // MARK: 5 - Remove Feeds and Folders
@ -231,14 +231,14 @@ class FeedlySyncAllOperationTests: XCTestCase {
loadMockData(inSubdirectoryNamed: "feedly-5-removefeedsandfolders") loadMockData(inSubdirectoryNamed: "feedly-5-removefeedsandfolders")
} }
func verifyRemoveFeedsAndFolders() { func verifyRemoveFeedsAndFolders() throws {
let subdirectory = "feedly-5-removefeedsandfolders" let subdirectory = "feedly-5-removefeedsandfolders"
support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory) support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory) try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "global.all", subdirectory: subdirectory)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds", subdirectory: subdirectory, testCase: self)
support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOGRlMjVmM2M6M2YxOmQ0NTA2MDcx", subdirectory: subdirectory, testCase: self) support.checkUnreadStatuses(in: account, againstIdsInStreamInJSONNamed: "unreadIds@MTZkOGRlMjVmM2M6M2YxOmQ0NTA2MDcx", subdirectory: subdirectory, testCase: self)
support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory, testCase: self) support.checkStarredStatuses(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory, testCase: self)
support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory) try support.checkArticles(in: account, againstItemsInStreamInJSONNamed: "starred", subdirectory: subdirectory)
} }
// MARK: Downloading Test Data // MARK: Downloading Test Data

View File

@ -56,21 +56,26 @@ class FeedlySyncStarredArticlesOperationTests: XCTestCase {
let expectedArticleIds = Set(items.map { $0.id }) let expectedArticleIds = Set(items.map { $0.id })
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchStarredArticleIDs { starredArticleIds in account.fetchStarredArticleIDs { starredArticleIdsResult in
do {
let starredArticleIds = try starredArticleIdsResult.get()
let missingIds = expectedArticleIds.subtracting(starredArticleIds) let missingIds = expectedArticleIds.subtracting(starredArticleIds)
XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as starred.") XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as starred.")
// Fetch articles directly because account.fetchArticles(.starred) fetches starred articles for feeds subscribed to. // Fetch articles directly because account.fetchArticles(.starred) fetches starred articles for feeds subscribed to.
let expectedArticles = self.account.fetchArticles(.articleIDs(expectedArticleIds)) let expectedArticles = try self.account.fetchArticles(.articleIDs(expectedArticleIds))
XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.") XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.")
let starredArticles = self.account.fetchArticles(.articleIDs(starredArticleIds)) let starredArticles = try self.account.fetchArticles(.articleIDs(starredArticleIds))
XCTAssertEqual(expectedArticleIds.count, expectedArticles.count) XCTAssertEqual(expectedArticleIds.count, expectedArticles.count)
let missingArticles = expectedArticles.subtracting(starredArticles) let missingArticles = expectedArticles.subtracting(starredArticles)
XCTAssertTrue(missingArticles.isEmpty, "These articles should be starred and fetched.") XCTAssertTrue(missingArticles.isEmpty, "These articles should be starred and fetched.")
XCTAssertEqual(expectedArticles, starredArticles) XCTAssertEqual(expectedArticles, starredArticles)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking starred article IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -104,9 +109,14 @@ class FeedlySyncStarredArticlesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchStarredArticleIDs { starredArticleIds in account.fetchStarredArticleIDs { starredArticleIdsResult in
do {
let starredArticleIds = try starredArticleIdsResult.get()
XCTAssertTrue(starredArticleIds.isEmpty) XCTAssertTrue(starredArticleIds.isEmpty)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking starred article IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -153,21 +163,26 @@ class FeedlySyncStarredArticlesOperationTests: XCTestCase {
// Find articles inserted. // Find articles inserted.
let expectedArticleIds = Set(service.pages.values.map { $0.items }.flatMap { $0 }.map { $0.id }) let expectedArticleIds = Set(service.pages.values.map { $0.items }.flatMap { $0 }.map { $0.id })
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchStarredArticleIDs { starredArticleIds in account.fetchStarredArticleIDs { starredArticleIdsResult in
do {
let starredArticleIds = try starredArticleIdsResult.get()
let missingIds = expectedArticleIds.subtracting(starredArticleIds) let missingIds = expectedArticleIds.subtracting(starredArticleIds)
XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as starred.") XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as starred.")
// Fetch articles directly because account.fetchArticles(.starred) fetches starred articles for feeds subscribed to. // Fetch articles directly because account.fetchArticles(.starred) fetches starred articles for feeds subscribed to.
let expectedArticles = self.account.fetchArticles(.articleIDs(expectedArticleIds)) let expectedArticles = try self.account.fetchArticles(.articleIDs(expectedArticleIds))
XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.") XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.")
let starredArticles = self.account.fetchArticles(.articleIDs(starredArticleIds)) let starredArticles = try self.account.fetchArticles(.articleIDs(starredArticleIds))
XCTAssertEqual(expectedArticleIds.count, expectedArticles.count) XCTAssertEqual(expectedArticleIds.count, expectedArticles.count)
let missingArticles = expectedArticles.subtracting(starredArticles) let missingArticles = expectedArticles.subtracting(starredArticles)
XCTAssertTrue(missingArticles.isEmpty, "These articles should be starred and fetched.") XCTAssertTrue(missingArticles.isEmpty, "These articles should be starred and fetched.")
XCTAssertEqual(expectedArticles, starredArticles) XCTAssertEqual(expectedArticles, starredArticles)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking starred article IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }

View File

@ -26,7 +26,7 @@ class FeedlySyncStreamContentsOperationTests: XCTestCase {
super.tearDown() super.tearDown()
} }
func testIngestsOnePageSuccess() { func testIngestsOnePageSuccess() throws {
let service = TestGetStreamContentsService() let service = TestGetStreamContentsService()
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678") let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 0) let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 0)
@ -56,7 +56,7 @@ class FeedlySyncStreamContentsOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let expectedArticleIds = Set(items.map { $0.id }) let expectedArticleIds = Set(items.map { $0.id })
let expectedArticles = account.fetchArticles(.articleIDs(expectedArticleIds)) let expectedArticles = try account.fetchArticles(.articleIDs(expectedArticleIds))
XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.") XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.")
} }
@ -90,7 +90,7 @@ class FeedlySyncStreamContentsOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
func testIngestsManyPagesSuccess() { func testIngestsManyPagesSuccess() throws {
let service = TestGetPagedStreamContentsService() let service = TestGetPagedStreamContentsService()
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678") let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 0) let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 0)
@ -132,7 +132,7 @@ class FeedlySyncStreamContentsOperationTests: XCTestCase {
// Find articles inserted. // Find articles inserted.
let articleIds = Set(service.pages.values.map { $0.items }.flatMap { $0 }.map { $0.id }) let articleIds = Set(service.pages.values.map { $0.items }.flatMap { $0 }.map { $0.id })
let articles = account.fetchArticles(.articleIDs(articleIds)) let articles = try account.fetchArticles(.articleIDs(articleIds))
XCTAssertEqual(articleIds.count, articles.count) XCTAssertEqual(articleIds.count, articles.count)
} }
} }

View File

@ -56,10 +56,15 @@ class FeedlySyncUnreadStatusesOperationTests: XCTestCase {
let expectedArticleIds = Set(ids) let expectedArticleIds = Set(ids)
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchUnreadArticleIDs { unreadArticleIds in account.fetchUnreadArticleIDs { unreadArticleIdsResult in
do {
let unreadArticleIds = try unreadArticleIdsResult.get()
let missingIds = expectedArticleIds.subtracting(unreadArticleIds) let missingIds = expectedArticleIds.subtracting(unreadArticleIds)
XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as unread.") XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as unread.")
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking unread article IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -93,9 +98,14 @@ class FeedlySyncUnreadStatusesOperationTests: XCTestCase {
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchUnreadArticleIDs { unreadArticleIds in account.fetchUnreadArticleIDs { unreadArticleIdsResult in
do {
let unreadArticleIds = try unreadArticleIdsResult.get()
XCTAssertTrue(unreadArticleIds.isEmpty) XCTAssertTrue(unreadArticleIds.isEmpty)
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking unread article IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }
@ -142,10 +152,15 @@ class FeedlySyncUnreadStatusesOperationTests: XCTestCase {
// Find statuses inserted. // Find statuses inserted.
let expectedArticleIds = Set(service.pages.values.map { $0.ids }.flatMap { $0 }) let expectedArticleIds = Set(service.pages.values.map { $0.ids }.flatMap { $0 })
let fetchIdsExpectation = expectation(description: "Fetch Article Ids") let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
account.fetchUnreadArticleIDs { unreadArticleIds in account.fetchUnreadArticleIDs { unreadArticleIdsResult in
do {
let unreadArticleIds = try unreadArticleIdsResult.get()
let missingIds = expectedArticleIds.subtracting(unreadArticleIds) let missingIds = expectedArticleIds.subtracting(unreadArticleIds)
XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as unread.") XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as unread.")
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error checking unread article IDs: \(error)")
}
} }
waitForExpectations(timeout: 2) waitForExpectations(timeout: 2)
} }

View File

@ -141,13 +141,13 @@ class FeedlyTestSupport {
XCTAssertTrue(missingFeedIds.isEmpty, "Feeds with these ids were not found in the \"\(label)\" folder.") XCTAssertTrue(missingFeedIds.isEmpty, "Feeds with these ids were not found in the \"\(label)\" folder.")
} }
func checkArticles(in account: Account, againstItemsInStreamInJSONNamed name: String, subdirectory: String? = nil) { func checkArticles(in account: Account, againstItemsInStreamInJSONNamed name: String, subdirectory: String? = nil) throws {
let stream = testJSON(named: name, subdirectory: subdirectory) as! [String:Any] let stream = testJSON(named: name, subdirectory: subdirectory) as! [String:Any]
checkArticles(in: account, againstItemsInStreamInJSONPayload: stream) try checkArticles(in: account, againstItemsInStreamInJSONPayload: stream)
} }
func checkArticles(in account: Account, againstItemsInStreamInJSONPayload stream: [String: Any]) { func checkArticles(in account: Account, againstItemsInStreamInJSONPayload stream: [String: Any]) throws {
checkArticles(in: account, correspondToStreamItemsIn: stream) try checkArticles(in: account, correspondToStreamItemsIn: stream)
} }
private struct ArticleItem { private struct ArticleItem {
@ -188,13 +188,13 @@ class FeedlyTestSupport {
} }
/// Awkwardly titled to make it clear the JSON given is from a stream response. /// Awkwardly titled to make it clear the JSON given is from a stream response.
func checkArticles(in testAccount: Account, correspondToStreamItemsIn stream: [String: Any]) { func checkArticles(in testAccount: Account, correspondToStreamItemsIn stream: [String: Any]) throws {
let items = stream["items"] as! [[String: Any]] let items = stream["items"] as! [[String: Any]]
let articleItems = items.map { ArticleItem(item: $0) } let articleItems = items.map { ArticleItem(item: $0) }
let itemIds = Set(articleItems.map { $0.id }) let itemIds = Set(articleItems.map { $0.id })
let articles = testAccount.fetchArticles(.articleIDs(itemIds)) let articles = try testAccount.fetchArticles(.articleIDs(itemIds))
let articleIds = Set(articles.map { $0.articleID }) let articleIds = Set(articles.map { $0.articleID })
let missing = itemIds.subtracting(articleIds) let missing = itemIds.subtracting(articleIds)
@ -220,12 +220,17 @@ class FeedlyTestSupport {
func checkUnreadStatuses(in testAccount: Account, correspondToIdsInJSONPayload streamIds: [String: Any], testCase: XCTestCase) { func checkUnreadStatuses(in testAccount: Account, correspondToIdsInJSONPayload streamIds: [String: Any], testCase: XCTestCase) {
let ids = Set(streamIds["ids"] as! [String]) let ids = Set(streamIds["ids"] as! [String])
let fetchIdsExpectation = testCase.expectation(description: "Fetch Article Ids") let fetchIdsExpectation = testCase.expectation(description: "Fetch Article Ids")
testAccount.fetchUnreadArticleIDs { articleIds in testAccount.fetchUnreadArticleIDs { articleIdsResult in
do {
let articleIds = try articleIdsResult.get()
// Unread statuses can be paged from Feedly. // Unread statuses can be paged from Feedly.
// Instead of joining test data, the best we can do is // Instead of joining test data, the best we can do is
// make sure that these ids are marked as unread (a subset of the total). // make sure that these ids are marked as unread (a subset of the total).
XCTAssertTrue(ids.isSubset(of: articleIds), "Some articles in `ids` are not marked as unread.") XCTAssertTrue(ids.isSubset(of: articleIds), "Some articles in `ids` are not marked as unread.")
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error unwrapping article IDs: \(error)")
}
} }
testCase.wait(for: [fetchIdsExpectation], timeout: 2) testCase.wait(for: [fetchIdsExpectation], timeout: 2)
} }
@ -239,12 +244,17 @@ class FeedlyTestSupport {
let items = stream["items"] as! [[String: Any]] let items = stream["items"] as! [[String: Any]]
let ids = Set(items.map { $0["id"] as! String }) let ids = Set(items.map { $0["id"] as! String })
let fetchIdsExpectation = testCase.expectation(description: "Fetch Article Ids") let fetchIdsExpectation = testCase.expectation(description: "Fetch Article Ids")
testAccount.fetchStarredArticleIDs { articleIds in testAccount.fetchStarredArticleIDs { articleIdsResult in
do {
let articleIds = try articleIdsResult.get()
// Starred articles can be paged from Feedly. // Starred articles can be paged from Feedly.
// Instead of joining test data, the best we can do is // Instead of joining test data, the best we can do is
// make sure that these articles are marked as starred (a subset of the total). // make sure that these articles are marked as starred (a subset of the total).
XCTAssertTrue(ids.isSubset(of: articleIds), "Some articles in `ids` are not marked as starred.") XCTAssertTrue(ids.isSubset(of: articleIds), "Some articles in `ids` are not marked as starred.")
fetchIdsExpectation.fulfill() fetchIdsExpectation.fulfill()
} catch {
XCTFail("Error unwrapping article IDs: \(error)")
}
} }
testCase.wait(for: [fetchIdsExpectation], timeout: 2) testCase.wait(for: [fetchIdsExpectation], timeout: 2)
} }

View File

@ -32,7 +32,7 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase {
var parsedItemsKeyedByFeedId: [String: Set<ParsedItem>] var parsedItemsKeyedByFeedId: [String: Set<ParsedItem>]
} }
func testUpdateAccountWithEmptyItems() { func testUpdateAccountWithEmptyItems() throws {
let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 0, numberOfItemsInFeeds: 0) let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 0, numberOfItemsInFeeds: 0)
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789") let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems) let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems)
@ -52,11 +52,11 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase {
let articleIds = Set(entries.compactMap { $0.syncServiceID }) let articleIds = Set(entries.compactMap { $0.syncServiceID })
XCTAssertEqual(articleIds.count, entries.count, "Not every item has a value for \(\ParsedItem.syncServiceID).") XCTAssertEqual(articleIds.count, entries.count, "Not every item has a value for \(\ParsedItem.syncServiceID).")
let accountArticles = account.fetchArticles(.articleIDs(articleIds)) let accountArticles = try account.fetchArticles(.articleIDs(articleIds))
XCTAssertTrue(accountArticles.isEmpty) XCTAssertTrue(accountArticles.isEmpty)
} }
func testUpdateAccountWithOneItem() { func testUpdateAccountWithOneItem() throws {
let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 1, numberOfItemsInFeeds: 1) let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 1, numberOfItemsInFeeds: 1)
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789") let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems) let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems)
@ -76,7 +76,7 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase {
let articleIds = Set(entries.compactMap { $0.syncServiceID }) let articleIds = Set(entries.compactMap { $0.syncServiceID })
XCTAssertEqual(articleIds.count, entries.count, "Not every item has a value for \(\ParsedItem.syncServiceID).") XCTAssertEqual(articleIds.count, entries.count, "Not every item has a value for \(\ParsedItem.syncServiceID).")
let accountArticles = account.fetchArticles(.articleIDs(articleIds)) let accountArticles = try account.fetchArticles(.articleIDs(articleIds))
XCTAssertTrue(accountArticles.count == entries.count) XCTAssertTrue(accountArticles.count == entries.count)
let accountArticleIds = Set(accountArticles.map { $0.articleID }) let accountArticleIds = Set(accountArticles.map { $0.articleID })
@ -84,7 +84,7 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase {
XCTAssertTrue(missingIds.isEmpty) XCTAssertTrue(missingIds.isEmpty)
} }
func testUpdateAccountWithManyItems() { func testUpdateAccountWithManyItems() throws {
let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 100, numberOfItemsInFeeds: 100) let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 100, numberOfItemsInFeeds: 100)
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789") let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems) let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems)
@ -104,7 +104,7 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase {
let articleIds = Set(entries.compactMap { $0.syncServiceID }) let articleIds = Set(entries.compactMap { $0.syncServiceID })
XCTAssertEqual(articleIds.count, entries.count, "Not every item has a value for \(\ParsedItem.syncServiceID).") XCTAssertEqual(articleIds.count, entries.count, "Not every item has a value for \(\ParsedItem.syncServiceID).")
let accountArticles = account.fetchArticles(.articleIDs(articleIds)) let accountArticles = try account.fetchArticles(.articleIDs(articleIds))
XCTAssertTrue(accountArticles.count == entries.count) XCTAssertTrue(accountArticles.count == entries.count)
let accountArticleIds = Set(accountArticles.map { $0.articleID }) let accountArticleIds = Set(accountArticles.map { $0.articleID })
@ -112,7 +112,7 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase {
XCTAssertTrue(missingIds.isEmpty) XCTAssertTrue(missingIds.isEmpty)
} }
func testCancelUpdateAccount() { func testCancelUpdateAccount() throws {
let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 1, numberOfItemsInFeeds: 1) let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 1, numberOfItemsInFeeds: 1)
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789") let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems) let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems)
@ -134,7 +134,7 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase {
let articleIds = Set(entries.compactMap { $0.syncServiceID }) let articleIds = Set(entries.compactMap { $0.syncServiceID })
XCTAssertEqual(articleIds.count, entries.count, "Not every item has a value for \(\ParsedItem.syncServiceID).") XCTAssertEqual(articleIds.count, entries.count, "Not every item has a value for \(\ParsedItem.syncServiceID).")
let accountArticles = account.fetchArticles(.articleIDs(articleIds)) let accountArticles = try account.fetchArticles(.articleIDs(articleIds))
XCTAssertTrue(accountArticles.isEmpty) XCTAssertTrue(accountArticles.isEmpty)
} }
} }

View File

@ -1237,20 +1237,38 @@ private extension FeedbinAccountDelegate {
return return
} }
database.selectPendingReadStatusArticleIDs() { result in
func process(_ pendingArticleIDs: Set<String>) {
let feedbinUnreadArticleIDs = Set(articleIDs.map { String($0) } ) let feedbinUnreadArticleIDs = Set(articleIDs.map { String($0) } )
let updatableFeedbinUnreadArticleIDs = feedbinUnreadArticleIDs.subtracting(pendingArticleIDs)
account.fetchUnreadArticleIDs { articleIDsResult in account.fetchUnreadArticleIDs { articleIDsResult in
guard let currentUnreadArticleIDs = try? articleIDsResult.get() else { guard let currentUnreadArticleIDs = try? articleIDsResult.get() else {
return return
} }
// Mark articles as unread // Mark articles as unread
let deltaUnreadArticleIDs = feedbinUnreadArticleIDs.subtracting(currentUnreadArticleIDs) let deltaUnreadArticleIDs = updatableFeedbinUnreadArticleIDs.subtracting(currentUnreadArticleIDs)
account.markAsUnread(deltaUnreadArticleIDs) account.markAsUnread(deltaUnreadArticleIDs)
// Mark articles as read // Mark articles as read
let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(feedbinUnreadArticleIDs) let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableFeedbinUnreadArticleIDs)
account.markAsRead(deltaReadArticleIDs) account.markAsRead(deltaReadArticleIDs)
} }
}
switch result {
case .success(let pendingArticleIDs):
process(pendingArticleIDs)
case .failure(let error):
os_log(.error, log: self.log, "Sync Article Read Status failed: %@.", error.localizedDescription)
}
}
} }
func syncArticleStarredState(account: Account, articleIDs: [Int]?) { func syncArticleStarredState(account: Account, articleIDs: [Int]?) {
@ -1258,20 +1276,38 @@ private extension FeedbinAccountDelegate {
return return
} }
database.selectPendingStarredStatusArticleIDs() { result in
func process(_ pendingArticleIDs: Set<String>) {
let feedbinStarredArticleIDs = Set(articleIDs.map { String($0) } ) let feedbinStarredArticleIDs = Set(articleIDs.map { String($0) } )
let updatableFeedbinUnreadArticleIDs = feedbinStarredArticleIDs.subtracting(pendingArticleIDs)
account.fetchStarredArticleIDs { articleIDsResult in account.fetchStarredArticleIDs { articleIDsResult in
guard let currentStarredArticleIDs = try? articleIDsResult.get() else { guard let currentStarredArticleIDs = try? articleIDsResult.get() else {
return return
} }
// Mark articles as starred // Mark articles as starred
let deltaStarredArticleIDs = feedbinStarredArticleIDs.subtracting(currentStarredArticleIDs) let deltaStarredArticleIDs = updatableFeedbinUnreadArticleIDs.subtracting(currentStarredArticleIDs)
account.markAsStarred(deltaStarredArticleIDs) account.markAsStarred(deltaStarredArticleIDs)
// Mark articles as unstarred // Mark articles as unstarred
let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(feedbinStarredArticleIDs) let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableFeedbinUnreadArticleIDs)
account.markAsUnstarred(deltaUnstarredArticleIDs) account.markAsUnstarred(deltaUnstarredArticleIDs)
} }
}
switch result {
case .success(let pendingArticleIDs):
process(pendingArticleIDs)
case .failure(let error):
os_log(.error, log: self.log, "Sync Article Starred Status failed: %@.", error.localizedDescription)
}
}
} }
func deleteTagging(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) { func deleteTagging(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {

View File

@ -17,10 +17,10 @@ struct FeedlyEntryParser {
return entry.id return entry.id
} }
var feedUrl: String { var feedUrl: String? {
guard let id = entry.origin?.streamId else { guard let id = entry.origin?.streamId else {
assertionFailure() assertionFailure()
return "" return nil
} }
return id return id
} }
@ -82,7 +82,11 @@ struct FeedlyEntryParser {
return attachments.isEmpty ? nil : Set(attachments) return attachments.isEmpty ? nil : Set(attachments)
} }
var parsedItemRepresentation: ParsedItem { var parsedItemRepresentation: ParsedItem? {
guard let feedUrl = feedUrl else {
return nil
}
return ParsedItem(syncServiceID: id, return ParsedItem(syncServiceID: id,
uniqueID: id, // This value seems to get ignored or replaced. uniqueID: id, // This value seems to get ignored or replaced.
feedURL: feedUrl, feedURL: feedUrl,

View File

@ -10,6 +10,6 @@ import Foundation
struct FeedlyOrigin: Decodable { struct FeedlyOrigin: Decodable {
var title: String? var title: String?
var streamId: String var streamId: String?
var htmlUrl: String var htmlUrl: String?
} }

View File

@ -50,7 +50,17 @@ final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntryProvid
return entries return entries
} }
let parsed = Set(entries.map { FeedlyEntryParser(entry: $0).parsedItemRepresentation }) let parsed = Set(entries.compactMap {
FeedlyEntryParser(entry: $0).parsedItemRepresentation
})
if parsed.count != entries.count {
let entryIds = Set(entries.map { $0.id })
let parsedIds = Set(parsed.map { $0.uniqueID })
let difference = entryIds.subtracting(parsedIds)
os_log(.debug, log: log, "Dropping articles with ids: %{public}@.", difference)
}
storedParsedEntries = parsed storedParsedEntries = parsed
return parsed return parsed

View File

@ -29,14 +29,17 @@ class FeedlyOperation: Operation {
} }
} }
override var isAsynchronous: Bool {
return true
}
func didFinish() { func didFinish() {
assert(Thread.isMainThread) assert(Thread.isMainThread)
assert(!isFinished, "Finished operation is attempting to finish again.") assert(!isFinished, "Finished operation is attempting to finish again.")
downloadProgress = nil downloadProgress = nil
isExecutingOperation = false updateExecutingAndFinished(false, true)
isFinishedOperation = true
} }
func didFinish(_ error: Error) { func didFinish(_ error: Error) {
@ -58,8 +61,7 @@ class FeedlyOperation: Operation {
override func start() { override func start() {
guard !isCancelled else { guard !isCancelled else {
isExecutingOperation = false updateExecutingAndFinished(false, true)
isFinishedOperation = true
if downloadProgress != nil { if downloadProgress != nil {
DispatchQueue.main.async { DispatchQueue.main.async {
@ -70,7 +72,7 @@ class FeedlyOperation: Operation {
return return
} }
isExecutingOperation = true updateExecutingAndFinished(true, false)
DispatchQueue.main.async { DispatchQueue.main.async {
self.main() self.main()
} }
@ -80,25 +82,31 @@ class FeedlyOperation: Operation {
return isExecutingOperation return isExecutingOperation
} }
private var isExecutingOperation = false {
willSet {
willChangeValue(for: \.isExecuting)
}
didSet {
didChangeValue(for: \.isExecuting)
}
}
override var isFinished: Bool { override var isFinished: Bool {
return isFinishedOperation return isFinishedOperation
} }
private var isFinishedOperation = false { private var isExecutingOperation = false
willSet { private var isFinishedOperation = false
willChangeValue(for: \.isFinished)
private func updateExecutingAndFinished(_ executing: Bool, _ finished: Bool) {
let isExecutingDidChange = executing != isExecutingOperation
let isFinishedDidChange = finished != isFinishedOperation
if isFinishedDidChange {
willChangeValue(forKey: #keyPath(isFinished))
} }
didSet { if isExecutingDidChange {
didChangeValue(for: \.isFinished) willChangeValue(forKey: #keyPath(isExecuting))
}
isExecutingOperation = executing
isFinishedOperation = finished
if isExecutingDidChange {
didChangeValue(forKey: #keyPath(isExecuting))
}
if isFinishedDidChange {
didChangeValue(forKey: #keyPath(isFinished))
} }
} }
} }

View File

@ -23,13 +23,12 @@ public struct Article: Hashable {
public let externalURL: String? public let externalURL: String?
public let summary: String? public let summary: String?
public let imageURL: String? public let imageURL: String?
public let bannerImageURL: String?
public let datePublished: Date? public let datePublished: Date?
public let dateModified: Date? public let dateModified: Date?
public let authors: Set<Author>? public let authors: Set<Author>?
public let status: ArticleStatus public let status: ArticleStatus
public init(accountID: String, articleID: String?, webFeedID: String, uniqueID: String, title: String?, contentHTML: String?, contentText: String?, url: String?, externalURL: String?, summary: String?, imageURL: String?, bannerImageURL: String?, datePublished: Date?, dateModified: Date?, authors: Set<Author>?, status: ArticleStatus) { public init(accountID: String, articleID: String?, webFeedID: String, uniqueID: String, title: String?, contentHTML: String?, contentText: String?, url: String?, externalURL: String?, summary: String?, imageURL: String?, datePublished: Date?, dateModified: Date?, authors: Set<Author>?, status: ArticleStatus) {
self.accountID = accountID self.accountID = accountID
self.webFeedID = webFeedID self.webFeedID = webFeedID
self.uniqueID = uniqueID self.uniqueID = uniqueID
@ -40,7 +39,6 @@ public struct Article: Hashable {
self.externalURL = externalURL self.externalURL = externalURL
self.summary = summary self.summary = summary
self.imageURL = imageURL self.imageURL = imageURL
self.bannerImageURL = bannerImageURL
self.datePublished = datePublished self.datePublished = datePublished
self.dateModified = dateModified self.dateModified = dateModified
self.authors = authors self.authors = authors

View File

@ -11,7 +11,6 @@
841D4D742106B59F00DD04E6 /* Articles.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841D4D732106B59F00DD04E6 /* Articles.framework */; }; 841D4D742106B59F00DD04E6 /* Articles.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841D4D732106B59F00DD04E6 /* Articles.framework */; };
84288A001F6A3C4400395871 /* DatabaseObject+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842889FF1F6A3C4400395871 /* DatabaseObject+Database.swift */; }; 84288A001F6A3C4400395871 /* DatabaseObject+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842889FF1F6A3C4400395871 /* DatabaseObject+Database.swift */; };
84288A021F6A3D8000395871 /* RelatedObjectsMap+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84288A011F6A3D8000395871 /* RelatedObjectsMap+Database.swift */; }; 84288A021F6A3D8000395871 /* RelatedObjectsMap+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84288A011F6A3D8000395871 /* RelatedObjectsMap+Database.swift */; };
843577161F744FC800F460AE /* DatabaseArticle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843577151F744FC800F460AE /* DatabaseArticle.swift */; };
843577221F749C6200F460AE /* ArticleChangesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843577211F749C6200F460AE /* ArticleChangesTests.swift */; }; 843577221F749C6200F460AE /* ArticleChangesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843577211F749C6200F460AE /* ArticleChangesTests.swift */; };
843702C31F70D15D00B18807 /* ParsedArticle+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843702C21F70D15D00B18807 /* ParsedArticle+Database.swift */; }; 843702C31F70D15D00B18807 /* ParsedArticle+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843702C21F70D15D00B18807 /* ParsedArticle+Database.swift */; };
843CB9961F34174100EE6581 /* Author+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20F901F1810DD00D8E682 /* Author+Database.swift */; }; 843CB9961F34174100EE6581 /* Author+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20F901F1810DD00D8E682 /* Author+Database.swift */; };
@ -115,7 +114,6 @@
841D4D732106B59F00DD04E6 /* Articles.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Articles.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 841D4D732106B59F00DD04E6 /* Articles.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Articles.framework; sourceTree = BUILT_PRODUCTS_DIR; };
842889FF1F6A3C4400395871 /* DatabaseObject+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DatabaseObject+Database.swift"; sourceTree = "<group>"; }; 842889FF1F6A3C4400395871 /* DatabaseObject+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DatabaseObject+Database.swift"; sourceTree = "<group>"; };
84288A011F6A3D8000395871 /* RelatedObjectsMap+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelatedObjectsMap+Database.swift"; sourceTree = "<group>"; }; 84288A011F6A3D8000395871 /* RelatedObjectsMap+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelatedObjectsMap+Database.swift"; sourceTree = "<group>"; };
843577151F744FC800F460AE /* DatabaseArticle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseArticle.swift; sourceTree = "<group>"; };
843577211F749C6200F460AE /* ArticleChangesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleChangesTests.swift; sourceTree = "<group>"; }; 843577211F749C6200F460AE /* ArticleChangesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleChangesTests.swift; sourceTree = "<group>"; };
843702C21F70D15D00B18807 /* ParsedArticle+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "ParsedArticle+Database.swift"; path = "Extensions/ParsedArticle+Database.swift"; sourceTree = "<group>"; }; 843702C21F70D15D00B18807 /* ParsedArticle+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "ParsedArticle+Database.swift"; path = "Extensions/ParsedArticle+Database.swift"; sourceTree = "<group>"; };
844BEE371F0AB3AA004AB7CD /* ArticlesDatabase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ArticlesDatabase.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 844BEE371F0AB3AA004AB7CD /* ArticlesDatabase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ArticlesDatabase.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -176,7 +174,6 @@
845580661F0AEBCD003CCFA1 /* Constants.swift */, 845580661F0AEBCD003CCFA1 /* Constants.swift */,
84E156EB1F0AB80E00F8CC05 /* ArticlesTable.swift */, 84E156EB1F0AB80E00F8CC05 /* ArticlesTable.swift */,
8477ACBB2221E76F00DF7F37 /* SearchTable.swift */, 8477ACBB2221E76F00DF7F37 /* SearchTable.swift */,
843577151F744FC800F460AE /* DatabaseArticle.swift */,
84E156ED1F0AB81400F8CC05 /* StatusesTable.swift */, 84E156ED1F0AB81400F8CC05 /* StatusesTable.swift */,
84F20F8E1F180D8700D8E682 /* AuthorsTable.swift */, 84F20F8E1F180D8700D8E682 /* AuthorsTable.swift */,
8461462A1F0AC44100870CB3 /* Extensions */, 8461462A1F0AC44100870CB3 /* Extensions */,
@ -350,14 +347,14 @@
TargetAttributes = { TargetAttributes = {
844BEE361F0AB3AA004AB7CD = { 844BEE361F0AB3AA004AB7CD = {
CreatedOnToolsVersion = 8.3.2; CreatedOnToolsVersion = 8.3.2;
DevelopmentTeam = SHJK2V3AJG; DevelopmentTeam = M8L2WTLA8W;
LastSwiftMigration = 0830; LastSwiftMigration = 0830;
ProvisioningStyle = Automatic; ProvisioningStyle = Manual;
}; };
844BEE3F1F0AB3AB004AB7CD = { 844BEE3F1F0AB3AB004AB7CD = {
CreatedOnToolsVersion = 8.3.2; CreatedOnToolsVersion = 8.3.2;
DevelopmentTeam = SHJK2V3AJG; DevelopmentTeam = M8L2WTLA8W;
ProvisioningStyle = Automatic; ProvisioningStyle = Manual;
}; };
}; };
}; };
@ -522,7 +519,6 @@
84F20F8F1F180D8700D8E682 /* AuthorsTable.swift in Sources */, 84F20F8F1F180D8700D8E682 /* AuthorsTable.swift in Sources */,
84288A001F6A3C4400395871 /* DatabaseObject+Database.swift in Sources */, 84288A001F6A3C4400395871 /* DatabaseObject+Database.swift in Sources */,
8477ACBC2221E76F00DF7F37 /* SearchTable.swift in Sources */, 8477ACBC2221E76F00DF7F37 /* SearchTable.swift in Sources */,
843577161F744FC800F460AE /* DatabaseArticle.swift in Sources */,
843702C31F70D15D00B18807 /* ParsedArticle+Database.swift in Sources */, 843702C31F70D15D00B18807 /* ParsedArticle+Database.swift in Sources */,
84E156EC1F0AB80E00F8CC05 /* ArticlesTable.swift in Sources */, 84E156EC1F0AB80E00F8CC05 /* ArticlesTable.swift in Sources */,
84E156EE1F0AB81400F8CC05 /* StatusesTable.swift in Sources */, 84E156EE1F0AB81400F8CC05 /* StatusesTable.swift in Sources */,

View File

@ -19,14 +19,14 @@ final class ArticlesTable: DatabaseTable {
private let queue: DatabaseQueue private let queue: DatabaseQueue
private let statusesTable: StatusesTable private let statusesTable: StatusesTable
private let authorsLookupTable: DatabaseLookupTable private let authorsLookupTable: DatabaseLookupTable
private var databaseArticlesCache = [String: DatabaseArticle]() private var articlesCache = [String: Article]()
private lazy var searchTable: SearchTable = { private lazy var searchTable: SearchTable = {
return SearchTable(queue: queue, articlesTable: self) return SearchTable(queue: queue, articlesTable: self)
}() }()
// TODO: update articleCutoffDate as time passes and based on user preferences. // TODO: update articleCutoffDate as time passes and based on user preferences.
private var articleCutoffDate = NSDate.rs_dateWithNumberOfDays(inThePast: 90)! private let articleCutoffDate = Date().bySubtracting(days: 90)
private typealias ArticlesFetchMethod = (FMDatabase) -> Set<Article> private typealias ArticlesFetchMethod = (FMDatabase) -> Set<Article>
@ -212,6 +212,9 @@ final class ArticlesTable: DatabaseTable {
self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, completion) //7 self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, completion) //7
self.addArticlesToCache(newArticles)
self.addArticlesToCache(updatedArticles)
// 8. Update search index. // 8. Update search index.
if let newArticles = newArticles { if let newArticles = newArticles {
self.searchTable.indexNewArticles(newArticles, database) self.searchTable.indexNewArticles(newArticles, database)
@ -302,9 +305,9 @@ final class ArticlesTable: DatabaseTable {
queue.runInDatabase { databaseResult in queue.runInDatabase { databaseResult in
func makeDatabaseCalls(_ database: FMDatabase) { func makeDatabaseCalls(_ database: FMDatabase) {
let sql = "select distinct feedID, count(*) from articles natural join statuses where read=0 and userDeleted=0 and (starred=1 or (datePublished > ? or (datePublished is null and dateArrived > ?))) group by feedID;" let sql = "select distinct feedID, count(*) from articles natural join statuses where read=0 and userDeleted=0 and (starred=1 or dateArrived>?) group by feedID;"
guard let resultSet = database.executeQuery(sql, withArgumentsIn: [cutoffDate, cutoffDate]) else { guard let resultSet = database.executeQuery(sql, withArgumentsIn: [cutoffDate]) else {
DispatchQueue.main.async { DispatchQueue.main.async {
completion(.success(UnreadCountDictionary())) completion(.success(UnreadCountDictionary()))
} }
@ -449,7 +452,7 @@ final class ArticlesTable: DatabaseTable {
func emptyCaches() { func emptyCaches() {
queue.runInDatabase { _ in queue.runInDatabase { _ in
self.databaseArticlesCache = [String: DatabaseArticle]() self.articlesCache = [String: Article]()
} }
} }
@ -527,86 +530,55 @@ private extension ArticlesTable {
} }
func articlesWithResultSet(_ resultSet: FMResultSet, _ database: FMDatabase) -> Set<Article> { func articlesWithResultSet(_ resultSet: FMResultSet, _ database: FMDatabase) -> Set<Article> {
// 1. Create DatabaseArticles without related objects. var cachedArticles = Set<Article>()
// 2. Then fetch the related objects, given the set of articleIDs. var fetchedArticles = Set<Article>()
// 3. Then create set of Articles with DatabaseArticles and related objects and return it.
// 1. Create databaseArticles (intermediate representations). while resultSet.next() {
let databaseArticles = makeDatabaseArticles(with: resultSet) guard let articleID = resultSet.string(forColumn: DatabaseKey.articleID) else {
if databaseArticles.isEmpty {
return Set<Article>()
}
let articleIDs = databaseArticles.articleIDs()
// 2. Fetch related objects.
let authorsMap = authorsLookupTable.fetchRelatedObjects(for: articleIDs, in: database)
// 3. Create articles with related objects.
let articles = databaseArticles.map { (databaseArticle) -> Article in
return articleWithDatabaseArticle(databaseArticle, authorsMap)
}
return Set(articles)
}
func articleWithDatabaseArticle(_ databaseArticle: DatabaseArticle, _ authorsMap: RelatedObjectsMap?) -> Article {
let articleID = databaseArticle.articleID
let authors = authorsMap?.authors(for: articleID)
return Article(databaseArticle: databaseArticle, accountID: accountID, authors: authors)
}
func makeDatabaseArticles(with resultSet: FMResultSet) -> Set<DatabaseArticle> {
let articles = resultSet.mapToSet { (row) -> DatabaseArticle? in
guard let articleID = row.string(forColumn: DatabaseKey.articleID) else {
assertionFailure("Expected articleID.") assertionFailure("Expected articleID.")
return nil continue
} }
// Articles are removed from the cache when theyre updated. if let article = articlesCache[articleID] {
// See saveUpdatedArticles. cachedArticles.insert(article)
if let databaseArticle = databaseArticlesCache[articleID] { continue
return databaseArticle
} }
// The resultSet is a result of a JOIN query with the statuses table, // The resultSet is a result of a JOIN query with the statuses table,
// so we can get the statuses at the same time and avoid additional database lookups. // so we can get the statuses at the same time and avoid additional database lookups.
guard let status = statusesTable.statusWithRow(resultSet, articleID: articleID) else { guard let status = statusesTable.statusWithRow(resultSet, articleID: articleID) else {
assertionFailure("Expected status.") assertionFailure("Expected status.")
return nil continue
}
guard let webFeedID = row.string(forColumn: DatabaseKey.feedID) else {
assertionFailure("Expected feedID.")
return nil
}
guard let uniqueID = row.string(forColumn: DatabaseKey.uniqueID) else {
assertionFailure("Expected uniqueID.")
return nil
} }
let title = row.string(forColumn: DatabaseKey.title) guard let article = Article(accountID: accountID, row: resultSet, status: status) else {
let contentHTML = row.string(forColumn: DatabaseKey.contentHTML) continue
let contentText = row.string(forColumn: DatabaseKey.contentText) }
let url = row.string(forColumn: DatabaseKey.url) fetchedArticles.insert(article)
let externalURL = row.string(forColumn: DatabaseKey.externalURL) }
let summary = row.string(forColumn: DatabaseKey.summary) resultSet.close()
let imageURL = row.string(forColumn: DatabaseKey.imageURL)
let bannerImageURL = row.string(forColumn: DatabaseKey.bannerImageURL)
let datePublished = row.date(forColumn: DatabaseKey.datePublished)
let dateModified = row.date(forColumn: DatabaseKey.dateModified)
let databaseArticle = DatabaseArticle(articleID: articleID, webFeedID: webFeedID, uniqueID: uniqueID, title: title, contentHTML: contentHTML, contentText: contentText, url: url, externalURL: externalURL, summary: summary, imageURL: imageURL, bannerImageURL: bannerImageURL, datePublished: datePublished, dateModified: dateModified, status: status) if fetchedArticles.isEmpty {
databaseArticlesCache[articleID] = databaseArticle return cachedArticles
return databaseArticle
} }
return articles // Fetch authors for non-cached articles. (Articles from the cache already have authors.)
let fetchedArticleIDs = fetchedArticles.articleIDs()
let authorsMap = authorsLookupTable.fetchRelatedObjects(for: fetchedArticleIDs, in: database)
let articlesWithFetchedAuthors = fetchedArticles.map { (article) -> Article in
if let authors = authorsMap?.authors(for: article.articleID) {
return article.byAdding(authors)
}
return article
}
// Add fetchedArticles to cache, now that they have attached authors.
for article in articlesWithFetchedAuthors {
articlesCache[article.articleID] = article
}
return cachedArticles.union(articlesWithFetchedAuthors)
} }
func fetchArticlesWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject], withLimits: Bool) -> Set<Article> { func fetchArticlesWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject], withLimits: Bool) -> Set<Article> {
@ -615,8 +587,8 @@ private extension ArticlesTable {
// * Must be either 1) starred or 2) dateArrived must be newer than cutoff date. // * Must be either 1) starred or 2) dateArrived must be newer than cutoff date.
if withLimits { if withLimits {
let sql = "select * from articles natural join statuses where \(whereClause) and userDeleted=0 and (starred=1 or (datePublished > ? or (datePublished is null and dateArrived > ?)));" let sql = "select * from articles natural join statuses where \(whereClause) and userDeleted=0 and (starred=1 or dateArrived>?);"
return articlesWithSQL(sql, parameters + [articleCutoffDate as AnyObject] + [articleCutoffDate as AnyObject], database) return articlesWithSQL(sql, parameters + [articleCutoffDate as AnyObject], database)
} }
else { else {
let sql = "select * from articles natural join statuses where \(whereClause);" let sql = "select * from articles natural join statuses where \(whereClause);"
@ -630,8 +602,8 @@ private extension ArticlesTable {
// * Must not be deleted. // * Must not be deleted.
// * Must be either 1) starred or 2) dateArrived must be newer than cutoff date. // * Must be either 1) starred or 2) dateArrived must be newer than cutoff date.
let sql = "select count(*) from articles natural join statuses where feedID=? and read=0 and userDeleted=0 and (starred=1 or (datePublished > ? or (datePublished is null and dateArrived > ?)));" let sql = "select count(*) from articles natural join statuses where feedID=? and read=0 and userDeleted=0 and (starred=1 or dateArrived>?);"
return numberWithSQLAndParameters(sql, [webFeedID, articleCutoffDate, articleCutoffDate], in: database) return numberWithSQLAndParameters(sql, [webFeedID, articleCutoffDate], in: database)
} }
func fetchArticlesMatching(_ searchString: String, _ database: FMDatabase) -> Set<Article> { func fetchArticlesMatching(_ searchString: String, _ database: FMDatabase) -> Set<Article> {
@ -872,7 +844,6 @@ private extension ArticlesTable {
func saveUpdatedArticles(_ updatedArticles: Set<Article>, _ fetchedArticles: [String: Article], _ database: FMDatabase) { func saveUpdatedArticles(_ updatedArticles: Set<Article>, _ fetchedArticles: [String: Article], _ database: FMDatabase) {
removeArticlesFromDatabaseArticlesCache(updatedArticles)
saveUpdatedRelatedObjects(updatedArticles, fetchedArticles, database) saveUpdatedRelatedObjects(updatedArticles, fetchedArticles, database)
for updatedArticle in updatedArticles { for updatedArticle in updatedArticles {
@ -897,10 +868,12 @@ private extension ArticlesTable {
updateRowsWithDictionary(changesDictionary, whereKey: DatabaseKey.articleID, matches: updatedArticle.articleID, database: database) updateRowsWithDictionary(changesDictionary, whereKey: DatabaseKey.articleID, matches: updatedArticle.articleID, database: database)
} }
func removeArticlesFromDatabaseArticlesCache(_ updatedArticles: Set<Article>) { func addArticlesToCache(_ articles: Set<Article>?) {
let articleIDs = updatedArticles.articleIDs() guard let articles = articles else {
for articleID in articleIDs { return
databaseArticlesCache[articleID] = nil }
for article in articles {
articlesCache[article.articleID] = article
} }
} }
@ -912,9 +885,6 @@ private extension ArticlesTable {
if article.status.starred { if article.status.starred {
return false return false
} }
if let datePublished = article.datePublished {
return datePublished < articleCutoffDate
}
return article.status.dateArrived < articleCutoffDate return article.status.dateArrived < articleCutoffDate
} }

View File

@ -1,44 +0,0 @@
//
// DatabaseArticle.swift
// NetNewsWire
//
// Created by Brent Simmons on 9/21/17.
// Copyright © 2017 Ranchero Software. All rights reserved.
//
import Foundation
import Articles
// Intermediate representation of an Article. Doesnt include related objects.
// Used by ArticlesTable as part of fetching articles.
struct DatabaseArticle: Hashable {
let articleID: String
let webFeedID: String
let uniqueID: String
let title: String?
let contentHTML: String?
let contentText: String?
let url: String?
let externalURL: String?
let summary: String?
let imageURL: String?
let bannerImageURL: String?
let datePublished: Date?
let dateModified: Date?
let status: ArticleStatus
// MARK: - Hashable
public func hash(into hasher: inout Hasher) {
hasher.combine(articleID)
}
}
extension Set where Element == DatabaseArticle {
func articleIDs() -> Set<String> {
return Set<String>(map { $0.articleID })
}
}

View File

@ -13,8 +13,31 @@ import RSParser
extension Article { extension Article {
init(databaseArticle: DatabaseArticle, accountID: String, authors: Set<Author>?) { init?(accountID: String, row: FMResultSet, status: ArticleStatus) {
self.init(accountID: accountID, articleID: databaseArticle.articleID, webFeedID: databaseArticle.webFeedID, uniqueID: databaseArticle.uniqueID, title: databaseArticle.title, contentHTML: databaseArticle.contentHTML, contentText: databaseArticle.contentText, url: databaseArticle.url, externalURL: databaseArticle.externalURL, summary: databaseArticle.summary, imageURL: databaseArticle.imageURL, bannerImageURL: databaseArticle.bannerImageURL, datePublished: databaseArticle.datePublished, dateModified: databaseArticle.dateModified, authors: authors, status: databaseArticle.status) guard let articleID = row.string(forColumn: DatabaseKey.articleID) else {
assertionFailure("Expected articleID.")
return nil
}
guard let webFeedID = row.string(forColumn: DatabaseKey.feedID) else {
assertionFailure("Expected feedID.")
return nil
}
guard let uniqueID = row.string(forColumn: DatabaseKey.uniqueID) else {
assertionFailure("Expected uniqueID.")
return nil
}
let title = row.string(forColumn: DatabaseKey.title)
let contentHTML = row.string(forColumn: DatabaseKey.contentHTML)
let contentText = row.string(forColumn: DatabaseKey.contentText)
let url = row.string(forColumn: DatabaseKey.url)
let externalURL = row.string(forColumn: DatabaseKey.externalURL)
let summary = row.string(forColumn: DatabaseKey.summary)
let imageURL = row.string(forColumn: DatabaseKey.imageURL)
let datePublished = row.date(forColumn: DatabaseKey.datePublished)
let dateModified = row.date(forColumn: DatabaseKey.dateModified)
self.init(accountID: accountID, articleID: articleID, webFeedID: webFeedID, uniqueID: uniqueID, title: title, contentHTML: contentHTML, contentText: contentText, url: url, externalURL: externalURL, summary: summary, imageURL: imageURL, datePublished: datePublished, dateModified: dateModified, authors: nil, status: status)
} }
init(parsedItem: ParsedItem, maximumDateAllowed: Date, accountID: String, webFeedID: String, status: ArticleStatus) { init(parsedItem: ParsedItem, maximumDateAllowed: Date, accountID: String, webFeedID: String, status: ArticleStatus) {
@ -34,7 +57,7 @@ extension Article {
dateModified = nil dateModified = nil
} }
self.init(accountID: accountID, articleID: parsedItem.syncServiceID, webFeedID: webFeedID, uniqueID: parsedItem.uniqueID, title: parsedItem.title, contentHTML: parsedItem.contentHTML, contentText: parsedItem.contentText, url: parsedItem.url, externalURL: parsedItem.externalURL, summary: parsedItem.summary, imageURL: parsedItem.imageURL, bannerImageURL: parsedItem.bannerImageURL, datePublished: datePublished, dateModified: dateModified, authors: authors, status: status) self.init(accountID: accountID, articleID: parsedItem.syncServiceID, webFeedID: webFeedID, uniqueID: parsedItem.uniqueID, title: parsedItem.title, contentHTML: parsedItem.contentHTML, contentText: parsedItem.contentText, url: parsedItem.url, externalURL: parsedItem.externalURL, summary: parsedItem.summary, imageURL: parsedItem.imageURL, datePublished: datePublished, dateModified: dateModified, authors: authors, status: status)
} }
private func addPossibleStringChangeWithKeyPath(_ comparisonKeyPath: KeyPath<Article,String?>, _ otherArticle: Article, _ key: String, _ dictionary: inout DatabaseDictionary) { private func addPossibleStringChangeWithKeyPath(_ comparisonKeyPath: KeyPath<Article,String?>, _ otherArticle: Article, _ key: String, _ dictionary: inout DatabaseDictionary) {
@ -43,6 +66,13 @@ extension Article {
} }
} }
func byAdding(_ authors: Set<Author>) -> Article {
if authors.isEmpty {
return self
}
return Article(accountID: self.accountID, articleID: self.articleID, webFeedID: self.webFeedID, uniqueID: self.uniqueID, title: self.title, contentHTML: self.contentHTML, contentText: self.contentText, url: self.url, externalURL: self.externalURL, summary: self.summary, imageURL: self.imageURL, datePublished: self.datePublished, dateModified: self.dateModified, authors: authors, status: self.status)
}
func changesFrom(_ existingArticle: Article) -> DatabaseDictionary? { func changesFrom(_ existingArticle: Article) -> DatabaseDictionary? {
if self == existingArticle { if self == existingArticle {
return nil return nil
@ -60,7 +90,6 @@ extension Article {
addPossibleStringChangeWithKeyPath(\Article.externalURL, existingArticle, DatabaseKey.externalURL, &d) addPossibleStringChangeWithKeyPath(\Article.externalURL, existingArticle, DatabaseKey.externalURL, &d)
addPossibleStringChangeWithKeyPath(\Article.summary, existingArticle, DatabaseKey.summary, &d) addPossibleStringChangeWithKeyPath(\Article.summary, existingArticle, DatabaseKey.summary, &d)
addPossibleStringChangeWithKeyPath(\Article.imageURL, existingArticle, DatabaseKey.imageURL, &d) addPossibleStringChangeWithKeyPath(\Article.imageURL, existingArticle, DatabaseKey.imageURL, &d)
addPossibleStringChangeWithKeyPath(\Article.bannerImageURL, existingArticle, DatabaseKey.bannerImageURL, &d)
// If updated versions of dates are nil, and we have existing dates, keep the existing dates. // If updated versions of dates are nil, and we have existing dates, keep the existing dates.
// This is data thats good to have, and its likely that a feed removing dates is doing so in error. // This is data thats good to have, and its likely that a feed removing dates is doing so in error.
@ -120,9 +149,6 @@ extension Article: DatabaseObject {
if let imageURL = imageURL { if let imageURL = imageURL {
d[DatabaseKey.imageURL] = imageURL d[DatabaseKey.imageURL] = imageURL
} }
if let bannerImageURL = bannerImageURL {
d[DatabaseKey.bannerImageURL] = bannerImageURL
}
if let datePublished = datePublished { if let datePublished = datePublished {
d[DatabaseKey.datePublished] = datePublished d[DatabaseKey.datePublished] = datePublished
} }

View File

@ -13,6 +13,9 @@ import RSDatabase
public typealias SyncStatusesResult = Result<Array<SyncStatus>, DatabaseError> public typealias SyncStatusesResult = Result<Array<SyncStatus>, DatabaseError>
public typealias SyncStatusesCompletionBlock = (SyncStatusesResult) -> Void public typealias SyncStatusesCompletionBlock = (SyncStatusesResult) -> Void
public typealias SyncStatusArticleIDsResult = Result<Set<String>, DatabaseError>
public typealias SyncStatusArticleIDsCompletionBlock = (SyncStatusArticleIDsResult) -> Void
public struct SyncDatabase { public struct SyncDatabase {
private let syncStatusTable: SyncStatusTable private let syncStatusTable: SyncStatusTable
@ -41,6 +44,14 @@ public struct SyncDatabase {
syncStatusTable.selectPendingCount(completion) syncStatusTable.selectPendingCount(completion)
} }
public func selectPendingReadStatusArticleIDs(completion: @escaping SyncStatusArticleIDsCompletionBlock) {
syncStatusTable.selectPendingReadStatusArticleIDs(completion: completion)
}
public func selectPendingStarredStatusArticleIDs(completion: @escaping SyncStatusArticleIDsCompletionBlock) {
syncStatusTable.selectPendingStarredStatusArticleIDs(completion: completion)
}
public func resetSelectedForProcessing(_ articleIDs: [String], completion: DatabaseCompletionBlock? = nil) { public func resetSelectedForProcessing(_ articleIDs: [String], completion: DatabaseCompletionBlock? = nil) {
syncStatusTable.resetSelectedForProcessing(articleIDs, completion: completion) syncStatusTable.resetSelectedForProcessing(articleIDs, completion: completion)
} }

View File

@ -83,6 +83,14 @@ struct SyncStatusTable: DatabaseTable {
} }
} }
func selectPendingReadStatusArticleIDs(completion: @escaping SyncStatusArticleIDsCompletionBlock) {
selectPendingArticleIDsAsync(.read, completion)
}
func selectPendingStarredStatusArticleIDs(completion: @escaping SyncStatusArticleIDsCompletionBlock) {
selectPendingArticleIDsAsync(.starred, completion)
}
func resetSelectedForProcessing(_ articleIDs: [String], completion: DatabaseCompletionBlock? = nil) { func resetSelectedForProcessing(_ articleIDs: [String], completion: DatabaseCompletionBlock? = nil) {
queue.runInTransaction { databaseResult in queue.runInTransaction { databaseResult in
@ -156,6 +164,38 @@ private extension SyncStatusTable {
return SyncStatus(articleID: articleID, key: key, flag: flag, selected: selected) return SyncStatus(articleID: articleID, key: key, flag: flag, selected: selected)
} }
func selectPendingArticleIDsAsync(_ statusKey: ArticleStatus.Key, _ completion: @escaping SyncStatusArticleIDsCompletionBlock) {
queue.runInDatabase { databaseResult in
func makeDatabaseCall(_ database: FMDatabase) {
let sql = "select articleID from syncStatus where selected == false and key = \"\(statusKey.rawValue)\";"
guard let resultSet = database.executeQuery(sql, withArgumentsIn: nil) else {
DispatchQueue.main.async {
completion(.success(Set<String>()))
}
return
}
let articleIDs = resultSet.mapToSet{ $0.string(forColumnIndex: 0) }
DispatchQueue.main.async {
completion(.success(articleIDs))
}
}
switch databaseResult {
case .success(let database):
makeDatabaseCall(database)
case .failure(let databaseError):
DispatchQueue.main.async {
completion(.failure(databaseError))
}
}
}
}
} }
private func callCompletion(_ completion: DatabaseCompletionBlock?, _ databaseError: DatabaseError?) { private func callCompletion(_ completion: DatabaseCompletionBlock?, _ databaseError: DatabaseError?) {

View File

@ -151,7 +151,6 @@ private extension ArticlePasteboardWriter {
d[Key.externalURL] = article.externalURL ?? nil d[Key.externalURL] = article.externalURL ?? nil
d[Key.summary] = article.summary ?? nil d[Key.summary] = article.summary ?? nil
d[Key.imageURL] = article.imageURL ?? nil d[Key.imageURL] = article.imageURL ?? nil
d[Key.bannerImageURL] = article.bannerImageURL ?? nil
d[Key.datePublished] = article.datePublished ?? nil d[Key.datePublished] = article.datePublished ?? nil
d[Key.dateModified] = article.dateModified ?? nil d[Key.dateModified] = article.dateModified ?? nil
d[Key.dateArrived] = article.status.dateArrived d[Key.dateArrived] = article.status.dateArrived

View File

@ -588,7 +588,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
let longTitle = "But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects, dislikes, or avoids pleasure itself, because it is pleasure, but because those who do not know how to pursue pleasure rationally encounter consequences that are extremely painful. Nor again is there anyone who loves or pursues or desires to obtain pain of itself, because it is pain, but because occasionally circumstances occur in which toil and pain can procure him some great pleasure. To take a trivial example, which of us ever undertakes laborious physical exercise, except to obtain some advantage from it? But who has any right to find fault with a man who chooses to enjoy a pleasure that has no annoying consequences, or one who avoids a pain that produces no resultant pleasure?" let longTitle = "But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system, and expound the actual teachings of the great explorer of the truth, the master-builder of human happiness. No one rejects, dislikes, or avoids pleasure itself, because it is pleasure, but because those who do not know how to pursue pleasure rationally encounter consequences that are extremely painful. Nor again is there anyone who loves or pursues or desires to obtain pain of itself, because it is pain, but because occasionally circumstances occur in which toil and pain can procure him some great pleasure. To take a trivial example, which of us ever undertakes laborious physical exercise, except to obtain some advantage from it? But who has any right to find fault with a man who chooses to enjoy a pleasure that has no annoying consequences, or one who avoids a pain that produces no resultant pleasure?"
let prototypeID = "prototype" let prototypeID = "prototype"
let status = ArticleStatus(articleID: prototypeID, read: false, starred: false, userDeleted: false, dateArrived: Date()) let status = ArticleStatus(articleID: prototypeID, read: false, starred: false, userDeleted: false, dateArrived: Date())
let prototypeArticle = Article(accountID: prototypeID, articleID: prototypeID, webFeedID: prototypeID, uniqueID: prototypeID, title: longTitle, contentHTML: nil, contentText: nil, url: nil, externalURL: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: nil, dateModified: nil, authors: nil, status: status) let prototypeArticle = Article(accountID: prototypeID, articleID: prototypeID, webFeedID: prototypeID, uniqueID: prototypeID, title: longTitle, contentHTML: nil, contentText: nil, url: nil, externalURL: nil, summary: nil, imageURL: nil, datePublished: nil, dateModified: nil, authors: nil, status: status)
let prototypeCellData = TimelineCellData(article: prototypeArticle, showFeedName: showingFeedNames, feedName: "Prototype Feed Name", iconImage: nil, showIcon: false, featuredImage: nil) let prototypeCellData = TimelineCellData(article: prototypeArticle, showFeedName: showingFeedNames, feedName: "Prototype Feed Name", iconImage: nil, showIcon: false, featuredImage: nil)
let height = TimelineCellLayout.height(for: 100, cellData: prototypeCellData, appearance: cellAppearance) let height = TimelineCellLayout.height(for: 100, cellData: prototypeCellData, appearance: cellAppearance)

View File

@ -110,7 +110,7 @@
51707439232AA97100A461A3 /* ShareFolderPickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51707438232AA97100A461A3 /* ShareFolderPickerController.swift */; }; 51707439232AA97100A461A3 /* ShareFolderPickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51707438232AA97100A461A3 /* ShareFolderPickerController.swift */; };
517630042336215100E15FFF /* main.js in Resources */ = {isa = PBXBuildFile; fileRef = 517630032336215100E15FFF /* main.js */; }; 517630042336215100E15FFF /* main.js in Resources */ = {isa = PBXBuildFile; fileRef = 517630032336215100E15FFF /* main.js */; };
517630052336215100E15FFF /* main.js in Resources */ = {isa = PBXBuildFile; fileRef = 517630032336215100E15FFF /* main.js */; }; 517630052336215100E15FFF /* main.js in Resources */ = {isa = PBXBuildFile; fileRef = 517630032336215100E15FFF /* main.js */; };
517630232336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517630222336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift */; }; 517630232336657E00E15FFF /* WebViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517630222336657E00E15FFF /* WebViewProvider.swift */; };
5183CCD0226E1E880010922C /* NonIntrinsicLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5183CCCF226E1E880010922C /* NonIntrinsicLabel.swift */; }; 5183CCD0226E1E880010922C /* NonIntrinsicLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5183CCCF226E1E880010922C /* NonIntrinsicLabel.swift */; };
5183CCDA226E31A50010922C /* NonIntrinsicImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5183CCD9226E31A50010922C /* NonIntrinsicImageView.swift */; }; 5183CCDA226E31A50010922C /* NonIntrinsicImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5183CCD9226E31A50010922C /* NonIntrinsicImageView.swift */; };
5183CCE5226F4DFA0010922C /* RefreshInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5183CCE4226F4DFA0010922C /* RefreshInterval.swift */; }; 5183CCE5226F4DFA0010922C /* RefreshInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5183CCE4226F4DFA0010922C /* RefreshInterval.swift */; };
@ -148,6 +148,7 @@
51A9A5F32380DE530033AADF /* AddWebFeedDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */; }; 51A9A5F32380DE530033AADF /* AddWebFeedDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */; };
51A9A5F52380F6A60033AADF /* ModalNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A9A5F42380F6A60033AADF /* ModalNavigationController.swift */; }; 51A9A5F52380F6A60033AADF /* ModalNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A9A5F42380F6A60033AADF /* ModalNavigationController.swift */; };
51A9A60A2382FD240033AADF /* PoppableGestureRecognizerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A9A6092382FD240033AADF /* PoppableGestureRecognizerDelegate.swift */; }; 51A9A60A2382FD240033AADF /* PoppableGestureRecognizerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A9A6092382FD240033AADF /* PoppableGestureRecognizerDelegate.swift */; };
51AB8AB323B7F4C6008F147D /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51AB8AB223B7F4C6008F147D /* WebViewController.swift */; };
51B62E68233186730085F949 /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B62E67233186730085F949 /* IconView.swift */; }; 51B62E68233186730085F949 /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B62E67233186730085F949 /* IconView.swift */; };
51BB7C272335A8E5008E8144 /* ArticleActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */; }; 51BB7C272335A8E5008E8144 /* ArticleActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */; };
51BB7C312335ACDE008E8144 /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = 51BB7C302335ACDE008E8144 /* page.html */; }; 51BB7C312335ACDE008E8144 /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = 51BB7C302335ACDE008E8144 /* page.html */; };
@ -1292,7 +1293,7 @@
516AE9DE2372269A007DEEAA /* IconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconImage.swift; sourceTree = "<group>"; }; 516AE9DE2372269A007DEEAA /* IconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconImage.swift; sourceTree = "<group>"; };
51707438232AA97100A461A3 /* ShareFolderPickerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareFolderPickerController.swift; sourceTree = "<group>"; }; 51707438232AA97100A461A3 /* ShareFolderPickerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareFolderPickerController.swift; sourceTree = "<group>"; };
517630032336215100E15FFF /* main.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = main.js; sourceTree = "<group>"; }; 517630032336215100E15FFF /* main.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = main.js; sourceTree = "<group>"; };
517630222336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleViewControllerWebViewProvider.swift; sourceTree = "<group>"; }; 517630222336657E00E15FFF /* WebViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewProvider.swift; sourceTree = "<group>"; };
5183CCCF226E1E880010922C /* NonIntrinsicLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicLabel.swift; sourceTree = "<group>"; }; 5183CCCF226E1E880010922C /* NonIntrinsicLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicLabel.swift; sourceTree = "<group>"; };
5183CCD9226E31A50010922C /* NonIntrinsicImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicImageView.swift; sourceTree = "<group>"; }; 5183CCD9226E31A50010922C /* NonIntrinsicImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicImageView.swift; sourceTree = "<group>"; };
5183CCE4226F4DFA0010922C /* RefreshInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshInterval.swift; sourceTree = "<group>"; }; 5183CCE4226F4DFA0010922C /* RefreshInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshInterval.swift; sourceTree = "<group>"; };
@ -1320,6 +1321,7 @@
51A9A5E72380CA130033AADF /* ShareFolderPickerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareFolderPickerCell.swift; sourceTree = "<group>"; }; 51A9A5E72380CA130033AADF /* ShareFolderPickerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareFolderPickerCell.swift; sourceTree = "<group>"; };
51A9A5F42380F6A60033AADF /* ModalNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalNavigationController.swift; sourceTree = "<group>"; }; 51A9A5F42380F6A60033AADF /* ModalNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalNavigationController.swift; sourceTree = "<group>"; };
51A9A6092382FD240033AADF /* PoppableGestureRecognizerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PoppableGestureRecognizerDelegate.swift; sourceTree = "<group>"; }; 51A9A6092382FD240033AADF /* PoppableGestureRecognizerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PoppableGestureRecognizerDelegate.swift; sourceTree = "<group>"; };
51AB8AB223B7F4C6008F147D /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = "<group>"; };
51B62E67233186730085F949 /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = "<group>"; }; 51B62E67233186730085F949 /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = "<group>"; };
51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleActivityItemSource.swift; sourceTree = "<group>"; }; 51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleActivityItemSource.swift; sourceTree = "<group>"; };
51BB7C302335ACDE008E8144 /* page.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = page.html; sourceTree = "<group>"; }; 51BB7C302335ACDE008E8144 /* page.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = page.html; sourceTree = "<group>"; };
@ -1950,7 +1952,8 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
51C4527E2265092C00C03939 /* ArticleViewController.swift */, 51C4527E2265092C00C03939 /* ArticleViewController.swift */,
517630222336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift */, 51AB8AB223B7F4C6008F147D /* WebViewController.swift */,
517630222336657E00E15FFF /* WebViewProvider.swift */,
51102164233A7D6C0007A5F7 /* ArticleExtractorButton.swift */, 51102164233A7D6C0007A5F7 /* ArticleExtractorButton.swift */,
51C266E9238C334800F53014 /* ContextMenuPreviewViewController.swift */, 51C266E9238C334800F53014 /* ContextMenuPreviewViewController.swift */,
5142192923522B5500E07E2C /* ImageViewController.swift */, 5142192923522B5500E07E2C /* ImageViewController.swift */,
@ -3835,7 +3838,7 @@
511B9807237DCAC90028BCAA /* UserInfoKey.swift in Sources */, 511B9807237DCAC90028BCAA /* UserInfoKey.swift in Sources */,
51C45269226508F600C03939 /* MasterFeedTableViewCell.swift in Sources */, 51C45269226508F600C03939 /* MasterFeedTableViewCell.swift in Sources */,
51F85BFD2275DCA800C787DC /* SingleLineUILabelSizer.swift in Sources */, 51F85BFD2275DCA800C787DC /* SingleLineUILabelSizer.swift in Sources */,
517630232336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift in Sources */, 517630232336657E00E15FFF /* WebViewProvider.swift in Sources */,
51E43962238037C400015C31 /* AddWebFeedFolderViewController.swift in Sources */, 51E43962238037C400015C31 /* AddWebFeedFolderViewController.swift in Sources */,
51C4528F226509BD00C03939 /* UnreadFeed.swift in Sources */, 51C4528F226509BD00C03939 /* UnreadFeed.swift in Sources */,
51FD413B2342BD0500880194 /* MasterTimelineUnreadCountView.swift in Sources */, 51FD413B2342BD0500880194 /* MasterTimelineUnreadCountView.swift in Sources */,
@ -3843,6 +3846,7 @@
51D87EE12311D34700E63F03 /* ActivityType.swift in Sources */, 51D87EE12311D34700E63F03 /* ActivityType.swift in Sources */,
51C452772265091600C03939 /* MultilineUILabelSizer.swift in Sources */, 51C452772265091600C03939 /* MultilineUILabelSizer.swift in Sources */,
51C452A522650A2D00C03939 /* SmallIconProvider.swift in Sources */, 51C452A522650A2D00C03939 /* SmallIconProvider.swift in Sources */,
51AB8AB323B7F4C6008F147D /* WebViewController.swift in Sources */,
516A09392360A2AE00EAE89B /* SettingsAccountTableViewCell.swift in Sources */, 516A09392360A2AE00EAE89B /* SettingsAccountTableViewCell.swift in Sources */,
3B3A32A5238B820900314204 /* FeedWranglerAccountViewController.swift in Sources */, 3B3A32A5238B820900314204 /* FeedWranglerAccountViewController.swift in Sources */,
51D5948722668EFA00DFC836 /* MarkStatusCommand.swift in Sources */, 51D5948722668EFA00DFC836 /* MarkStatusCommand.swift in Sources */,

View File

@ -8,10 +8,18 @@ function wrapFrames() {
}); });
} }
// Strip out all styling so that we have better control over layout // Strip out color and font styling
function stripStylesFromElement(element, propertiesToStrip) {
for (name of propertiesToStrip) {
element.style.removeProperty(name);
}
}
function stripStyles() { function stripStyles() {
document.getElementsByTagName("body")[0].querySelectorAll("style, link[rel=stylesheet]").forEach(element => element.remove()); document.getElementsByTagName("body")[0].querySelectorAll("style, link[rel=stylesheet]").forEach(element => element.remove());
document.getElementsByTagName("body")[0].querySelectorAll("[style]").forEach(element => element.removeAttribute("style")); // Removing "background" and "font" will also remove properties that would be reflected in them, e.g., "background-color" and "font-family"
document.getElementsByTagName("body")[0].querySelectorAll("[style]").forEach(element => stripStylesFromElement(element, ["color", "background", "font"]));
} }
// Convert all image locations to be absolute // Convert all image locations to be absolute
@ -21,6 +29,52 @@ function convertImgSrc() {
}); });
} }
// Wrap tables in an overflow-x: auto; div
function wrapTables() {
var tables = document.querySelector("div.articleBody").getElementsByTagName("table");
for (table of tables) {
var wrapper = document.createElement("div");
wrapper.className = "nnw-overflow";
table.parentNode.insertBefore(wrapper, table);
wrapper.appendChild(table);
}
}
// Remove some children (currently just spans) from pre elements to work around a strange clipping issue
var ElementUnwrapper = {
unwrapSelector: "span",
unwrapElement: function (element) {
var parent = element.parentNode;
var children = Array.from(element.childNodes);
for (child of children) {
parent.insertBefore(child, element);
}
parent.removeChild(element);
},
// `elements` can be a selector string, an element, or a list of elements
unwrapAppropriateChildren: function (elements) {
if (typeof elements[Symbol.iterator] !== 'function')
elements = [elements];
else if (typeof elements === "string")
elements = document.querySelectorAll(elements);
for (element of elements) {
for (unwrap of element.querySelectorAll(this.unwrapSelector)) {
this.unwrapElement(unwrap);
}
element.normalize()
}
}
};
function flattenPreElements() {
ElementUnwrapper.unwrapAppropriateChildren("div.articleBody td > pre");
}
function reloadArticleImage() { function reloadArticleImage() {
var image = document.getElementById("nnwImageIcon"); var image = document.getElementById("nnwImageIcon");
image.src = "nnwImageIcon://"; image.src = "nnwImageIcon://";
@ -37,8 +91,10 @@ function render(data, scrollY) {
window.scrollTo(0, scrollY); window.scrollTo(0, scrollY);
wrapFrames() wrapFrames()
wrapTables()
stripStyles() stripStyles()
convertImgSrc() convertImgSrc()
flattenPreElements()
postRenderProcessing() postRenderProcessing()
} }

View File

@ -20,7 +20,22 @@ class AddFolderViewController: UITableViewController, AddContainerViewController
return accounts.count > 1 return accounts.count > 1
} }
private var accounts: [Account]! private var accounts: [Account]! {
didSet {
if let predefinedAccount = accounts.first(where: { $0.accountID == AppDefaults.addFolderAccountID }) {
selectedAccount = predefinedAccount
} else {
selectedAccount = accounts[0]
}
}
}
private var selectedAccount: Account! {
didSet {
guard selectedAccount != oldValue else { return }
accountLabel.text = selectedAccount.flatMap { ($0 as DisplayNameProvider).nameForDisplay }
}
}
weak var delegate: AddContainerViewControllerChildDelegate? weak var delegate: AddContainerViewControllerChildDelegate?
@ -32,13 +47,11 @@ class AddFolderViewController: UITableViewController, AddContainerViewController
nameTextField.delegate = self nameTextField.delegate = self
accountLabel.text = (accounts[0] as DisplayNameProvider).nameForDisplay
if shouldDisplayPicker { if shouldDisplayPicker {
accountPickerView.dataSource = self accountPickerView.dataSource = self
accountPickerView.delegate = self accountPickerView.delegate = self
if let index = accounts.firstIndex(where: { $0.accountID == AppDefaults.addFolderAccountID }) { if let index = accounts.firstIndex(of: selectedAccount) {
accountPickerView.selectRow(index, inComponent: 0, animated: false) accountPickerView.selectRow(index, inComponent: 0, animated: false)
} }
@ -53,14 +66,20 @@ class AddFolderViewController: UITableViewController, AddContainerViewController
} }
private func didSelect(_ account: Account) {
AppDefaults.addFolderAccountID = account.accountID
selectedAccount = account
}
func cancel() { func cancel() {
delegate?.processingDidEnd() delegate?.processingDidEnd()
} }
func add() { func add() {
let account = accounts[accountPickerView.selectedRow(inComponent: 0)] guard let folderName = nameTextField.text else {
if let folderName = nameTextField.text { return
account.addFolder(folderName) { result in }
selectedAccount.addFolder(folderName) { result in
switch result { switch result {
case .success: case .success:
self.delegate?.processingDidEnd() self.delegate?.processingDidEnd()
@ -69,7 +88,6 @@ class AddFolderViewController: UITableViewController, AddContainerViewController
} }
} }
} }
}
@objc func textDidChange(_ note: Notification) { @objc func textDidChange(_ note: Notification) {
delegate?.readyToAdd(state: !(nameTextField.text?.isEmpty ?? false)) delegate?.readyToAdd(state: !(nameTextField.text?.isEmpty ?? false))
@ -100,8 +118,7 @@ extension AddFolderViewController: UIPickerViewDataSource, UIPickerViewDelegate
} }
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
accountLabel.text = (accounts[row] as DisplayNameProvider).nameForDisplay didSelect(accounts[row])
AppDefaults.addFolderAccountID = accounts[row].accountID
} }
} }

View File

@ -117,11 +117,11 @@ struct AppAssets {
return UIImage(systemName: "asterisk.circle")! return UIImage(systemName: "asterisk.circle")!
}() }()
static var markOlderAsReadDownImage: UIImage = { static var markBelowAsReadImage: UIImage = {
return UIImage(systemName: "arrowtriangle.down.circle")! return UIImage(systemName: "arrowtriangle.down.circle")!
}() }()
static var markOlderAsReadUpImage: UIImage = { static var markAboveAsReadImage: UIImage = {
return UIImage(systemName: "arrowtriangle.up.circle")! return UIImage(systemName: "arrowtriangle.up.circle")!
}() }()

View File

@ -59,7 +59,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
appDelegate = self appDelegate = self
// Force lazy initialization of the web view provider so that it can warm up the queue of prepared web views // Force lazy initialization of the web view provider so that it can warm up the queue of prepared web views
let _ = ArticleViewControllerWebViewProvider.shared let _ = WebViewProvider.shared
AccountManager.shared = AccountManager() AccountManager.shared = AccountManager()
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)

View File

@ -12,32 +12,20 @@ import Account
import Articles import Articles
import SafariServices import SafariServices
enum ArticleViewState: Equatable {
case noSelection
case multipleSelection
case loading
case article(Article)
case extracted(Article, ExtractedArticle)
}
class ArticleViewController: UIViewController { class ArticleViewController: UIViewController {
private struct MessageName {
static let imageWasClicked = "imageWasClicked"
static let imageWasShown = "imageWasShown"
}
@IBOutlet private weak var nextUnreadBarButtonItem: UIBarButtonItem! @IBOutlet private weak var nextUnreadBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var prevArticleBarButtonItem: UIBarButtonItem! @IBOutlet private weak var prevArticleBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var nextArticleBarButtonItem: UIBarButtonItem! @IBOutlet private weak var nextArticleBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var readBarButtonItem: UIBarButtonItem! @IBOutlet private weak var readBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var starBarButtonItem: UIBarButtonItem! @IBOutlet private weak var starBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var actionBarButtonItem: UIBarButtonItem! @IBOutlet private weak var actionBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var webViewContainer: UIView!
@IBOutlet private weak var showNavigationView: UIView! private var pageViewController: UIPageViewController!
@IBOutlet private weak var showToolbarView: UIView!
@IBOutlet private weak var showNavigationViewConstraint: NSLayoutConstraint! private var currentWebViewController: WebViewController? {
@IBOutlet private weak var showToolbarViewConstraint: NSLayoutConstraint! return pageViewController?.viewControllers?.first as? WebViewController
}
private var articleExtractorButton: ArticleExtractorButton = { private var articleExtractorButton: ArticleExtractorButton = {
let button = ArticleExtractorButton(type: .system) let button = ArticleExtractorButton(type: .system)
@ -46,44 +34,23 @@ class ArticleViewController: UIViewController {
return button return button
}() }()
private var webView: WKWebView!
private lazy var contextMenuInteraction = UIContextMenuInteraction(delegate: self)
private var isFullScreenAvailable: Bool { private var isFullScreenAvailable: Bool {
return traitCollection.userInterfaceIdiom == .phone && coordinator.isRootSplitCollapsed return traitCollection.userInterfaceIdiom == .phone && coordinator.isRootSplitCollapsed
} }
private lazy var transition = ImageTransition(controller: self)
private var clickedImageCompletion: (() -> Void)?
weak var coordinator: SceneCoordinator! weak var coordinator: SceneCoordinator!
var state: ArticleViewState = .noSelection { var article: Article? {
didSet { didSet {
if state != oldValue { if let controller = currentWebViewController, controller.article != article {
controller.article = article
DispatchQueue.main.async {
// You have to set the view controller to clear out the UIPageViewController child controller cache.
// You also have to do it in an async call or you will get a strange assertion error.
self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
}
}
updateUI() updateUI()
reloadHTML()
}
}
}
var restoreOffset = 0
var currentArticle: Article? {
switch state {
case .article(let article):
return article
case .extracted(let article, _):
return article
default:
return nil
}
}
var articleExtractorButtonState: ArticleExtractorButtonState {
get {
return articleExtractorButton.buttonState
}
set {
articleExtractorButton.buttonState = newValue
} }
} }
@ -92,68 +59,47 @@ class ArticleViewController: UIViewController {
return keyboardManager.keyCommands return keyboardManager.keyCommands
} }
deinit {
if webView != nil {
webView?.evaluateJavaScript("cancelImageLoad();")
webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasClicked)
webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasShown)
webView.removeFromSuperview()
ArticleViewControllerWebViewProvider.shared.enqueueWebView(webView)
webView = nil
}
}
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange(_:)), name: UIContentSizeCategory.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
let fullScreenTapZone = UIView()
NSLayoutConstraint.activate([
fullScreenTapZone.widthAnchor.constraint(equalToConstant: 150),
fullScreenTapZone.heightAnchor.constraint(equalToConstant: 44)
])
fullScreenTapZone.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapNavigationBar)))
navigationItem.titleView = fullScreenTapZone
articleExtractorButton.addTarget(self, action: #selector(toggleArticleExtractor(_:)), for: .touchUpInside) articleExtractorButton.addTarget(self, action: #selector(toggleArticleExtractor(_:)), for: .touchUpInside)
toolbarItems?.insert(UIBarButtonItem(customView: articleExtractorButton), at: 6) toolbarItems?.insert(UIBarButtonItem(customView: articleExtractorButton), at: 6)
showNavigationView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:)))) pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [:])
showToolbarView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:)))) pageViewController.delegate = self
pageViewController.dataSource = self
ArticleViewControllerWebViewProvider.shared.dequeueWebView() { webView in view.addSubview(pageViewController.view)
addChild(pageViewController!)
self.webView = webView
self.webViewContainer.addChildAndPin(webView)
webView.translatesAutoresizingMaskIntoConstraints = false
self.webViewContainer.addSubview(webView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
self.webViewContainer.leadingAnchor.constraint(equalTo: webView.leadingAnchor), view.leadingAnchor.constraint(equalTo: pageViewController.view.leadingAnchor),
self.webViewContainer.trailingAnchor.constraint(equalTo: webView.trailingAnchor), view.trailingAnchor.constraint(equalTo: pageViewController.view.trailingAnchor),
self.webViewContainer.topAnchor.constraint(equalTo: webView.topAnchor), view.topAnchor.constraint(equalTo: pageViewController.view.topAnchor),
self.webViewContainer.bottomAnchor.constraint(equalTo: webView.bottomAnchor) view.bottomAnchor.constraint(equalTo: pageViewController.view.bottomAnchor)
]) ])
webView.navigationDelegate = self let controller = createWebViewController(article)
webView.uiDelegate = self pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
self.configureContextMenuInteraction()
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked)
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown)
// Even though page.html should be loaded into this webview, we have to do it again
// to work around this bug: http://www.openradar.me/22855188
let url = Bundle.main.url(forResource: "page", withExtension: "html")!
webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
}
updateUI()
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
if AppDefaults.articleFullscreenEnabled { if AppDefaults.articleFullscreenEnabled {
hideBars() currentWebViewController?.hideBars()
} }
} }
@ -169,7 +115,7 @@ class ArticleViewController: UIViewController {
func updateUI() { func updateUI() {
guard let article = currentArticle else { guard let article = article else {
articleExtractorButton.isEnabled = false articleExtractorButton.isEnabled = false
nextUnreadBarButtonItem.isEnabled = false nextUnreadBarButtonItem.isEnabled = false
prevArticleBarButtonItem.isEnabled = false prevArticleBarButtonItem.isEnabled = false
@ -197,41 +143,6 @@ class ArticleViewController: UIViewController {
} }
func reloadHTML() {
let style = ArticleStylesManager.shared.currentStyle
let rendering: ArticleRenderer.Rendering
switch state {
case .noSelection:
rendering = ArticleRenderer.noSelectionHTML(style: style)
case .multipleSelection:
rendering = ArticleRenderer.multipleSelectionHTML(style: style)
case .loading:
rendering = ArticleRenderer.loadingHTML(style: style)
case .article(let article):
rendering = ArticleRenderer.articleHTML(article: article, style: style, useImageIcon: true)
case .extracted(let article, let extractedArticle):
rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, style: style, useImageIcon: true)
}
let templateData = TemplateData(style: rendering.style, body: rendering.html)
let encoder = JSONEncoder()
var render = "error();"
if let data = try? encoder.encode(templateData) {
let json = String(data: data, encoding: .utf8)!
render = "render(\(json), \(restoreOffset));"
}
restoreOffset = 0
ArticleViewControllerWebViewProvider.shared.articleIconSchemeHandler.currentArticle = currentArticle
webView?.scrollView.setZoomScale(1.0, animated: false)
webView?.evaluateJavaScript(render)
}
// MARK: Notifications // MARK: Notifications
@objc dynamic func unreadCountDidChange(_ notification: Notification) { @objc dynamic func unreadCountDidChange(_ notification: Notification) {
@ -242,45 +153,33 @@ class ArticleViewController: UIViewController {
guard let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set<String> else { guard let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set<String> else {
return return
} }
guard let currentArticle = currentArticle else { guard let article = article else {
return return
} }
if articleIDs.contains(currentArticle.articleID) { if articleIDs.contains(article.articleID) {
updateUI() updateUI()
} }
} }
@objc func webFeedIconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func avatarDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func faviconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func contentSizeCategoryDidChange(_ note: Notification) {
reloadHTML()
}
@objc func willEnterForeground(_ note: Notification) { @objc func willEnterForeground(_ note: Notification) {
// The toolbar will come back on you if you don't hide it again // The toolbar will come back on you if you don't hide it again
if AppDefaults.articleFullscreenEnabled { if AppDefaults.articleFullscreenEnabled {
hideBars() currentWebViewController?.hideBars()
} }
} }
// MARK: Actions // MARK: Actions
@objc func didTapNavigationBar() {
currentWebViewController?.hideBars()
}
@objc func showBars(_ sender: Any) { @objc func showBars(_ sender: Any) {
showBars() currentWebViewController?.showBars()
} }
@IBAction func toggleArticleExtractor(_ sender: Any) { @IBAction func toggleArticleExtractor(_ sender: Any) {
coordinator.toggleArticleExtractor() currentWebViewController?.toggleArticleExtractor()
} }
@IBAction func nextUnread(_ sender: Any) { @IBAction func nextUnread(_ sender: Any) {
@ -304,7 +203,7 @@ class ArticleViewController: UIViewController {
} }
@IBAction func showActivityDialog(_ sender: Any) { @IBAction func showActivityDialog(_ sender: Any) {
showActivityDialog() currentWebViewController?.showActivityDialog(popOverBarButtonItem: actionBarButtonItem)
} }
// MARK: Keyboard Shortcuts // MARK: Keyboard Shortcuts
@ -315,339 +214,81 @@ class ArticleViewController: UIViewController {
// MARK: API // MARK: API
func focus() { func focus() {
webView.becomeFirstResponder() currentWebViewController?.focus()
} }
func finalScrollPosition() -> CGFloat { func finalScrollPosition() -> CGFloat {
return webView.scrollView.contentSize.height - webView.scrollView.bounds.size.height + webView.scrollView.contentInset.bottom return currentWebViewController?.finalScrollPosition() ?? 0.0
} }
func canScrollDown() -> Bool { func canScrollDown() -> Bool {
return webView.scrollView.contentOffset.y < finalScrollPosition() return currentWebViewController?.canScrollDown() ?? false
} }
func scrollPageDown() { func scrollPageDown() {
let scrollToY: CGFloat = { currentWebViewController?.scrollPageDown()
let fullScroll = webView.scrollView.contentOffset.y + webView.scrollView.bounds.size.height
let final = finalScrollPosition()
return fullScroll < final ? fullScroll : final
}()
let convertedPoint = self.view.convert(CGPoint(x: 0, y: 0), to: webView.scrollView)
let scrollToPoint = CGPoint(x: convertedPoint.x, y: scrollToY)
webView.scrollView.setContentOffset(scrollToPoint, animated: true)
}
func hideClickedImage() {
webView?.evaluateJavaScript("hideClickedImage();")
}
func showClickedImage(completion: @escaping () -> Void) {
clickedImageCompletion = completion
webView?.evaluateJavaScript("showClickedImage();")
} }
func fullReload() { func fullReload() {
if let offset = webView?.scrollView.contentOffset.y { currentWebViewController?.fullReload()
restoreOffset = Int(offset) }
webView?.reload()
}
// MARK: WebViewControllerDelegate
extension ArticleViewController: WebViewControllerDelegate {
func webViewController(_ webViewController: WebViewController, articleExtractorButtonStateDidUpdate buttonState: ArticleExtractorButtonState) {
if webViewController === currentWebViewController {
articleExtractorButton.buttonState = buttonState
} }
} }
} }
// MARK: InteractiveNavigationControllerTappable // MARK: UIPageViewControllerDataSource
extension ArticleViewController: InteractiveNavigationControllerTappable { extension ArticleViewController: UIPageViewControllerDataSource {
func didTapNavigationBar() {
hideBars() func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let article = coordinator.prevArticle else {
return nil
} }
return createWebViewController(article)
} }
// MARK: UIContextMenuInteractionDelegate func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let article = coordinator.nextArticle else {
extension ArticleViewController: UIContextMenuInteractionDelegate { return nil
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil, previewProvider: contextMenuPreviewProvider) { [weak self] suggestedActions in
guard let self = self else { return nil }
var actions = [UIAction]()
if let action = self.prevArticleAction() {
actions.append(action)
} }
if let action = self.nextArticleAction() { return createWebViewController(article)
actions.append(action)
}
actions.append(self.toggleReadAction())
actions.append(self.toggleStarredAction())
if let action = self.nextUnreadArticleAction() {
actions.append(action)
}
actions.append(self.toggleArticleExtractorAction())
actions.append(self.shareAction())
return UIMenu(title: "", children: actions)
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
coordinator.showBrowserForCurrentArticle()
} }
} }
// MARK: WKNavigationDelegate // MARK: UIPageViewControllerDelegate
extension ArticleViewController: WKNavigationDelegate { extension ArticleViewController: UIPageViewControllerDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.navigationType == .linkActivated { func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
guard finished, completed else { return }
guard let url = navigationAction.request.url else { guard let article = currentWebViewController?.article else { return }
decisionHandler(.allow) coordinator.selectArticle(article)
return articleExtractorButton.buttonState = currentWebViewController?.articleExtractorButtonState ?? .off
} }
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if components?.scheme == "http" || components?.scheme == "https" {
let vc = SFSafariViewController(url: url)
present(vc, animated: true)
decisionHandler(.cancel)
} else {
decisionHandler(.allow)
}
} else {
decisionHandler(.allow)
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
self.updateUI()
self.reloadHTML()
}
}
// MARK: WKUIDelegate
extension ArticleViewController: WKUIDelegate {
func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) {
// We need to have at least an unimplemented WKUIDelegate assigned to the WKWebView. This makes the
// link preview launch Safari when the link preview is tapped. In theory, you shoud be able to get
// the link from the elementInfo above and transition to SFSafariViewController instead of launching
// Safari. As the time of this writing, the link in elementInfo is always nil. ¯\_()_/¯
}
}
// MARK: WKScriptMessageHandler
extension ArticleViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
switch message.name {
case MessageName.imageWasShown:
clickedImageCompletion?()
case MessageName.imageWasClicked:
imageWasClicked(body: message.body as? String)
default:
return
}
}
}
class WrapperScriptMessageHandler: NSObject, WKScriptMessageHandler {
// We need to wrap a message handler to prevent a circlular reference
private weak var handler: WKScriptMessageHandler?
init(_ handler: WKScriptMessageHandler) {
self.handler = handler
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
handler?.userContentController(userContentController, didReceive: message)
}
}
// MARK: UIViewControllerTransitioningDelegate
extension ArticleViewController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = true
return transition
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = false
return transition
}
}
// MARK: JSON
private struct TemplateData: Codable {
let style: String
let body: String
}
private struct ImageClickMessage: Codable {
let x: Float
let y: Float
let width: Float
let height: Float
let imageURL: String
} }
// MARK: Private // MARK: Private
private extension ArticleViewController { private extension ArticleViewController {
func reloadArticleImage() { func createWebViewController(_ article: Article?) -> WebViewController {
webView?.evaluateJavaScript("reloadArticleImage()") let controller = WebViewController()
} controller.coordinator = coordinator
controller.delegate = self
func imageWasClicked(body: String?) { controller.article = article
guard let body = body, return controller
let data = body.data(using: .utf8),
let clickMessage = try? JSONDecoder().decode(ImageClickMessage.self, from: data),
let range = clickMessage.imageURL.range(of: ";base64,")
else { return }
let base64Image = String(clickMessage.imageURL.suffix(from: range.upperBound))
if let imageData = Data(base64Encoded: base64Image), let image = UIImage(data: imageData) {
let y = CGFloat(clickMessage.y) + webView.safeAreaInsets.top
let rect = CGRect(x: CGFloat(clickMessage.x), y: y, width: CGFloat(clickMessage.width), height: CGFloat(clickMessage.height))
transition.originFrame = webView.convert(rect, to: nil)
if navigationController?.navigationBar.isHidden ?? false {
transition.maskFrame = webView.convert(webView.frame, to: nil)
} else {
transition.maskFrame = webView.convert(webView.safeAreaLayoutGuide.layoutFrame, to: nil)
}
transition.originImage = image
coordinator.showFullScreenImage(image: image, transitioningDelegate: self)
}
}
func showActivityDialog() {
guard let preferredLink = currentArticle?.preferredLink, let url = URL(string: preferredLink) else {
return
}
let itemSource = ArticleActivityItemSource(url: url, subject: currentArticle!.title)
let activityViewController = UIActivityViewController(activityItems: [itemSource], applicationActivities: nil)
activityViewController.popoverPresentationController?.barButtonItem = actionBarButtonItem
present(activityViewController, animated: true)
}
func showBars() {
if isFullScreenAvailable {
AppDefaults.articleFullscreenEnabled = false
coordinator.showStatusBar()
showNavigationViewConstraint.constant = 0
showToolbarViewConstraint.constant = 0
navigationController?.setNavigationBarHidden(false, animated: true)
navigationController?.setToolbarHidden(false, animated: true)
configureContextMenuInteraction()
}
}
func hideBars() {
if isFullScreenAvailable {
AppDefaults.articleFullscreenEnabled = true
coordinator.hideStatusBar()
showNavigationViewConstraint.constant = 44.0
showToolbarViewConstraint.constant = 44.0
navigationController?.setNavigationBarHidden(true, animated: true)
navigationController?.setToolbarHidden(true, animated: true)
configureContextMenuInteraction()
}
}
func configureContextMenuInteraction() {
if isFullScreenAvailable {
if navigationController?.isNavigationBarHidden ?? false {
webView?.addInteraction(contextMenuInteraction)
} else {
webView?.removeInteraction(contextMenuInteraction)
}
}
}
func contextMenuPreviewProvider() -> UIViewController {
let previewProvider = UIStoryboard.main.instantiateController(ofType: ContextMenuPreviewViewController.self)
previewProvider.article = currentArticle
return previewProvider
}
func prevArticleAction() -> UIAction? {
guard coordinator.isPrevArticleAvailable else { return nil }
let title = NSLocalizedString("Previous Article", comment: "Previous Article")
return UIAction(title: title, image: AppAssets.prevArticleImage) { [weak self] action in
self?.coordinator.selectPrevArticle()
}
}
func nextArticleAction() -> UIAction? {
guard coordinator.isNextArticleAvailable else { return nil }
let title = NSLocalizedString("Next Article", comment: "Next Article")
return UIAction(title: title, image: AppAssets.nextArticleImage) { [weak self] action in
self?.coordinator.selectNextArticle()
}
}
func toggleReadAction() -> UIAction {
let read = currentArticle?.status.read ?? false
let title = read ? NSLocalizedString("Mark as Unread", comment: "Mark as Unread") : NSLocalizedString("Mark as Read", comment: "Mark as Read")
let readImage = read ? AppAssets.circleClosedImage : AppAssets.circleOpenImage
return UIAction(title: title, image: readImage) { [weak self] action in
self?.coordinator.toggleReadForCurrentArticle()
}
}
func toggleStarredAction() -> UIAction {
let starred = currentArticle?.status.starred ?? false
let title = starred ? NSLocalizedString("Mark as Unstarred", comment: "Mark as Unstarred") : NSLocalizedString("Mark as Starred", comment: "Mark as Starred")
let starredImage = starred ? AppAssets.starOpenImage : AppAssets.starClosedImage
return UIAction(title: title, image: starredImage) { [weak self] action in
self?.coordinator.toggleStarredForCurrentArticle()
}
}
func nextUnreadArticleAction() -> UIAction? {
guard coordinator.isAnyUnreadAvailable else { return nil }
let title = NSLocalizedString("Next Unread Article", comment: "Next Unread Article")
return UIAction(title: title, image: AppAssets.nextUnreadArticleImage) { [weak self] action in
self?.coordinator.selectNextUnread()
}
}
func toggleArticleExtractorAction() -> UIAction {
let extracted = articleExtractorButton.buttonState == .on
let title = extracted ? NSLocalizedString("Show Feed Article", comment: "Show Feed Article") : NSLocalizedString("Show Reader View", comment: "Show Reader View")
let extractorImage = extracted ? AppAssets.articleExtractorOffSF : AppAssets.articleExtractorOnSF
return UIAction(title: title, image: extractorImage) { [weak self] action in
self?.coordinator.toggleArticleExtractor()
}
}
func shareAction() -> UIAction {
let title = NSLocalizedString("Share", comment: "Share")
return UIAction(title: title, image: AppAssets.shareImage) { [weak self] action in
self?.showActivityDialog()
}
} }
} }

View File

@ -10,15 +10,15 @@ import UIKit
class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning { class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning {
private weak var articleController: ArticleViewController? private weak var webViewController: WebViewController?
private let duration = 0.4 private let duration = 0.4
var presenting = true var presenting = true
var originFrame: CGRect! var originFrame: CGRect!
var maskFrame: CGRect! var maskFrame: CGRect!
var originImage: UIImage! var originImage: UIImage!
init(controller: ArticleViewController) { init(controller: WebViewController) {
self.articleController = controller self.webViewController = controller
} }
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
@ -44,7 +44,7 @@ class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning {
transitionContext.containerView.backgroundColor = AppAssets.fullScreenBackgroundColor transitionContext.containerView.backgroundColor = AppAssets.fullScreenBackgroundColor
transitionContext.containerView.addSubview(imageView) transitionContext.containerView.addSubview(imageView)
articleController?.hideClickedImage() webViewController?.hideClickedImage()
UIView.animate( UIView.animate(
withDuration: duration, withDuration: duration,
@ -93,7 +93,7 @@ class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning {
animations: { animations: {
imageView.frame = self.originFrame imageView.frame = self.originFrame
}, completion: { _ in }, completion: { _ in
self.articleController?.showClickedImage() { self.webViewController?.showClickedImage() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
imageView.removeFromSuperview() imageView.removeFromSuperview()
transitionContext.completeTransition(true) transitionContext.completeTransition(true)

View File

@ -12,8 +12,10 @@ class ImageViewController: UIViewController {
@IBOutlet weak var shareButton: UIButton! @IBOutlet weak var shareButton: UIButton!
@IBOutlet weak var imageScrollView: ImageScrollView! @IBOutlet weak var imageScrollView: ImageScrollView!
@IBOutlet weak var titleLabel: UILabel!
var image: UIImage! var image: UIImage!
var imageTitle: String?
var zoomedFrame: CGRect { var zoomedFrame: CGRect {
return imageScrollView.zoomedFrame return imageScrollView.zoomedFrame
} }
@ -26,6 +28,8 @@ class ImageViewController: UIViewController {
imageScrollView.imageContentMode = .aspectFit imageScrollView.imageContentMode = .aspectFit
imageScrollView.initialOffset = .center imageScrollView.initialOffset = .center
imageScrollView.display(image: image) imageScrollView.display(image: image)
titleLabel.text = imageTitle ?? ""
} }
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {

View File

@ -0,0 +1,619 @@
//
// WebViewController.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 12/28/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import WebKit
import Account
import Articles
import SafariServices
protocol WebViewControllerDelegate: class {
func webViewController(_: WebViewController, articleExtractorButtonStateDidUpdate: ArticleExtractorButtonState)
}
class WebViewController: UIViewController {
private struct MessageName {
static let imageWasClicked = "imageWasClicked"
static let imageWasShown = "imageWasShown"
}
private var topShowBarsView: UIView!
private var bottomShowBarsView: UIView!
private var topShowBarsViewConstraint: NSLayoutConstraint!
private var bottomShowBarsViewConstraint: NSLayoutConstraint!
private var webView: WKWebView!
private lazy var contextMenuInteraction = UIContextMenuInteraction(delegate: self)
private var isFullScreenAvailable: Bool {
return traitCollection.userInterfaceIdiom == .phone && coordinator.isRootSplitCollapsed
}
private lazy var transition = ImageTransition(controller: self)
private var clickedImageCompletion: (() -> Void)?
private var articleExtractor: ArticleExtractor? = nil
private var extractedArticle: ExtractedArticle?
private var isShowingExtractedArticle = false {
didSet {
if isShowingExtractedArticle != oldValue {
reloadHTML()
}
}
}
var articleExtractorButtonState: ArticleExtractorButtonState = .off {
didSet {
delegate.webViewController(self, articleExtractorButtonStateDidUpdate: articleExtractorButtonState)
}
}
weak var coordinator: SceneCoordinator!
weak var delegate: WebViewControllerDelegate!
var article: Article? {
didSet {
stopArticleExtractor()
if article?.webFeed?.isArticleExtractorAlwaysOn ?? false {
startArticleExtractor()
}
if article != oldValue {
reloadHTML()
}
}
}
var restoreOffset = 0
deinit {
if webView != nil {
webView?.evaluateJavaScript("cancelImageLoad();")
webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasClicked)
webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasShown)
webView.removeFromSuperview()
WebViewProvider.shared.enqueueWebView(webView)
webView = nil
}
}
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange(_:)), name: UIContentSizeCategory.didChangeNotification, object: nil)
WebViewProvider.shared.dequeueWebView() { webView in
// Add the webview
self.webView = webView
webView.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(webView)
NSLayoutConstraint.activate([
self.view.leadingAnchor.constraint(equalTo: webView.leadingAnchor),
self.view.trailingAnchor.constraint(equalTo: webView.trailingAnchor),
self.view.topAnchor.constraint(equalTo: webView.topAnchor),
self.view.bottomAnchor.constraint(equalTo: webView.bottomAnchor)
])
self.configureTopShowBarsView()
self.configureBottomShowBarsView()
// Configure the webview
webView.navigationDelegate = self
webView.uiDelegate = self
self.configureContextMenuInteraction()
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked)
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown)
// Even though page.html should be loaded into this webview, we have to do it again
// to work around this bug: http://www.openradar.me/22855188
let url = Bundle.main.url(forResource: "page", withExtension: "html")!
webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
}
}
func reloadHTML() {
guard let webView = webView else { return }
let style = ArticleStylesManager.shared.currentStyle
let rendering: ArticleRenderer.Rendering
if let articleExtractor = articleExtractor, articleExtractor.state == .processing {
rendering = ArticleRenderer.loadingHTML(style: style)
} else if let article = article, let extractedArticle = extractedArticle {
if isShowingExtractedArticle {
rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, style: style, useImageIcon: true)
} else {
rendering = ArticleRenderer.articleHTML(article: article, style: style, useImageIcon: true)
}
} else if let article = article {
rendering = ArticleRenderer.articleHTML(article: article, style: style, useImageIcon: true)
} else {
rendering = ArticleRenderer.noSelectionHTML(style: style)
}
let templateData = TemplateData(style: rendering.style, body: rendering.html)
let encoder = JSONEncoder()
var render = "error();"
if let data = try? encoder.encode(templateData) {
let json = String(data: data, encoding: .utf8)!
render = "render(\(json), \(restoreOffset));"
}
restoreOffset = 0
WebViewProvider.shared.articleIconSchemeHandler.currentArticle = article
webView.scrollView.setZoomScale(1.0, animated: false)
webView.evaluateJavaScript(render)
}
// MARK: Notifications
@objc func webFeedIconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func avatarDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func faviconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func contentSizeCategoryDidChange(_ note: Notification) {
reloadHTML()
}
// MARK: Actions
@objc func showBars(_ sender: Any) {
showBars()
}
// MARK: API
func focus() {
webView.becomeFirstResponder()
}
func finalScrollPosition() -> CGFloat {
return webView.scrollView.contentSize.height - webView.scrollView.bounds.size.height + webView.scrollView.contentInset.bottom
}
func canScrollDown() -> Bool {
return webView.scrollView.contentOffset.y < finalScrollPosition()
}
func scrollPageDown() {
let scrollToY: CGFloat = {
let fullScroll = webView.scrollView.contentOffset.y + webView.scrollView.bounds.size.height
let final = finalScrollPosition()
return fullScroll < final ? fullScroll : final
}()
let convertedPoint = self.view.convert(CGPoint(x: 0, y: 0), to: webView.scrollView)
let scrollToPoint = CGPoint(x: convertedPoint.x, y: scrollToY)
webView.scrollView.setContentOffset(scrollToPoint, animated: true)
}
func hideClickedImage() {
webView?.evaluateJavaScript("hideClickedImage();")
}
func showClickedImage(completion: @escaping () -> Void) {
clickedImageCompletion = completion
webView?.evaluateJavaScript("showClickedImage();")
}
func fullReload() {
if let offset = webView?.scrollView.contentOffset.y {
restoreOffset = Int(offset)
webView?.reload()
}
}
func showBars() {
if isFullScreenAvailable {
AppDefaults.articleFullscreenEnabled = false
coordinator.showStatusBar()
topShowBarsViewConstraint.constant = 0
bottomShowBarsViewConstraint.constant = 0
navigationController?.setNavigationBarHidden(false, animated: true)
navigationController?.setToolbarHidden(false, animated: true)
configureContextMenuInteraction()
}
}
func hideBars() {
if isFullScreenAvailable {
AppDefaults.articleFullscreenEnabled = true
coordinator.hideStatusBar()
topShowBarsViewConstraint.constant = -44.0
bottomShowBarsViewConstraint.constant = 44.0
navigationController?.setNavigationBarHidden(true, animated: true)
navigationController?.setToolbarHidden(true, animated: true)
configureContextMenuInteraction()
}
}
func toggleArticleExtractor() {
guard let article = article else {
return
}
guard articleExtractor?.state != .processing else {
stopArticleExtractor()
return
}
guard !isShowingExtractedArticle else {
isShowingExtractedArticle = false
articleExtractorButtonState = .off
return
}
if let articleExtractor = articleExtractor {
if article.preferredLink == articleExtractor.articleLink {
isShowingExtractedArticle = true
articleExtractorButtonState = .on
}
} else {
startArticleExtractor()
}
}
func showActivityDialog(popOverBarButtonItem: UIBarButtonItem? = nil) {
guard let preferredLink = article?.preferredLink, let url = URL(string: preferredLink) else {
return
}
let itemSource = ArticleActivityItemSource(url: url, subject: article!.title)
let activityViewController = UIActivityViewController(activityItems: [itemSource], applicationActivities: nil)
activityViewController.popoverPresentationController?.barButtonItem = popOverBarButtonItem
present(activityViewController, animated: true)
}
}
// MARK: ArticleExtractorDelegate
extension WebViewController: ArticleExtractorDelegate {
func articleExtractionDidFail(with: Error) {
stopArticleExtractor()
articleExtractorButtonState = .error
}
func articleExtractionDidComplete(extractedArticle: ExtractedArticle) {
if articleExtractor?.state != .cancelled {
self.extractedArticle = extractedArticle
isShowingExtractedArticle = true
articleExtractorButtonState = .on
}
}
}
// MARK: UIContextMenuInteractionDelegate
extension WebViewController: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil, previewProvider: contextMenuPreviewProvider) { [weak self] suggestedActions in
guard let self = self else { return nil }
var actions = [UIAction]()
if let action = self.prevArticleAction() {
actions.append(action)
}
if let action = self.nextArticleAction() {
actions.append(action)
}
actions.append(self.toggleReadAction())
actions.append(self.toggleStarredAction())
if let action = self.nextUnreadArticleAction() {
actions.append(action)
}
actions.append(self.toggleArticleExtractorAction())
actions.append(self.shareAction())
return UIMenu(title: "", children: actions)
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
coordinator.showBrowserForCurrentArticle()
}
}
// MARK: WKNavigationDelegate
extension WebViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.navigationType == .linkActivated {
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if components?.scheme == "http" || components?.scheme == "https" {
let vc = SFSafariViewController(url: url)
present(vc, animated: true)
decisionHandler(.cancel)
} else {
decisionHandler(.allow)
}
} else {
decisionHandler(.allow)
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
self.reloadHTML()
}
}
// MARK: WKUIDelegate
extension WebViewController: WKUIDelegate {
func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) {
// We need to have at least an unimplemented WKUIDelegate assigned to the WKWebView. This makes the
// link preview launch Safari when the link preview is tapped. In theory, you shoud be able to get
// the link from the elementInfo above and transition to SFSafariViewController instead of launching
// Safari. As the time of this writing, the link in elementInfo is always nil. ¯\_()_/¯
}
}
// MARK: WKScriptMessageHandler
extension WebViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
switch message.name {
case MessageName.imageWasShown:
clickedImageCompletion?()
case MessageName.imageWasClicked:
imageWasClicked(body: message.body as? String)
default:
return
}
}
}
class WrapperScriptMessageHandler: NSObject, WKScriptMessageHandler {
// We need to wrap a message handler to prevent a circlular reference
private weak var handler: WKScriptMessageHandler?
init(_ handler: WKScriptMessageHandler) {
self.handler = handler
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
handler?.userContentController(userContentController, didReceive: message)
}
}
// MARK: UIViewControllerTransitioningDelegate
extension WebViewController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = true
return transition
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = false
return transition
}
}
// MARK: JSON
private struct TemplateData: Codable {
let style: String
let body: String
}
private struct ImageClickMessage: Codable {
let x: Float
let y: Float
let width: Float
let height: Float
let imageTitle: String?
let imageURL: String
}
// MARK: Private
private extension WebViewController {
func startArticleExtractor() {
if let link = article?.preferredLink, let extractor = ArticleExtractor(link) {
extractor.delegate = self
extractor.process()
articleExtractor = extractor
articleExtractorButtonState = .animated
}
}
func stopArticleExtractor() {
articleExtractor?.cancel()
articleExtractor = nil
isShowingExtractedArticle = false
articleExtractorButtonState = .off
}
func reloadArticleImage() {
webView?.evaluateJavaScript("reloadArticleImage()")
}
func imageWasClicked(body: String?) {
guard let body = body,
let data = body.data(using: .utf8),
let clickMessage = try? JSONDecoder().decode(ImageClickMessage.self, from: data),
let range = clickMessage.imageURL.range(of: ";base64,")
else { return }
let base64Image = String(clickMessage.imageURL.suffix(from: range.upperBound))
if let imageData = Data(base64Encoded: base64Image), let image = UIImage(data: imageData) {
let y = CGFloat(clickMessage.y) + webView.safeAreaInsets.top
let rect = CGRect(x: CGFloat(clickMessage.x), y: y, width: CGFloat(clickMessage.width), height: CGFloat(clickMessage.height))
transition.originFrame = webView.convert(rect, to: nil)
if navigationController?.navigationBar.isHidden ?? false {
transition.maskFrame = webView.convert(webView.frame, to: nil)
} else {
transition.maskFrame = webView.convert(webView.safeAreaLayoutGuide.layoutFrame, to: nil)
}
transition.originImage = image
coordinator.showFullScreenImage(image: image, imageTitle: clickMessage.imageTitle, transitioningDelegate: self)
}
}
func configureTopShowBarsView() {
topShowBarsView = UIView()
topShowBarsView.backgroundColor = .clear
topShowBarsView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(topShowBarsView)
if AppDefaults.articleFullscreenEnabled {
topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: -44.0)
} else {
topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: 0.0)
}
NSLayoutConstraint.activate([
topShowBarsViewConstraint,
view.leadingAnchor.constraint(equalTo: topShowBarsView.leadingAnchor),
view.trailingAnchor.constraint(equalTo: topShowBarsView.trailingAnchor),
topShowBarsView.heightAnchor.constraint(equalToConstant: 44.0)
])
topShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
}
func configureBottomShowBarsView() {
bottomShowBarsView = UIView()
topShowBarsView.backgroundColor = .clear
bottomShowBarsView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(bottomShowBarsView)
if AppDefaults.articleFullscreenEnabled {
bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 44.0)
} else {
bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 0.0)
}
NSLayoutConstraint.activate([
bottomShowBarsViewConstraint,
view.leadingAnchor.constraint(equalTo: bottomShowBarsView.leadingAnchor),
view.trailingAnchor.constraint(equalTo: bottomShowBarsView.trailingAnchor),
bottomShowBarsView.heightAnchor.constraint(equalToConstant: 44.0)
])
bottomShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
}
func configureContextMenuInteraction() {
if isFullScreenAvailable {
if navigationController?.isNavigationBarHidden ?? false {
webView?.addInteraction(contextMenuInteraction)
} else {
webView?.removeInteraction(contextMenuInteraction)
}
}
}
func contextMenuPreviewProvider() -> UIViewController {
let previewProvider = UIStoryboard.main.instantiateController(ofType: ContextMenuPreviewViewController.self)
previewProvider.article = article
return previewProvider
}
func prevArticleAction() -> UIAction? {
guard coordinator.isPrevArticleAvailable else { return nil }
let title = NSLocalizedString("Previous Article", comment: "Previous Article")
return UIAction(title: title, image: AppAssets.prevArticleImage) { [weak self] action in
self?.coordinator.selectPrevArticle()
}
}
func nextArticleAction() -> UIAction? {
guard coordinator.isNextArticleAvailable else { return nil }
let title = NSLocalizedString("Next Article", comment: "Next Article")
return UIAction(title: title, image: AppAssets.nextArticleImage) { [weak self] action in
self?.coordinator.selectNextArticle()
}
}
func toggleReadAction() -> UIAction {
let read = article?.status.read ?? false
let title = read ? NSLocalizedString("Mark as Unread", comment: "Mark as Unread") : NSLocalizedString("Mark as Read", comment: "Mark as Read")
let readImage = read ? AppAssets.circleClosedImage : AppAssets.circleOpenImage
return UIAction(title: title, image: readImage) { [weak self] action in
self?.coordinator.toggleReadForCurrentArticle()
}
}
func toggleStarredAction() -> UIAction {
let starred = article?.status.starred ?? false
let title = starred ? NSLocalizedString("Mark as Unstarred", comment: "Mark as Unstarred") : NSLocalizedString("Mark as Starred", comment: "Mark as Starred")
let starredImage = starred ? AppAssets.starOpenImage : AppAssets.starClosedImage
return UIAction(title: title, image: starredImage) { [weak self] action in
self?.coordinator.toggleStarredForCurrentArticle()
}
}
func nextUnreadArticleAction() -> UIAction? {
guard coordinator.isAnyUnreadAvailable else { return nil }
let title = NSLocalizedString("Next Unread Article", comment: "Next Unread Article")
return UIAction(title: title, image: AppAssets.nextUnreadArticleImage) { [weak self] action in
self?.coordinator.selectNextUnread()
}
}
func toggleArticleExtractorAction() -> UIAction {
let extracted = articleExtractorButtonState == .on
let title = extracted ? NSLocalizedString("Show Feed Article", comment: "Show Feed Article") : NSLocalizedString("Show Reader View", comment: "Show Reader View")
let extractorImage = extracted ? AppAssets.articleExtractorOffSF : AppAssets.articleExtractorOnSF
return UIAction(title: title, image: extractorImage) { [weak self] action in
self?.toggleArticleExtractor()
}
}
func shareAction() -> UIAction {
let title = NSLocalizedString("Share", comment: "Share")
return UIAction(title: title, image: AppAssets.shareImage) { [weak self] action in
self?.showActivityDialog()
}
}
}

View File

@ -1,5 +1,5 @@
// //
// ArticleViewControllerWebViewProvider.swift // WebViewProvider.swift
// NetNewsWire-iOS // NetNewsWire-iOS
// //
// Created by Maurice Parker on 9/21/19. // Created by Maurice Parker on 9/21/19.
@ -11,9 +11,9 @@ import WebKit
/// WKWebView has an awful behavior of a flash to white on first load when in dark mode. /// WKWebView has an awful behavior of a flash to white on first load when in dark mode.
/// Keep a queue of WebViews where we've already done a trivial load so that by the time we need them in the UI, they're past the flash-to-shite part of their lifecycle. /// Keep a queue of WebViews where we've already done a trivial load so that by the time we need them in the UI, they're past the flash-to-shite part of their lifecycle.
class ArticleViewControllerWebViewProvider: NSObject, WKNavigationDelegate { class WebViewProvider: NSObject, WKNavigationDelegate {
static let shared = ArticleViewControllerWebViewProvider() static let shared = WebViewProvider()
let articleIconSchemeHandler = ArticleIconSchemeHandler() let articleIconSchemeHandler = ArticleIconSchemeHandler()

View File

@ -23,9 +23,20 @@ class ArticleActivityItemSource: NSObject, UIActivityItemSource {
} }
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? { func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
guard let activityType = activityType,
let subject = subject else {
return url return url
} }
switch activityType.rawValue {
case "com.omnigroup.OmniFocus3.iOS.QuickEntry",
"com.culturedcode.ThingsiPhone.ShareExtension":
return "\(subject)\n\(url)"
default:
return url
}
}
func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String { func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String {
return subject ?? "" return subject ?? ""
} }

View File

@ -15,39 +15,7 @@
<view key="view" contentMode="scaleToFill" id="svH-Pt-448"> <view key="view" contentMode="scaleToFill" id="svH-Pt-448">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/> <rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="DNb-lt-KzC">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/> <color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
</view>
<view opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="iEi-hX-TYy">
<rect key="frame" x="0.0" y="813" width="414" height="100"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="100" id="xX2-AK-xJX"/>
</constraints>
</view>
<view opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="A7j-8T-DqE">
<rect key="frame" x="0.0" y="-12" width="414" height="100"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="100" id="3HX-Dm-bA6"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstItem="VUw-jc-0yf" firstAttribute="bottom" secondItem="iEi-hX-TYy" secondAttribute="top" id="4fZ-pn-fmB"/>
<constraint firstItem="DNb-lt-KzC" firstAttribute="top" secondItem="svH-Pt-448" secondAttribute="top" id="Bfh-RL-m4d"/>
<constraint firstItem="A7j-8T-DqE" firstAttribute="trailing" secondItem="VUw-jc-0yf" secondAttribute="trailing" id="Feu-hj-K01"/>
<constraint firstItem="DNb-lt-KzC" firstAttribute="bottom" secondItem="svH-Pt-448" secondAttribute="bottom" id="FfW-6G-Bcp"/>
<constraint firstItem="VUw-jc-0yf" firstAttribute="trailing" secondItem="iEi-hX-TYy" secondAttribute="trailing" id="Ij6-ri-sBN"/>
<constraint firstItem="iEi-hX-TYy" firstAttribute="leading" secondItem="VUw-jc-0yf" secondAttribute="leading" id="Muc-gr-S7o"/>
<constraint firstItem="DNb-lt-KzC" firstAttribute="trailing" secondItem="VUw-jc-0yf" secondAttribute="trailing" id="QJ5-Ne-ndd"/>
<constraint firstItem="A7j-8T-DqE" firstAttribute="bottom" secondItem="VUw-jc-0yf" secondAttribute="top" id="b2h-zZ-xwi"/>
<constraint firstItem="DNb-lt-KzC" firstAttribute="leading" secondItem="VUw-jc-0yf" secondAttribute="leading" id="ezE-0p-35X"/>
<constraint firstItem="A7j-8T-DqE" firstAttribute="leading" secondItem="VUw-jc-0yf" secondAttribute="leading" id="wny-M6-akA"/>
</constraints>
<viewLayoutGuide key="safeArea" id="VUw-jc-0yf"/> <viewLayoutGuide key="safeArea" id="VUw-jc-0yf"/>
</view> </view>
<toolbarItems> <toolbarItems>
@ -75,7 +43,7 @@
<userDefinedRuntimeAttribute type="string" keyPath="accLabelText" value="Next Unread"/> <userDefinedRuntimeAttribute type="string" keyPath="accLabelText" value="Next Unread"/>
</userDefinedRuntimeAttributes> </userDefinedRuntimeAttributes>
<connections> <connections>
<action selector="nextUnread:" destination="JEX-9P-axG" id="USD-hC-C6z"/> <action selector="nextUnread:" destination="JEX-9P-axG" id="nI3-pz-tc8"/>
</connections> </connections>
</barButtonItem> </barButtonItem>
<barButtonItem style="plain" systemItem="flexibleSpace" id="vAq-iW-Yyo"/> <barButtonItem style="plain" systemItem="flexibleSpace" id="vAq-iW-Yyo"/>
@ -117,15 +85,10 @@
<connections> <connections>
<outlet property="actionBarButtonItem" destination="9Ut-5B-JKP" id="9bO-kz-cTz"/> <outlet property="actionBarButtonItem" destination="9Ut-5B-JKP" id="9bO-kz-cTz"/>
<outlet property="nextArticleBarButtonItem" destination="2qz-M5-Yhk" id="IQd-jx-qEr"/> <outlet property="nextArticleBarButtonItem" destination="2qz-M5-Yhk" id="IQd-jx-qEr"/>
<outlet property="nextUnreadBarButtonItem" destination="2w5-e9-C2V" id="xJr-5y-p1N"/> <outlet property="nextUnreadBarButtonItem" destination="2w5-e9-C2V" id="Ekf-My-AHN"/>
<outlet property="prevArticleBarButtonItem" destination="v4j-fq-23N" id="Gny-Oh-cQa"/> <outlet property="prevArticleBarButtonItem" destination="v4j-fq-23N" id="Gny-Oh-cQa"/>
<outlet property="readBarButtonItem" destination="hy0-LS-MzE" id="BzM-x9-tuj"/> <outlet property="readBarButtonItem" destination="hy0-LS-MzE" id="BzM-x9-tuj"/>
<outlet property="showNavigationView" destination="A7j-8T-DqE" id="D59-3C-HmS"/>
<outlet property="showNavigationViewConstraint" destination="b2h-zZ-xwi" id="CaG-8F-5kF"/>
<outlet property="showToolbarView" destination="iEi-hX-TYy" id="zoa-h3-H8b"/>
<outlet property="showToolbarViewConstraint" destination="4fZ-pn-fmB" id="ayD-Mq-kft"/>
<outlet property="starBarButtonItem" destination="wU4-eH-wC9" id="Z8Q-Lt-dKk"/> <outlet property="starBarButtonItem" destination="wU4-eH-wC9" id="Z8Q-Lt-dKk"/>
<outlet property="webViewContainer" destination="DNb-lt-KzC" id="Fc1-Ae-pWK"/>
</connections> </connections>
</viewController> </viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="FJe-Yq-33r" sceneMemberID="firstResponder"/> <placeholder placeholderIdentifier="IBFirstResponder" id="FJe-Yq-33r" sceneMemberID="firstResponder"/>
@ -156,13 +119,20 @@
</connections> </connections>
</tableView> </tableView>
<toolbarItems> <toolbarItems>
<barButtonItem title="Mark All as Read" id="fTv-eX-72r"> <barButtonItem image="asterisk.circle" catalog="system" id="fTv-eX-72r">
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="accLabelText" value="Mark All as Read"/>
</userDefinedRuntimeAttributes>
<connections> <connections>
<action selector="markAllAsRead:" destination="Kyk-vK-QRX" id="4nd-Gg-APm"/> <action selector="markAllAsRead:" destination="Kyk-vK-QRX" id="4nd-Gg-APm"/>
</connections> </connections>
</barButtonItem> </barButtonItem>
<barButtonItem style="plain" systemItem="flexibleSpace" id="53V-wq-bat"/>
<barButtonItem style="plain" systemItem="flexibleSpace" id="93y-8j-WBh"/> <barButtonItem style="plain" systemItem="flexibleSpace" id="93y-8j-WBh"/>
<barButtonItem title="First Unread" id="2v2-jD-C9k"> <barButtonItem image="chevron.down.circle" catalog="system" id="2v2-jD-C9k">
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="accLabelText" value="First Unread"/>
</userDefinedRuntimeAttributes>
<connections> <connections>
<action selector="firstUnread:" destination="Kyk-vK-QRX" id="d5y-x5-Qht"/> <action selector="firstUnread:" destination="Kyk-vK-QRX" id="d5y-x5-Qht"/>
</connections> </connections>
@ -251,6 +221,12 @@
<viewLayoutGuide key="contentLayoutGuide" id="phv-DN-krZ"/> <viewLayoutGuide key="contentLayoutGuide" id="phv-DN-krZ"/>
<viewLayoutGuide key="frameLayoutGuide" id="NNU-C8-Fsz"/> <viewLayoutGuide key="frameLayoutGuide" id="NNU-C8-Fsz"/>
</scrollView> </scrollView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="eMj-1g-3xm">
<rect key="frame" x="0.0" y="862" width="414" height="0.0"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="RmY-a3-hUg"> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="RmY-a3-hUg">
<rect key="frame" x="362" y="44" width="44" height="44"/> <rect key="frame" x="362" y="44" width="44" height="44"/>
<constraints> <constraints>
@ -280,9 +256,12 @@
<constraints> <constraints>
<constraint firstItem="RmY-a3-hUg" firstAttribute="top" secondItem="mbY-02-GFL" secondAttribute="top" id="A0i-Hs-1Ac"/> <constraint firstItem="RmY-a3-hUg" firstAttribute="top" secondItem="mbY-02-GFL" secondAttribute="top" id="A0i-Hs-1Ac"/>
<constraint firstAttribute="bottom" secondItem="msG-pz-EKk" secondAttribute="bottom" id="AtA-bA-jDr"/> <constraint firstAttribute="bottom" secondItem="msG-pz-EKk" secondAttribute="bottom" id="AtA-bA-jDr"/>
<constraint firstItem="eMj-1g-3xm" firstAttribute="trailing" secondItem="mbY-02-GFL" secondAttribute="trailing" id="E7e-Lv-6ZA"/>
<constraint firstAttribute="trailing" secondItem="msG-pz-EKk" secondAttribute="trailing" id="R49-qV-8nm"/> <constraint firstAttribute="trailing" secondItem="msG-pz-EKk" secondAttribute="trailing" id="R49-qV-8nm"/>
<constraint firstItem="msG-pz-EKk" firstAttribute="leading" secondItem="w6Q-vH-063" secondAttribute="leading" id="XN1-xN-hYS"/> <constraint firstItem="msG-pz-EKk" firstAttribute="leading" secondItem="w6Q-vH-063" secondAttribute="leading" id="XN1-xN-hYS"/>
<constraint firstItem="eMj-1g-3xm" firstAttribute="leading" secondItem="mbY-02-GFL" secondAttribute="leading" id="Xni-Dn-I3Z"/>
<constraint firstItem="mbY-02-GFL" firstAttribute="trailing" secondItem="RmY-a3-hUg" secondAttribute="trailing" constant="8" id="Zlz-lM-LV8"/> <constraint firstItem="mbY-02-GFL" firstAttribute="trailing" secondItem="RmY-a3-hUg" secondAttribute="trailing" constant="8" id="Zlz-lM-LV8"/>
<constraint firstItem="mbY-02-GFL" firstAttribute="bottom" secondItem="eMj-1g-3xm" secondAttribute="bottom" id="eaS-iG-yMv"/>
<constraint firstItem="msG-pz-EKk" firstAttribute="top" secondItem="w6Q-vH-063" secondAttribute="top" id="p1a-s0-wdK"/> <constraint firstItem="msG-pz-EKk" firstAttribute="top" secondItem="w6Q-vH-063" secondAttribute="top" id="p1a-s0-wdK"/>
<constraint firstItem="cXR-ll-xBx" firstAttribute="leading" secondItem="mbY-02-GFL" secondAttribute="leading" constant="8" id="vJs-LN-Ydd"/> <constraint firstItem="cXR-ll-xBx" firstAttribute="leading" secondItem="mbY-02-GFL" secondAttribute="leading" constant="8" id="vJs-LN-Ydd"/>
<constraint firstItem="cXR-ll-xBx" firstAttribute="top" secondItem="mbY-02-GFL" secondAttribute="top" id="xVN-Qt-WYA"/> <constraint firstItem="cXR-ll-xBx" firstAttribute="top" secondItem="mbY-02-GFL" secondAttribute="top" id="xVN-Qt-WYA"/>
@ -292,6 +271,7 @@
<connections> <connections>
<outlet property="imageScrollView" destination="msG-pz-EKk" id="dGi-M6-dcO"/> <outlet property="imageScrollView" destination="msG-pz-EKk" id="dGi-M6-dcO"/>
<outlet property="shareButton" destination="RmY-a3-hUg" id="Z54-ah-WAI"/> <outlet property="shareButton" destination="RmY-a3-hUg" id="Z54-ah-WAI"/>
<outlet property="titleLabel" destination="eMj-1g-3xm" id="6wF-IZ-fNw"/>
</connections> </connections>
</viewController> </viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="ZPN-tH-JAG" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/> <placeholder placeholderIdentifier="IBFirstResponder" id="ZPN-tH-JAG" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
@ -387,6 +367,7 @@
</scene> </scene>
</scenes> </scenes>
<resources> <resources>
<image name="asterisk.circle" catalog="system" width="64" height="60"/>
<image name="chevron.down" catalog="system" width="64" height="36"/> <image name="chevron.down" catalog="system" width="64" height="36"/>
<image name="chevron.down.circle" catalog="system" width="64" height="60"/> <image name="chevron.down.circle" catalog="system" width="64" height="60"/>
<image name="chevron.up" catalog="system" width="64" height="36"/> <image name="chevron.up" catalog="system" width="64" height="36"/>

View File

@ -168,8 +168,11 @@ private extension KeyboardManager {
let toggleReadTitle = NSLocalizedString("Toggle Read Status", comment: "Toggle Read Status") let toggleReadTitle = NSLocalizedString("Toggle Read Status", comment: "Toggle Read Status")
keys.append(KeyboardManager.createKeyCommand(title: toggleReadTitle, action: "toggleRead:", input: "u", modifiers: [.command, .shift])) keys.append(KeyboardManager.createKeyCommand(title: toggleReadTitle, action: "toggleRead:", input: "u", modifiers: [.command, .shift]))
let markOlderAsReadTitle = NSLocalizedString("Mark Older as Read", comment: "Mark Older as Read") let markAboveAsReadTitle = NSLocalizedString("Mark Above as Read", comment: "Mark Above as Read")
keys.append(KeyboardManager.createKeyCommand(title: markOlderAsReadTitle, action: "markOlderArticlesAsRead:", input: "k", modifiers: [.command, .shift])) keys.append(KeyboardManager.createKeyCommand(title: markAboveAsReadTitle, action: "markAboveAsRead:", input: "k", modifiers: [.command, .control]))
let markBelowAsReadTitle = NSLocalizedString("Mark Below as Read", comment: "Mark Below as Read")
keys.append(KeyboardManager.createKeyCommand(title: markBelowAsReadTitle, action: "markBelowAsRead:", input: "k", modifiers: [.command, .shift]))
let toggleStarredTitle = NSLocalizedString("Toggle Starred Status", comment: "Toggle Starred Status") let toggleStarredTitle = NSLocalizedString("Toggle Starred Status", comment: "Toggle Starred Status")
keys.append(KeyboardManager.createKeyCommand(title: toggleStarredTitle, action: "toggleStarred:", input: "l", modifiers: [.command, .shift])) keys.append(KeyboardManager.createKeyCommand(title: toggleStarredTitle, action: "toggleStarred:", input: "l", modifiers: [.command, .shift]))

View File

@ -301,13 +301,17 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
} }
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let node = dataSource.itemIdentifier(for: indexPath), !(node.representedObject is PseudoFeed) else { guard let node = dataSource.itemIdentifier(for: indexPath) else {
return nil return nil
} }
if node.representedObject is WebFeed { if node.representedObject is WebFeed {
return makeFeedContextMenu(node: node, indexPath: indexPath, includeDeleteRename: true) return makeFeedContextMenu(node: node, indexPath: indexPath, includeDeleteRename: true)
} else { } else if node.representedObject is Folder {
return makeFolderContextMenu(node: node, indexPath: indexPath) return makeFolderContextMenu(node: node, indexPath: indexPath)
} else if node.representedObject is PseudoFeed {
return makePseudoFeedContextMenu(node: node, indexPath: indexPath)
} else {
return nil
} }
} }
@ -564,7 +568,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
} }
// It wasn't already visable, so expand its folder and try again // It wasn't already visable, so expand its folder and try again
guard let parent = node.parent else { guard let parent = node.parent, parent.representedObject is Folder else {
completion?() completion?()
return return
} }
@ -618,7 +622,14 @@ extension MasterFeedViewController: UIContextMenuInteractionDelegate {
return UIContextMenuConfiguration(identifier: sectionIndex as NSCopying, previewProvider: nil) { suggestedActions in return UIContextMenuConfiguration(identifier: sectionIndex as NSCopying, previewProvider: nil) { suggestedActions in
let accountInfoAction = self.getAccountInfoAction(account: account) let accountInfoAction = self.getAccountInfoAction(account: account)
let deactivateAction = self.deactivateAccountAction(account: account) let deactivateAction = self.deactivateAccountAction(account: account)
return UIMenu(title: "", children: [accountInfoAction, deactivateAction])
var actions = [accountInfoAction, deactivateAction]
if let markAllAction = self.markAllAsReadAction(account: account) {
actions.insert(markAllAction, at: 1)
}
return UIMenu(title: "", children: actions)
} }
} }
@ -658,10 +669,16 @@ private extension MasterFeedViewController {
self.refreshProgressView = refreshProgressView self.refreshProgressView = refreshProgressView
let spaceItemButton1 = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
let refreshProgressItemButton = UIBarButtonItem(customView: refreshProgressView) let refreshProgressItemButton = UIBarButtonItem(customView: refreshProgressView)
let spaceItemButton = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) let spaceItemButton2 = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
addNewItemButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(add(_:))) addNewItemButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(add(_:)))
setToolbarItems([refreshProgressItemButton, spaceItemButton, addNewItemButton], animated: false)
setToolbarItems([spaceItemButton1,
refreshProgressItemButton,
spaceItemButton2,
addNewItemButton
], animated: false)
} }
func updateUI() { func updateUI() {
@ -866,9 +883,13 @@ private extension MasterFeedViewController {
actions.append(copyHomePageAction) actions.append(copyHomePageAction)
} }
if let markAllAction = self.markAllAsReadAction(indexPath: indexPath) {
actions.append(markAllAction)
}
if includeDeleteRename { if includeDeleteRename {
actions.append(self.deleteAction(indexPath: indexPath))
actions.append(self.renameAction(indexPath: indexPath)) actions.append(self.renameAction(indexPath: indexPath))
actions.append(self.deleteAction(indexPath: indexPath))
} }
return UIMenu(title: "", children: actions) return UIMenu(title: "", children: actions)
@ -886,11 +907,25 @@ private extension MasterFeedViewController {
actions.append(self.deleteAction(indexPath: indexPath)) actions.append(self.deleteAction(indexPath: indexPath))
actions.append(self.renameAction(indexPath: indexPath)) actions.append(self.renameAction(indexPath: indexPath))
if let markAllAction = self.markAllAsReadAction(indexPath: indexPath) {
actions.append(markAllAction)
}
return UIMenu(title: "", children: actions) return UIMenu(title: "", children: actions)
}) })
} }
func makePseudoFeedContextMenu(node: Node, indexPath: IndexPath) -> UIContextMenuConfiguration? {
guard let markAllAction = self.markAllAsReadAction(indexPath: indexPath) else {
return nil
}
return UIContextMenuConfiguration(identifier: node.uniqueID as NSCopying, previewProvider: nil, actionProvider: { suggestedActions in
return UIMenu(title: "", children: [markAllAction])
})
}
func homePageAction(indexPath: IndexPath) -> UIAction? { func homePageAction(indexPath: IndexPath) -> UIAction? {
guard coordinator.homePageURLForFeed(indexPath) != nil else { guard coordinator.homePageURLForFeed(indexPath) != nil else {
return nil return nil
@ -1034,6 +1069,45 @@ private extension MasterFeedViewController {
return action return action
} }
func markAllAsReadAction(indexPath: IndexPath) -> UIAction? {
guard let node = dataSource.itemIdentifier(for: indexPath),
coordinator.unreadCountFor(node) > 0 else {
return nil
}
guard let articleFetcher = node.representedObject as? Feed,
let fetchedArticles = try? articleFetcher.fetchArticles() else {
return nil
}
let articles = Array(fetchedArticles)
return markAllAsReadAction(articles: articles, nameForDisplay: articleFetcher.nameForDisplay)
}
func markAllAsReadAction(account: Account) -> UIAction? {
guard let fetchedArticles = try? account.fetchArticles(FetchType.unread) else {
return nil
}
let articles = Array(fetchedArticles)
return markAllAsReadAction(articles: articles, nameForDisplay: account.nameForDisplay)
}
func markAllAsReadAction(articles: [Article], nameForDisplay: String) -> UIAction? {
guard articles.canMarkAllAsRead() else {
return nil
}
let localizedMenuText = NSLocalizedString("Mark All as Read in “%@”", comment: "Command")
let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, nameForDisplay) as String
let action = UIAction(title: title, image: AppAssets.markAllInFeedAsReadImage) { [weak self] action in
self?.coordinator.markAllAsRead(articles)
}
return action
}
func rename(indexPath: IndexPath) { func rename(indexPath: IndexPath) {
let name = (dataSource.itemIdentifier(for: indexPath)?.representedObject as? DisplayNameProvider)?.nameForDisplay ?? "" let name = (dataSource.itemIdentifier(for: indexPath)?.representedObject as? DisplayNameProvider)?.nameForDisplay ?? ""

View File

@ -14,33 +14,40 @@ class RefreshProgressView: UIView {
@IBOutlet weak var progressView: UIProgressView! @IBOutlet weak var progressView: UIProgressView!
@IBOutlet weak var label: UILabel! @IBOutlet weak var label: UILabel!
private lazy var progressWidth = progressView.widthAnchor.constraint(equalToConstant: 100.0) private lazy var progressWidth = progressView.widthAnchor.constraint(equalToConstant: 100.0)
private var lastLabelDisplayedTime: Date? = nil
override init(frame: CGRect) { override func awakeFromNib() {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
NotificationCenter.default.addObserver(self, selector: #selector(progressDidChange(_:)), name: .AccountRefreshProgressDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(progressDidChange(_:)), name: .AccountRefreshProgressDidChange, object: nil)
if !AccountManager.shared.combinedRefreshProgress.isComplete {
progressChanged()
} else {
updateRefreshLabel()
}
} }
func updateRefreshLabel() { func updateRefreshLabel() {
if let accountLastArticleFetchEndTime = AccountManager.shared.lastArticleFetchEndTime { if let accountLastArticleFetchEndTime = AccountManager.shared.lastArticleFetchEndTime {
if let lastLabelDisplayedTime = lastLabelDisplayedTime, lastLabelDisplayedTime.addingTimeInterval(2) > Date() {
return
}
lastLabelDisplayedTime = Date()
if Date() > accountLastArticleFetchEndTime.addingTimeInterval(1) { if Date() > accountLastArticleFetchEndTime.addingTimeInterval(1) {
let relativeDateTimeFormatter = RelativeDateTimeFormatter() let relativeDateTimeFormatter = RelativeDateTimeFormatter()
relativeDateTimeFormatter.dateTimeStyle = .named relativeDateTimeFormatter.dateTimeStyle = .named
let refreshed = relativeDateTimeFormatter.localizedString(for: accountLastArticleFetchEndTime, relativeTo: Date()) let refreshed = relativeDateTimeFormatter.localizedString(for: accountLastArticleFetchEndTime, relativeTo: Date())
let localizedRefreshText = NSLocalizedString("Updated %@", comment: "Updated") let localizedRefreshText = NSLocalizedString("Updated %@", comment: "Updated")
let refreshText = NSString.localizedStringWithFormat(localizedRefreshText as NSString, refreshed) as String let refreshText = NSString.localizedStringWithFormat(localizedRefreshText as NSString, refreshed) as String
label.text = refreshText label.text = refreshText
} else { } else {
label.text = NSLocalizedString("Updated just now", comment: "Updated Just Now") label.text = NSLocalizedString("Updated just now", comment: "Updated Just Now")
} }
} else { } else {
label.text = "" label.text = ""
} }
@ -48,7 +55,20 @@ class RefreshProgressView: UIView {
} }
@objc func progressDidChange(_ note: Notification) { @objc func progressDidChange(_ note: Notification) {
progressChanged()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
// MARK: Private
private extension RefreshProgressView {
func progressChanged() {
let progress = AccountManager.shared.combinedRefreshProgress let progress = AccountManager.shared.combinedRefreshProgress
if progress.isComplete { if progress.isComplete {
@ -60,18 +80,12 @@ class RefreshProgressView: UIView {
self.progressWidth.isActive = false self.progressWidth.isActive = false
} }
} else { } else {
lastLabelDisplayedTime = nil
label.isHidden = true label.isHidden = true
progressView.isHidden = false progressView.isHidden = false
self.progressWidth.isActive = true self.progressWidth.isActive = true
let percent = Float(progress.numberCompleted) / Float(progress.numberOfTasks) let percent = Float(progress.numberCompleted) / Float(progress.numberOfTasks)
progressView.progress = percent progressView.progress = percent
} }
} }
deinit {
NotificationCenter.default.removeObserver(self)
} }
}

View File

@ -17,6 +17,8 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
private var iconSize = IconSize.medium private var iconSize = IconSize.medium
private lazy var feedTapGestureRecognizer = UITapGestureRecognizer(target: self, action:#selector(showFeedInspector(_:))) private lazy var feedTapGestureRecognizer = UITapGestureRecognizer(target: self, action:#selector(showFeedInspector(_:)))
private var refreshProgressView: RefreshProgressView?
@IBOutlet weak var filterButton: UIBarButtonItem! @IBOutlet weak var filterButton: UIBarButtonItem!
@IBOutlet weak var markAllAsReadButton: UIBarButtonItem! @IBOutlet weak var markAllAsReadButton: UIBarButtonItem!
@IBOutlet weak var firstUnreadButton: UIBarButtonItem! @IBOutlet weak var firstUnreadButton: UIBarButtonItem!
@ -73,12 +75,17 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
navigationItem.titleView = titleView navigationItem.titleView = titleView
} }
resetUI(resetScroll: true) refreshControl = UIRefreshControl()
applyChanges(animated: false) refreshControl!.addTarget(self, action: #selector(refreshAccounts(_:)), for: .valueChanged)
// Restore the scroll position if we have one stored configureToolbar()
if let restoreIndexPath = coordinator.timelineMiddleIndexPath { resetUI(resetScroll: true)
tableView.scrollToRow(at: restoreIndexPath, at: .middle, animated: false)
// Load the table and then scroll to the saved position if available
applyChanges(animated: false) {
if let restoreIndexPath = self.coordinator.timelineMiddleIndexPath {
self.tableView.scrollToRow(at: restoreIndexPath, at: .middle, animated: false)
}
} }
} }
@ -129,6 +136,15 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
coordinator.selectFirstUnread() coordinator.selectFirstUnread()
} }
@objc func refreshAccounts(_ sender: Any) {
refreshControl?.endRefreshing()
// This is a hack to make sure that an error dialog doesn't interfere with dismissing the refreshControl.
// If the error dialog appears too closely to the call to endRefreshing, then the refreshControl never disappears.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.present(self))
}
}
// MARK: Keyboard shortcuts // MARK: Keyboard shortcuts
@objc func selectNextUp(_ sender: Any?) { @objc func selectNextUp(_ sender: Any?) {
@ -243,7 +259,13 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
popoverController.sourceRect = CGRect(x: view.frame.size.width/2, y: view.frame.size.height/2, width: 1, height: 1) popoverController.sourceRect = CGRect(x: view.frame.size.width/2, y: view.frame.size.height/2, width: 1, height: 1)
} }
alert.addAction(self.markOlderAsReadAlertAction(article, completion: completion)) if let action = self.markAboveAsReadAlertAction(article, completion: completion) {
alert.addAction(action)
}
if let action = self.markBelowAsReadAlertAction(article, completion: completion) {
alert.addAction(action)
}
if let action = self.discloseFeedAlertAction(article, completion: completion) { if let action = self.discloseFeedAlertAction(article, completion: completion) {
alert.addAction(action) alert.addAction(action)
@ -290,7 +312,14 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
var actions = [UIAction]() var actions = [UIAction]()
actions.append(self.toggleArticleReadStatusAction(article)) actions.append(self.toggleArticleReadStatusAction(article))
actions.append(self.toggleArticleStarStatusAction(article)) actions.append(self.toggleArticleStarStatusAction(article))
actions.append(self.markOlderAsReadAction(article))
if let action = self.markAboveAsReadAction(article) {
actions.append(action)
}
if let action = self.markBelowAsReadAction(article) {
actions.append(action)
}
if let action = self.discloseFeedAction(article) { if let action = self.discloseFeedAction(article) {
actions.append(action) actions.append(action)
@ -450,7 +479,7 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
let prototypeID = "prototype" let prototypeID = "prototype"
let status = ArticleStatus(articleID: prototypeID, read: false, starred: false, userDeleted: false, dateArrived: Date()) let status = ArticleStatus(articleID: prototypeID, read: false, starred: false, userDeleted: false, dateArrived: Date())
let prototypeArticle = Article(accountID: prototypeID, articleID: prototypeID, webFeedID: prototypeID, uniqueID: prototypeID, title: longTitle, contentHTML: nil, contentText: nil, url: nil, externalURL: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: nil, dateModified: nil, authors: nil, status: status) let prototypeArticle = Article(accountID: prototypeID, articleID: prototypeID, webFeedID: prototypeID, uniqueID: prototypeID, title: longTitle, contentHTML: nil, contentText: nil, url: nil, externalURL: nil, summary: nil, imageURL: nil, datePublished: nil, dateModified: nil, authors: nil, status: status)
let prototypeCellData = MasterTimelineCellData(article: prototypeArticle, showFeedName: true, feedName: "Prototype Feed Name", iconImage: nil, showIcon: false, featuredImage: nil, numberOfLines: numberOfTextLines, iconSize: iconSize) let prototypeCellData = MasterTimelineCellData(article: prototypeArticle, showFeedName: true, feedName: "Prototype Feed Name", iconImage: nil, showIcon: false, featuredImage: nil, numberOfLines: numberOfTextLines, iconSize: iconSize)
@ -502,6 +531,22 @@ extension MasterTimelineViewController: UISearchBarDelegate {
private extension MasterTimelineViewController { private extension MasterTimelineViewController {
func configureToolbar() {
if coordinator.isThreePanelMode {
firstUnreadButton.isHidden = true
return
}
guard let refreshProgressView = Bundle.main.loadNibNamed("RefreshProgressView", owner: self, options: nil)?[0] as? RefreshProgressView else {
return
}
self.refreshProgressView = refreshProgressView
let refreshProgressItemButton = UIBarButtonItem(customView: refreshProgressView)
toolbarItems?.insert(refreshProgressItemButton, at: 2)
}
func resetUI(resetScroll: Bool) { func resetUI(resetScroll: Bool) {
title = coordinator.timelineFeed?.nameForDisplay ?? "Timeline" title = coordinator.timelineFeed?.nameForDisplay ?? "Timeline"
@ -544,6 +589,7 @@ private extension MasterTimelineViewController {
} }
func updateUI() { func updateUI() {
refreshProgressView?.updateRefreshLabel()
updateTitleUnreadCount() updateTitleUnreadCount()
updateToolbar() updateToolbar()
} }
@ -634,19 +680,53 @@ private extension MasterTimelineViewController {
return action return action
} }
func markOlderAsReadAction(_ article: Article) -> UIAction { func markAboveAsReadAction(_ article: Article) -> UIAction? {
let title = NSLocalizedString("Mark Older as Read", comment: "Mark Older as Read") guard coordinator.canMarkAboveAsRead(for: article) else {
let image = coordinator.sortDirection == .orderedDescending ? AppAssets.markOlderAsReadDownImage : AppAssets.markOlderAsReadUpImage return nil
}
let title = NSLocalizedString("Mark Above as Read", comment: "Mark Above as Read")
let image = AppAssets.markAboveAsReadImage
let action = UIAction(title: title, image: image) { [weak self] action in let action = UIAction(title: title, image: image) { [weak self] action in
self?.coordinator.markAsReadOlderArticlesInTimeline(article) self?.coordinator.markAboveAsRead(article)
} }
return action return action
} }
func markOlderAsReadAlertAction(_ article: Article, completion: @escaping (Bool) -> Void) -> UIAlertAction { func markBelowAsReadAction(_ article: Article) -> UIAction? {
let title = NSLocalizedString("Mark Older as Read", comment: "Mark Older as Read") guard coordinator.canMarkBelowAsRead(for: article) else {
return nil
}
let title = NSLocalizedString("Mark Below as Read", comment: "Mark Below as Read")
let image = AppAssets.markBelowAsReadImage
let action = UIAction(title: title, image: image) { [weak self] action in
self?.coordinator.markBelowAsRead(article)
}
return action
}
func markAboveAsReadAlertAction(_ article: Article, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard coordinator.canMarkAboveAsRead(for: article) else {
return nil
}
let title = NSLocalizedString("Mark Above as Read", comment: "Mark Above as Read")
let action = UIAlertAction(title: title, style: .default) { [weak self] action in let action = UIAlertAction(title: title, style: .default) { [weak self] action in
self?.coordinator.markAsReadOlderArticlesInTimeline(article) self?.coordinator.markAboveAsRead(article)
completion(true)
}
return action
}
func markBelowAsReadAlertAction(_ article: Article, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard coordinator.canMarkBelowAsRead(for: article) else {
return nil
}
let title = NSLocalizedString("Mark Below as Read", comment: "Mark Below as Read")
let action = UIAlertAction(title: title, style: .default) { [weak self] action in
self?.coordinator.markBelowAsRead(article)
completion(true) completion(true)
} }
return action return action

View File

@ -37,7 +37,8 @@ async function imageWasClicked(img) {
x: rect.x, x: rect.x,
y: rect.y, y: rect.y,
width: rect.width, width: rect.width,
height: rect.height height: rect.height,
imageTitle: img.title
}; };
message.imageURL = reader.result; message.imageURL = reader.result;

View File

@ -1,6 +1,6 @@
<html> <html>
<head> <head>
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width, initial-scale=1">
<style> <style>
:root { :root {
color-scheme: light dark; color-scheme: light dark;

View File

@ -8,6 +8,7 @@ body {
word-wrap: break-word; word-wrap: break-word;
word-break: break-word; word-break: break-word;
-webkit-hyphens: auto; -webkit-hyphens: auto;
-webkit-text-size-adjust: none;
} }
a { a {
@ -130,6 +131,9 @@ pre {
word-break: normal; word-break: normal;
-webkit-hyphens: none; -webkit-hyphens: none;
} }
.nnw-overflow {
overflow-x: auto;
}
code, pre { code, pre {
font-family: "SF Mono", Menlo, "Courier New", Courier, monospace; font-family: "SF Mono", Menlo, "Courier New", Courier, monospace;
font-size: 14px; font-size: 14px;
@ -185,6 +189,13 @@ sub {
width: 100% !important; width: 100% !important;
} }
@media (max-width: 420px) {
blockquote {
margin-inline-start: 25px;
margin-inline-end: 0;
}
}
/*Block ads and junk*/ /*Block ads and junk*/
iframe[src*="feedads"], iframe[src*="feedads"],
@ -220,12 +231,6 @@ img[src*="share-buttons"] {
display: none !important; display: none !important;
} }
/* Site specific styles */
.wp-smiley {
height: 1em;
max-height: 1em;
}
/* Newsfoot specific styles. Structural styles come first, theme styles second */ /* Newsfoot specific styles. Structural styles come first, theme styles second */
.newsfoot-footnote-container { .newsfoot-footnote-container {
position: relative; position: relative;

View File

@ -54,8 +54,12 @@ class RootSplitViewController: UISplitViewController {
coordinator.selectNextUnread() coordinator.selectNextUnread()
} }
@objc func markOlderArticlesAsRead(_ sender: Any?) { @objc func markAboveAsRead(_ sender: Any?) {
coordinator.markAsReadOlderArticlesInTimeline() coordinator.markAboveAsRead()
}
@objc func markBelowAsRead(_ sender: Any?) {
coordinator.markBelowAsRead()
} }
@objc func markUnread(_ sender: Any?) { @objc func markUnread(_ sender: Any?) {

View File

@ -34,9 +34,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
private var activityManager = ActivityManager() private var activityManager = ActivityManager()
private var isShowingExtractedArticle = false
private var articleExtractor: ArticleExtractor? = nil
private var rootSplitViewController: RootSplitViewController! private var rootSplitViewController: RootSplitViewController!
private var masterNavigationController: UINavigationController! private var masterNavigationController: UINavigationController!
private var masterFeedViewController: MasterFeedViewController! private var masterFeedViewController: MasterFeedViewController!
@ -723,7 +720,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
func selectArticle(_ article: Article?, animated: Bool = false) { func selectArticle(_ article: Article?, animated: Bool = false) {
guard article != currentArticle else { return } guard article != currentArticle else { return }
stopArticleExtractor()
currentArticle = article currentArticle = article
activityManager.reading(feed: timelineFeed, article: article) activityManager.reading(feed: timelineFeed, article: article)
@ -733,7 +729,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
masterNavigationController.popViewController(animated: animated) masterNavigationController.popViewController(animated: animated)
} }
} else { } else {
articleViewController?.state = .noSelection articleViewController?.article = nil
} }
masterTimelineViewController?.updateArticleSelection(animated: animated) masterTimelineViewController?.updateArticleSelection(animated: animated)
return return
@ -747,13 +743,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
} }
masterTimelineViewController?.updateArticleSelection(animated: animated) masterTimelineViewController?.updateArticleSelection(animated: animated)
currentArticleViewController.article = article
if article!.webFeed?.isArticleExtractorAlwaysOn ?? false {
startArticleExtractorForCurrentLink()
currentArticleViewController.state = .loading
} else {
currentArticleViewController.state = .article(article!)
}
markArticles(Set([article!]), statusKey: .read, flag: true) markArticles(Set([article!]), statusKey: .read, flag: true)
@ -881,18 +871,52 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
masterNavigationController.popViewController(animated: true) masterNavigationController.popViewController(animated: true)
} }
func markAsReadOlderArticlesInTimeline() { func canMarkAboveAsRead(for article: Article) -> Bool {
if let article = currentArticle { return articles.first != article
markAsReadOlderArticlesInTimeline(article)
}
} }
func markAsReadOlderArticlesInTimeline(_ article: Article) { func markAboveAsRead() {
let articlesToMark = articles.filter { $0.logicalDatePublished < article.logicalDatePublished } guard let currentArticle = currentArticle else {
if articlesToMark.isEmpty {
return return
} }
markAllAsRead(articlesToMark)
markAboveAsRead(currentArticle)
}
func markAboveAsRead(_ article: Article) {
guard let position = articles.firstIndex(of: article) else {
return
}
let articlesAbove = articles[..<position]
markAllAsRead(Array(articlesAbove))
}
func canMarkBelowAsRead(for article: Article) -> Bool {
return articles.last != article
}
func markBelowAsRead() {
guard let currentArticle = currentArticle else {
return
}
markBelowAsRead(currentArticle)
}
func markBelowAsRead(_ article: Article) {
guard let position = articles.firstIndex(of: article) else {
return
}
var articlesBelow = Array(articles[position...])
guard !articlesBelow.isEmpty else {
return
}
articlesBelow.removeFirst()
markAllAsRead(articlesBelow)
} }
func markAsReadForCurrentArticle() { func markAsReadForCurrentArticle() {
@ -998,45 +1022,15 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
masterFeedViewController.present(addViewController, animated: true) masterFeedViewController.present(addViewController, animated: true)
} }
func showFullScreenImage(image: UIImage, transitioningDelegate: UIViewControllerTransitioningDelegate) { func showFullScreenImage(image: UIImage, imageTitle: String?, transitioningDelegate: UIViewControllerTransitioningDelegate) {
let imageVC = UIStoryboard.main.instantiateController(ofType: ImageViewController.self) let imageVC = UIStoryboard.main.instantiateController(ofType: ImageViewController.self)
imageVC.image = image imageVC.image = image
imageVC.imageTitle = imageTitle
imageVC.modalPresentationStyle = .currentContext imageVC.modalPresentationStyle = .currentContext
imageVC.transitioningDelegate = transitioningDelegate imageVC.transitioningDelegate = transitioningDelegate
rootSplitViewController.present(imageVC, animated: true) rootSplitViewController.present(imageVC, animated: true)
} }
func toggleArticleExtractor() {
guard let article = currentArticle else {
return
}
guard articleExtractor?.state != .processing else {
stopArticleExtractor()
articleViewController?.state = .article(article)
return
}
guard !isShowingExtractedArticle else {
isShowingExtractedArticle = false
articleViewController?.articleExtractorButtonState = .off
articleViewController?.state = .article(article)
return
}
if let articleExtractor = articleExtractor, let extractedArticle = articleExtractor.article {
if currentArticle?.preferredLink == articleExtractor.articleLink {
isShowingExtractedArticle = true
articleViewController?.articleExtractorButtonState = .on
articleViewController?.state = .extracted(article, extractedArticle)
}
} else {
startArticleExtractorForCurrentLink()
}
}
func homePageURLForFeed(_ indexPath: IndexPath) -> URL? { func homePageURLForFeed(_ indexPath: IndexPath) -> URL? {
guard let node = nodeFor(indexPath), guard let node = nodeFor(indexPath),
let feed = node.representedObject as? WebFeed, let feed = node.representedObject as? WebFeed,
@ -1154,7 +1148,6 @@ extension SceneCoordinator: UINavigationControllerDelegate {
// This happens when we are going to the next unread and we need to grab another timeline to continue. The // This happens when we are going to the next unread and we need to grab another timeline to continue. The
// ArticleViewController will be pushed, but we will breifly show the Timeline. Don't clear things out when that happens. // ArticleViewController will be pushed, but we will breifly show the Timeline. Don't clear things out when that happens.
if viewController === masterTimelineViewController && !isThreePanelMode && rootSplitViewController.isCollapsed && !isArticleViewControllerPending { if viewController === masterTimelineViewController && !isThreePanelMode && rootSplitViewController.isCollapsed && !isArticleViewControllerPending {
stopArticleExtractor()
currentArticle = nil currentArticle = nil
masterTimelineViewController?.updateArticleSelection(animated: animated) masterTimelineViewController?.updateArticleSelection(animated: animated)
activityManager.invalidateReading() activityManager.invalidateReading()
@ -1170,25 +1163,6 @@ extension SceneCoordinator: UINavigationControllerDelegate {
} }
// MARK: ArticleExtractorDelegate
extension SceneCoordinator: ArticleExtractorDelegate {
func articleExtractionDidFail(with: Error) {
stopArticleExtractor()
articleViewController?.articleExtractorButtonState = .error
}
func articleExtractionDidComplete(extractedArticle: ExtractedArticle) {
if let article = currentArticle, articleExtractor?.state != .cancelled {
isShowingExtractedArticle = true
articleViewController?.state = .extracted(article, extractedArticle)
articleViewController?.articleExtractorButtonState = .on
}
}
}
// MARK: Private // MARK: Private
private extension SceneCoordinator { private extension SceneCoordinator {
@ -1533,24 +1507,9 @@ private extension SceneCoordinator {
// MARK: Fetching Articles // MARK: Fetching Articles
func startArticleExtractorForCurrentLink() {
if let link = currentArticle?.preferredLink, let extractor = ArticleExtractor(link) {
extractor.delegate = self
extractor.process()
articleExtractor = extractor
articleViewController?.articleExtractorButtonState = .animated
}
}
func stopArticleExtractor() {
articleExtractor?.cancel()
articleExtractor = nil
isShowingExtractedArticle = false
articleViewController?.articleExtractorButtonState = .off
}
func emptyTheTimeline() { func emptyTheTimeline() {
if !articles.isEmpty { if !articles.isEmpty {
timelineMiddleIndexPath = nil
replaceArticles(with: Set<Article>(), animated: false) replaceArticles(with: Set<Article>(), animated: false)
} }
} }
@ -1722,6 +1681,8 @@ private extension SceneCoordinator {
// We have to do a full reload when installing an article controller. We may have changed color contexts // We have to do a full reload when installing an article controller. We may have changed color contexts
// and need to update the article colors. An example is in dark mode. Split screen doesn't use true black // and need to update the article colors. An example is in dark mode. Split screen doesn't use true black
// like darkmode usually does. // like darkmode usually does.
// TODO: This should probably only happen to recycled article controllers
articleController.fullReload() articleController.fullReload()
return articleController return articleController

View File

@ -380,6 +380,7 @@ private extension SettingsViewController {
func exportOPML(sourceView: UIView, sourceRect: CGRect) { func exportOPML(sourceView: UIView, sourceRect: CGRect) {
if AccountManager.shared.accounts.count == 1 { if AccountManager.shared.accounts.count == 1 {
opmlAccount = AccountManager.shared.accounts.first!
exportOPMLDocumentPicker() exportOPMLDocumentPicker()
} else { } else {
exportOPMLAccountPicker(sourceView: sourceView, sourceRect: sourceRect) exportOPMLAccountPicker(sourceView: sourceView, sourceRect: sourceRect)

View File

@ -67,7 +67,7 @@ private extension TimelinePreviewTableViewController {
let prototypeID = "prototype" let prototypeID = "prototype"
let status = ArticleStatus(articleID: prototypeID, read: false, starred: false, userDeleted: false, dateArrived: Date()) let status = ArticleStatus(articleID: prototypeID, read: false, starred: false, userDeleted: false, dateArrived: Date())
let prototypeArticle = Article(accountID: prototypeID, articleID: prototypeID, webFeedID: prototypeID, uniqueID: prototypeID, title: longTitle, contentHTML: nil, contentText: nil, url: nil, externalURL: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: nil, dateModified: nil, authors: nil, status: status) let prototypeArticle = Article(accountID: prototypeID, articleID: prototypeID, webFeedID: prototypeID, uniqueID: prototypeID, title: longTitle, contentHTML: nil, contentText: nil, url: nil, externalURL: nil, summary: nil, imageURL: nil, datePublished: nil, dateModified: nil, authors: nil, status: status)
let iconImage = IconImage(AppAssets.faviconTemplateImage.withTintColor(AppAssets.secondaryAccentColor)) let iconImage = IconImage(AppAssets.faviconTemplateImage.withTintColor(AppAssets.secondaryAccentColor))

View File

@ -100,6 +100,9 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont
account = containerAccount account = containerAccount
} else if let containerFolder = container as? Folder, let containerAccount = containerFolder.account { } else if let containerFolder = container as? Folder, let containerAccount = containerFolder.account {
account = containerAccount account = containerAccount
} else {
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
return
} }
if let urlString = url?.absoluteString, account!.hasWebFeed(withURL: urlString) { if let urlString = url?.absoluteString, account!.hasWebFeed(withURL: urlString) {

View File

@ -8,10 +8,6 @@
import UIKit import UIKit
protocol InteractiveNavigationControllerTappable {
func didTapNavigationBar()
}
class InteractiveNavigationController: UINavigationController { class InteractiveNavigationController: UINavigationController {
private let poppableDelegate = PoppableGestureRecognizerDelegate() private let poppableDelegate = PoppableGestureRecognizerDelegate()
@ -33,8 +29,6 @@ class InteractiveNavigationController: UINavigationController {
poppableDelegate.originalDelegate = interactivePopGestureRecognizer?.delegate poppableDelegate.originalDelegate = interactivePopGestureRecognizer?.delegate
poppableDelegate.navigationController = self poppableDelegate.navigationController = self
interactivePopGestureRecognizer?.delegate = poppableDelegate interactivePopGestureRecognizer?.delegate = poppableDelegate
navigationBar.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapNavigationBar)))
} }
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
@ -44,12 +38,6 @@ class InteractiveNavigationController: UINavigationController {
} }
} }
@objc func didTapNavigationBar() {
if let tappable = topViewController as? InteractiveNavigationControllerTappable {
tappable.didTapNavigationBar()
}
}
} }
// MARK: Private // MARK: Private

@ -1 +1 @@
Subproject commit 16a33dad14992190f354006205387b8ea18058a1 Subproject commit a23d10cbd7adce19841300ad9e5d57c54ea150f6

@ -1 +1 @@
Subproject commit 2ebad90d4e9c586e9daadf98b025058541963566 Subproject commit d86cafc5d8593c28bf5a1454af07fc8fac82c297

View File

@ -1,7 +1,7 @@
// High Level Settings common to both the iOS application and any extensions we bundle with it // High Level Settings common to both the iOS application and any extensions we bundle with it
MARKETING_VERSION = 5.0 MARKETING_VERSION = 5.0
CURRENT_PROJECT_VERSION = 23 CURRENT_PROJECT_VERSION = 24
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon