Merge branch 'upstream-ios-candidate' into previewing-articles
This commit is contained in:
commit
d1004569b2
|
@ -120,7 +120,7 @@ class FeedlyAddNewFeedOperationTests: XCTestCase {
|
|||
XCTAssert(progress.isComplete)
|
||||
}
|
||||
|
||||
func testAddNewFeedSuccess() {
|
||||
func testAddNewFeedSuccess() throws {
|
||||
guard let folder = getFolderByLoadingInitialContent() else {
|
||||
return
|
||||
}
|
||||
|
@ -163,7 +163,7 @@ class FeedlyAddNewFeedOperationTests: XCTestCase {
|
|||
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
@ -50,7 +50,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: false) }
|
||||
|
||||
let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
container.database.insertStatuses(statuses) {
|
||||
container.database.insertStatuses(statuses) { error in
|
||||
XCTAssertNil(error)
|
||||
insertExpectation.fulfill()
|
||||
}
|
||||
|
||||
|
@ -74,7 +75,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
|
||||
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() {
|
||||
|
@ -82,7 +93,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: false) }
|
||||
|
||||
let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
container.database.insertStatuses(statuses) {
|
||||
container.database.insertStatuses(statuses) { error in
|
||||
XCTAssertNil(error)
|
||||
insertExpectation.fulfill()
|
||||
}
|
||||
|
||||
|
@ -106,7 +118,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
|
||||
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() {
|
||||
|
@ -114,7 +136,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: true) }
|
||||
|
||||
let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
container.database.insertStatuses(statuses) {
|
||||
container.database.insertStatuses(statuses) { error in
|
||||
XCTAssertNil(error)
|
||||
insertExpectation.fulfill()
|
||||
}
|
||||
|
||||
|
@ -138,7 +161,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
|
||||
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() {
|
||||
|
@ -146,7 +179,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .read, flag: true) }
|
||||
|
||||
let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
container.database.insertStatuses(statuses) {
|
||||
container.database.insertStatuses(statuses) { error in
|
||||
XCTAssertNil(error)
|
||||
insertExpectation.fulfill()
|
||||
}
|
||||
|
||||
|
@ -170,7 +204,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
|
||||
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() {
|
||||
|
@ -178,7 +222,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: true) }
|
||||
|
||||
let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
container.database.insertStatuses(statuses) {
|
||||
container.database.insertStatuses(statuses) { error in
|
||||
XCTAssertNil(error)
|
||||
insertExpectation.fulfill()
|
||||
}
|
||||
|
||||
|
@ -202,7 +247,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
|
||||
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() {
|
||||
|
@ -210,7 +265,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: true) }
|
||||
|
||||
let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
container.database.insertStatuses(statuses) {
|
||||
container.database.insertStatuses(statuses) { error in
|
||||
XCTAssertNil(error)
|
||||
insertExpectation.fulfill()
|
||||
}
|
||||
|
||||
|
@ -234,7 +290,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
|
||||
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() {
|
||||
|
@ -242,7 +308,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: false) }
|
||||
|
||||
let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
container.database.insertStatuses(statuses) {
|
||||
container.database.insertStatuses(statuses) { error in
|
||||
XCTAssertNil(error)
|
||||
insertExpectation.fulfill()
|
||||
}
|
||||
|
||||
|
@ -266,7 +333,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
|
||||
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() {
|
||||
|
@ -274,7 +351,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
let statuses = articleIds.map { SyncStatus(articleID: $0, key: .starred, flag: false) }
|
||||
|
||||
let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
container.database.insertStatuses(statuses) {
|
||||
container.database.insertStatuses(statuses) { error in
|
||||
XCTAssertNil(error)
|
||||
insertExpectation.fulfill()
|
||||
}
|
||||
|
||||
|
@ -298,7 +376,17 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
|
||||
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() {
|
||||
|
@ -313,7 +401,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
}
|
||||
|
||||
let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
container.database.insertStatuses(statuses) {
|
||||
container.database.insertStatuses(statuses) { error in
|
||||
XCTAssertNil(error)
|
||||
insertExpectation.fulfill()
|
||||
}
|
||||
|
||||
|
@ -346,7 +435,18 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
OperationQueue.main.addOperation(send)
|
||||
|
||||
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() {
|
||||
|
@ -361,7 +461,8 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
}
|
||||
|
||||
let insertExpectation = expectation(description: "Inserted Statuses")
|
||||
container.database.insertStatuses(statuses) {
|
||||
container.database.insertStatuses(statuses) { error in
|
||||
XCTAssertNil(error)
|
||||
insertExpectation.fulfill()
|
||||
}
|
||||
|
||||
|
@ -396,6 +497,16 @@ class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
|||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,10 +49,15 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchStarredArticleIDs { accountArticlesIDs in
|
||||
XCTAssertTrue(accountArticlesIDs.isEmpty)
|
||||
XCTAssertEqual(accountArticlesIDs, testIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchStarredArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertTrue(accountArticlesIDs.isEmpty)
|
||||
XCTAssertEqual(accountArticlesIDs, testIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking articles IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -73,9 +78,14 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchStarredArticleIDs { accountArticlesIDs in
|
||||
XCTAssertEqual(accountArticlesIDs.count, testIds.count)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchStarredArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertEqual(accountArticlesIDs.count, testIds.count)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking articles IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -96,9 +106,14 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchStarredArticleIDs { accountArticlesIDs in
|
||||
XCTAssertEqual(accountArticlesIDs.count, testIds.count)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchStarredArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertEqual(accountArticlesIDs.count, testIds.count)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking articles IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -134,9 +149,14 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchStarredArticleIDs { remainingAccountArticlesIDs in
|
||||
XCTAssertEqual(remainingAccountArticlesIDs, remainingStarredIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchStarredArticleIDs { remainingAccountArticlesIDsResult in
|
||||
do {
|
||||
let remainingAccountArticlesIDs = try remainingAccountArticlesIDsResult.get()
|
||||
XCTAssertEqual(remainingAccountArticlesIDs, remainingStarredIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking articles IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -172,9 +192,14 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchStarredArticleIDs { remainingAccountArticlesIDs in
|
||||
XCTAssertEqual(remainingAccountArticlesIDs, remainingStarredIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchStarredArticleIDs { remainingAccountArticlesIDsResult in
|
||||
do {
|
||||
let remainingAccountArticlesIDs = try remainingAccountArticlesIDsResult.get()
|
||||
XCTAssertEqual(remainingAccountArticlesIDs, remainingStarredIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking articles IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -221,15 +246,21 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchStarredArticleIDs { accountArticlesIDs in
|
||||
XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
|
||||
|
||||
let idsOfStarredArticles = Set(self.account
|
||||
.fetchArticles(.articleIDs(remainingStarredIds))
|
||||
.filter { $0.status.boolStatus(forKey: .starred) == true }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
|
||||
account.fetchStarredArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
|
||||
|
||||
let idsOfStarredArticles = Set(try self.account
|
||||
.fetchArticles(.articleIDs(remainingStarredIds))
|
||||
.filter { $0.status.boolStatus(forKey: .starred) == true }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking articles IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -274,15 +305,21 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchStarredArticleIDs { accountArticlesIDs in
|
||||
XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
|
||||
|
||||
let idsOfStarredArticles = Set(self.account
|
||||
.fetchArticles(.articleIDs(remainingStarredIds))
|
||||
.filter { $0.status.boolStatus(forKey: .starred) == true }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
|
||||
account.fetchStarredArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
|
||||
|
||||
let idsOfStarredArticles = Set(try self.account
|
||||
.fetchArticles(.articleIDs(remainingStarredIds))
|
||||
.filter { $0.status.boolStatus(forKey: .starred) == true }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking articles IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -321,16 +358,21 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchStarredArticleIDs { accountArticlesIDs in
|
||||
XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
|
||||
|
||||
let idsOfStarredArticles = Set(self.account
|
||||
.fetchArticles(.articleIDs(remainingStarredIds))
|
||||
.filter { $0.status.boolStatus(forKey: .starred) == true }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchStarredArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
|
||||
|
||||
let idsOfStarredArticles = Set(try self.account
|
||||
.fetchArticles(.articleIDs(remainingStarredIds))
|
||||
.filter { $0.status.boolStatus(forKey: .starred) == true }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking articles IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -368,16 +410,21 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchStarredArticleIDs { accountArticlesIDs in
|
||||
XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
|
||||
|
||||
let idsOfStarredArticles = Set(self.account
|
||||
.fetchArticles(.articleIDs(remainingStarredIds))
|
||||
.filter { $0.status.boolStatus(forKey: .starred) == true }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchStarredArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
|
||||
|
||||
let idsOfStarredArticles = Set(try self.account
|
||||
.fetchArticles(.articleIDs(remainingStarredIds))
|
||||
.filter { $0.status.boolStatus(forKey: .starred) == true }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfStarredArticles, remainingStarredIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking articles IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -418,19 +465,24 @@ class FeedlySetStarredArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchStarredArticleIDs { accountArticlesIDs in
|
||||
XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
|
||||
|
||||
let someTestItems = Set(someItemsAndFeeds.flatMap { $0.value })
|
||||
let someRemainingStarredIdsOfIngestedArticles = Set(someTestItems.compactMap { $0.syncServiceID })
|
||||
let idsOfStarredArticles = Set(self.account
|
||||
.fetchArticles(.articleIDs(someRemainingStarredIdsOfIngestedArticles))
|
||||
.filter { $0.status.boolStatus(forKey: .starred) == true }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfStarredArticles, someRemainingStarredIdsOfIngestedArticles)
|
||||
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchStarredArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertEqual(accountArticlesIDs, remainingStarredIds)
|
||||
|
||||
let someTestItems = Set(someItemsAndFeeds.flatMap { $0.value })
|
||||
let someRemainingStarredIdsOfIngestedArticles = Set(someTestItems.compactMap { $0.syncServiceID })
|
||||
let idsOfStarredArticles = Set(try self.account
|
||||
.fetchArticles(.articleIDs(someRemainingStarredIdsOfIngestedArticles))
|
||||
.filter { $0.status.boolStatus(forKey: .starred) == true }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfStarredArticles, someRemainingStarredIdsOfIngestedArticles)
|
||||
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking articles IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
|
|
@ -49,10 +49,15 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDs in
|
||||
XCTAssertTrue(accountArticlesIDs.isEmpty)
|
||||
XCTAssertEqual(accountArticlesIDs.count, testIds.count)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertTrue(accountArticlesIDs.isEmpty)
|
||||
XCTAssertEqual(accountArticlesIDs.count, testIds.count)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking account articles IDs result: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 2)
|
||||
|
@ -74,9 +79,14 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDs in
|
||||
XCTAssertEqual(accountArticlesIDs.count, testIds.count)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertEqual(accountArticlesIDs.count, testIds.count)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking account articles IDs result: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -97,9 +107,14 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDs in
|
||||
XCTAssertEqual(accountArticlesIDs.count, testIds.count)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertEqual(accountArticlesIDs.count, testIds.count)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking account articles IDs result: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -135,9 +150,14 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
|
||||
account.fetchUnreadArticleIDs { remainingAccountArticlesIDs in
|
||||
XCTAssertEqual(remainingAccountArticlesIDs, remainingUnreadIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchUnreadArticleIDs { remainingAccountArticlesIDsResult in
|
||||
do {
|
||||
let remainingAccountArticlesIDs = try remainingAccountArticlesIDsResult.get()
|
||||
XCTAssertEqual(remainingAccountArticlesIDs, remainingUnreadIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking account articles IDs result: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -173,9 +193,14 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
|
||||
account.fetchUnreadArticleIDs { remainingAccountArticlesIDs in
|
||||
XCTAssertEqual(remainingAccountArticlesIDs, remainingUnreadIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchUnreadArticleIDs { remainingAccountArticlesIDsResult in
|
||||
do {
|
||||
let remainingAccountArticlesIDs = try remainingAccountArticlesIDsResult.get()
|
||||
XCTAssertEqual(remainingAccountArticlesIDs, remainingUnreadIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking account articles IDs result: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -222,15 +247,20 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDs in
|
||||
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
|
||||
let idsOfUnreadArticles = Set(self.account
|
||||
.fetchArticles(.articleIDs(remainingUnreadIds))
|
||||
.filter { $0.status.boolStatus(forKey: .read) == false }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
|
||||
let idsOfUnreadArticles = Set(try self.account
|
||||
.fetchArticles(.articleIDs(remainingUnreadIds))
|
||||
.filter { $0.status.boolStatus(forKey: .read) == false }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking account articles IDs result: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -275,16 +305,21 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDs in
|
||||
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
|
||||
|
||||
let idsOfUnreadArticles = Set(self.account
|
||||
.fetchArticles(.articleIDs(remainingUnreadIds))
|
||||
.filter { $0.status.boolStatus(forKey: .read) == false }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
|
||||
|
||||
let idsOfUnreadArticles = Set(try self.account
|
||||
.fetchArticles(.articleIDs(remainingUnreadIds))
|
||||
.filter { $0.status.boolStatus(forKey: .read) == false }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking account articles IDs result: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -322,16 +357,21 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDs in
|
||||
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
|
||||
|
||||
let idsOfUnreadArticles = Set(self.account
|
||||
.fetchArticles(.articleIDs(remainingUnreadIds))
|
||||
.filter { $0.status.boolStatus(forKey: .read) == false }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
|
||||
|
||||
let idsOfUnreadArticles = Set(try self.account
|
||||
.fetchArticles(.articleIDs(remainingUnreadIds))
|
||||
.filter { $0.status.boolStatus(forKey: .read) == false }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking account articles IDs result: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -369,16 +409,21 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDs in
|
||||
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
|
||||
|
||||
let idsOfUnreadArticles = Set(self.account
|
||||
.fetchArticles(.articleIDs(remainingUnreadIds))
|
||||
.filter { $0.status.boolStatus(forKey: .read) == false }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
|
||||
|
||||
let idsOfUnreadArticles = Set(try self.account
|
||||
.fetchArticles(.articleIDs(remainingUnreadIds))
|
||||
.filter { $0.status.boolStatus(forKey: .read) == false }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfUnreadArticles, remainingUnreadIds)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking account articles IDs result: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -418,18 +463,23 @@ class FeedlySetUnreadArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetched Articles Ids")
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDs in
|
||||
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
|
||||
|
||||
let someTestItems = Set(someItemsAndFeeds.flatMap { $0.value })
|
||||
let someRemainingUnreadIdsOfIngestedArticles = Set(someTestItems.compactMap { $0.syncServiceID })
|
||||
let idsOfUnreadArticles = Set(self.account
|
||||
.fetchArticles(.articleIDs(someRemainingUnreadIdsOfIngestedArticles))
|
||||
.filter { $0.status.boolStatus(forKey: .read) == false }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfUnreadArticles, someRemainingUnreadIdsOfIngestedArticles)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchUnreadArticleIDs { accountArticlesIDsResult in
|
||||
do {
|
||||
let accountArticlesIDs = try accountArticlesIDsResult.get()
|
||||
XCTAssertEqual(accountArticlesIDs, remainingUnreadIds)
|
||||
|
||||
let someTestItems = Set(someItemsAndFeeds.flatMap { $0.value })
|
||||
let someRemainingUnreadIdsOfIngestedArticles = Set(someTestItems.compactMap { $0.syncServiceID })
|
||||
let idsOfUnreadArticles = Set(try self.account
|
||||
.fetchArticles(.articleIDs(someRemainingUnreadIdsOfIngestedArticles))
|
||||
.filter { $0.status.boolStatus(forKey: .read) == false }
|
||||
.map { $0.articleID })
|
||||
|
||||
XCTAssertEqual(idsOfUnreadArticles, someRemainingUnreadIdsOfIngestedArticles)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking account articles IDs result: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -114,18 +114,18 @@ class FeedlySyncAllOperationTests: XCTestCase {
|
|||
return caller
|
||||
}()
|
||||
|
||||
func testSyncing() {
|
||||
func testSyncing() throws {
|
||||
performInitialSync()
|
||||
verifyInitialSync()
|
||||
try verifyInitialSync()
|
||||
|
||||
performChangeStatuses()
|
||||
verifyChangeStatuses()
|
||||
try verifyChangeStatuses()
|
||||
|
||||
performChangeStatusesAgain()
|
||||
verifyChangeStatusesAgain()
|
||||
try verifyChangeStatusesAgain()
|
||||
|
||||
performAddFeedsAndFolders()
|
||||
verifyAddFeedsAndFolders()
|
||||
try verifyAddFeedsAndFolders()
|
||||
}
|
||||
|
||||
// MARK: 1 - Initial Sync
|
||||
|
@ -166,15 +166,15 @@ class FeedlySyncAllOperationTests: XCTestCase {
|
|||
loadMockData(inSubdirectoryNamed: "feedly-1-initial")
|
||||
}
|
||||
|
||||
func verifyInitialSync() {
|
||||
func verifyInitialSync() throws {
|
||||
let subdirectory = "feedly-1-initial"
|
||||
support.checkFoldersAndFeeds(in: account, againstCollectionsAndFeedsInJSONNamed: "collections", subdirectory: subdirectory)
|
||||
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", 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@MTZkOTRhOTNhZTQ6MzExOjUzYjgyNmEy", 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
|
||||
|
@ -183,14 +183,14 @@ class FeedlySyncAllOperationTests: XCTestCase {
|
|||
loadMockData(inSubdirectoryNamed: "feedly-2-changestatuses")
|
||||
}
|
||||
|
||||
func verifyChangeStatuses() {
|
||||
func verifyChangeStatuses() throws {
|
||||
let subdirectory = "feedly-2-changestatuses"
|
||||
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@MTZkOTJkNjIwM2Q6MTEzYjpkNDUwNjA3MQ==", 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
|
||||
|
@ -199,14 +199,14 @@ class FeedlySyncAllOperationTests: XCTestCase {
|
|||
loadMockData(inSubdirectoryNamed: "feedly-3-changestatusesagain")
|
||||
}
|
||||
|
||||
func verifyChangeStatusesAgain() {
|
||||
func verifyChangeStatusesAgain() throws {
|
||||
let subdirectory = "feedly-3-changestatusesagain"
|
||||
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@MTZkOGRlMjVmM2M6M2YyOmQ0NTA2MDcx", 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
|
||||
|
@ -215,14 +215,14 @@ class FeedlySyncAllOperationTests: XCTestCase {
|
|||
loadMockData(inSubdirectoryNamed: "feedly-4-addfeedsandfolders")
|
||||
}
|
||||
|
||||
func verifyAddFeedsAndFolders() {
|
||||
func verifyAddFeedsAndFolders() throws {
|
||||
let subdirectory = "feedly-4-addfeedsandfolders"
|
||||
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@MTZkOTE3YTRlMzQ6YWZjOmQ0NTA2MDcx", 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
|
||||
|
@ -231,14 +231,14 @@ class FeedlySyncAllOperationTests: XCTestCase {
|
|||
loadMockData(inSubdirectoryNamed: "feedly-5-removefeedsandfolders")
|
||||
}
|
||||
|
||||
func verifyRemoveFeedsAndFolders() {
|
||||
func verifyRemoveFeedsAndFolders() throws {
|
||||
let subdirectory = "feedly-5-removefeedsandfolders"
|
||||
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@MTZkOGRlMjVmM2M6M2YxOmQ0NTA2MDcx", 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
|
||||
|
|
|
@ -56,21 +56,26 @@ class FeedlySyncStarredArticlesOperationTests: XCTestCase {
|
|||
|
||||
let expectedArticleIds = Set(items.map { $0.id })
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchStarredArticleIDs { starredArticleIds in
|
||||
let missingIds = expectedArticleIds.subtracting(starredArticleIds)
|
||||
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.
|
||||
let expectedArticles = self.account.fetchArticles(.articleIDs(expectedArticleIds))
|
||||
XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.")
|
||||
|
||||
let starredArticles = self.account.fetchArticles(.articleIDs(starredArticleIds))
|
||||
XCTAssertEqual(expectedArticleIds.count, expectedArticles.count)
|
||||
let missingArticles = expectedArticles.subtracting(starredArticles)
|
||||
XCTAssertTrue(missingArticles.isEmpty, "These articles should be starred and fetched.")
|
||||
XCTAssertEqual(expectedArticles, starredArticles)
|
||||
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchStarredArticleIDs { starredArticleIdsResult in
|
||||
do {
|
||||
let starredArticleIds = try starredArticleIdsResult.get()
|
||||
let missingIds = expectedArticleIds.subtracting(starredArticleIds)
|
||||
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.
|
||||
let expectedArticles = try self.account.fetchArticles(.articleIDs(expectedArticleIds))
|
||||
XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.")
|
||||
|
||||
let starredArticles = try self.account.fetchArticles(.articleIDs(starredArticleIds))
|
||||
XCTAssertEqual(expectedArticleIds.count, expectedArticles.count)
|
||||
let missingArticles = expectedArticles.subtracting(starredArticles)
|
||||
XCTAssertTrue(missingArticles.isEmpty, "These articles should be starred and fetched.")
|
||||
XCTAssertEqual(expectedArticles, starredArticles)
|
||||
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking starred article IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -104,9 +109,14 @@ class FeedlySyncStarredArticlesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchStarredArticleIDs { starredArticleIds in
|
||||
XCTAssertTrue(starredArticleIds.isEmpty)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchStarredArticleIDs { starredArticleIdsResult in
|
||||
do {
|
||||
let starredArticleIds = try starredArticleIdsResult.get()
|
||||
XCTAssertTrue(starredArticleIds.isEmpty)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking starred article IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -153,21 +163,26 @@ class FeedlySyncStarredArticlesOperationTests: XCTestCase {
|
|||
// Find articles inserted.
|
||||
let expectedArticleIds = Set(service.pages.values.map { $0.items }.flatMap { $0 }.map { $0.id })
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchStarredArticleIDs { starredArticleIds in
|
||||
let missingIds = expectedArticleIds.subtracting(starredArticleIds)
|
||||
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.
|
||||
let expectedArticles = self.account.fetchArticles(.articleIDs(expectedArticleIds))
|
||||
XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.")
|
||||
|
||||
let starredArticles = self.account.fetchArticles(.articleIDs(starredArticleIds))
|
||||
XCTAssertEqual(expectedArticleIds.count, expectedArticles.count)
|
||||
let missingArticles = expectedArticles.subtracting(starredArticles)
|
||||
XCTAssertTrue(missingArticles.isEmpty, "These articles should be starred and fetched.")
|
||||
XCTAssertEqual(expectedArticles, starredArticles)
|
||||
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchStarredArticleIDs { starredArticleIdsResult in
|
||||
do {
|
||||
let starredArticleIds = try starredArticleIdsResult.get()
|
||||
let missingIds = expectedArticleIds.subtracting(starredArticleIds)
|
||||
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.
|
||||
let expectedArticles = try self.account.fetchArticles(.articleIDs(expectedArticleIds))
|
||||
XCTAssertEqual(expectedArticles.count, expectedArticleIds.count, "Did not fetch all the articles.")
|
||||
|
||||
let starredArticles = try self.account.fetchArticles(.articleIDs(starredArticleIds))
|
||||
XCTAssertEqual(expectedArticleIds.count, expectedArticles.count)
|
||||
let missingArticles = expectedArticles.subtracting(starredArticles)
|
||||
XCTAssertTrue(missingArticles.isEmpty, "These articles should be starred and fetched.")
|
||||
XCTAssertEqual(expectedArticles, starredArticles)
|
||||
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking starred article IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ class FeedlySyncStreamContentsOperationTests: XCTestCase {
|
|||
super.tearDown()
|
||||
}
|
||||
|
||||
func testIngestsOnePageSuccess() {
|
||||
func testIngestsOnePageSuccess() throws {
|
||||
let service = TestGetStreamContentsService()
|
||||
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
|
||||
let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 0)
|
||||
|
@ -56,7 +56,7 @@ class FeedlySyncStreamContentsOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
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.")
|
||||
}
|
||||
|
||||
|
@ -90,7 +90,7 @@ class FeedlySyncStreamContentsOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
||||
func testIngestsManyPagesSuccess() {
|
||||
func testIngestsManyPagesSuccess() throws {
|
||||
let service = TestGetPagedStreamContentsService()
|
||||
let resource = FeedlyCategoryResourceId(id: "user/1234/category/5678")
|
||||
let newerThan: Date? = Date(timeIntervalSinceReferenceDate: 0)
|
||||
|
@ -132,7 +132,7 @@ class FeedlySyncStreamContentsOperationTests: XCTestCase {
|
|||
|
||||
// Find articles inserted.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,10 +56,15 @@ class FeedlySyncUnreadStatusesOperationTests: XCTestCase {
|
|||
|
||||
let expectedArticleIds = Set(ids)
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchUnreadArticleIDs { unreadArticleIds in
|
||||
let missingIds = expectedArticleIds.subtracting(unreadArticleIds)
|
||||
XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as unread.")
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchUnreadArticleIDs { unreadArticleIdsResult in
|
||||
do {
|
||||
let unreadArticleIds = try unreadArticleIdsResult.get()
|
||||
let missingIds = expectedArticleIds.subtracting(unreadArticleIds)
|
||||
XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as unread.")
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking unread article IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -93,9 +98,14 @@ class FeedlySyncUnreadStatusesOperationTests: XCTestCase {
|
|||
waitForExpectations(timeout: 2)
|
||||
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchUnreadArticleIDs { unreadArticleIds in
|
||||
XCTAssertTrue(unreadArticleIds.isEmpty)
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchUnreadArticleIDs { unreadArticleIdsResult in
|
||||
do {
|
||||
let unreadArticleIds = try unreadArticleIdsResult.get()
|
||||
XCTAssertTrue(unreadArticleIds.isEmpty)
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking unread article IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
@ -142,10 +152,15 @@ class FeedlySyncUnreadStatusesOperationTests: XCTestCase {
|
|||
// Find statuses inserted.
|
||||
let expectedArticleIds = Set(service.pages.values.map { $0.ids }.flatMap { $0 })
|
||||
let fetchIdsExpectation = expectation(description: "Fetch Article Ids")
|
||||
account.fetchUnreadArticleIDs { unreadArticleIds in
|
||||
let missingIds = expectedArticleIds.subtracting(unreadArticleIds)
|
||||
XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as unread.")
|
||||
fetchIdsExpectation.fulfill()
|
||||
account.fetchUnreadArticleIDs { unreadArticleIdsResult in
|
||||
do {
|
||||
let unreadArticleIds = try unreadArticleIdsResult.get()
|
||||
let missingIds = expectedArticleIds.subtracting(unreadArticleIds)
|
||||
XCTAssertTrue(missingIds.isEmpty, "These article ids were not marked as unread.")
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error checking unread article IDs: \(error)")
|
||||
}
|
||||
}
|
||||
waitForExpectations(timeout: 2)
|
||||
}
|
||||
|
|
|
@ -141,13 +141,13 @@ class FeedlyTestSupport {
|
|||
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]
|
||||
checkArticles(in: account, againstItemsInStreamInJSONPayload: stream)
|
||||
try checkArticles(in: account, againstItemsInStreamInJSONPayload: stream)
|
||||
}
|
||||
|
||||
func checkArticles(in account: Account, againstItemsInStreamInJSONPayload stream: [String: Any]) {
|
||||
checkArticles(in: account, correspondToStreamItemsIn: stream)
|
||||
func checkArticles(in account: Account, againstItemsInStreamInJSONPayload stream: [String: Any]) throws {
|
||||
try checkArticles(in: account, correspondToStreamItemsIn: stream)
|
||||
}
|
||||
|
||||
private struct ArticleItem {
|
||||
|
@ -188,13 +188,13 @@ class FeedlyTestSupport {
|
|||
}
|
||||
|
||||
/// 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 articleItems = items.map { ArticleItem(item: $0) }
|
||||
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 missing = itemIds.subtracting(articleIds)
|
||||
|
@ -220,12 +220,17 @@ class FeedlyTestSupport {
|
|||
func checkUnreadStatuses(in testAccount: Account, correspondToIdsInJSONPayload streamIds: [String: Any], testCase: XCTestCase) {
|
||||
let ids = Set(streamIds["ids"] as! [String])
|
||||
let fetchIdsExpectation = testCase.expectation(description: "Fetch Article Ids")
|
||||
testAccount.fetchUnreadArticleIDs { articleIds in
|
||||
// Unread statuses can be paged from Feedly.
|
||||
// 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).
|
||||
XCTAssertTrue(ids.isSubset(of: articleIds), "Some articles in `ids` are not marked as unread.")
|
||||
fetchIdsExpectation.fulfill()
|
||||
testAccount.fetchUnreadArticleIDs { articleIdsResult in
|
||||
do {
|
||||
let articleIds = try articleIdsResult.get()
|
||||
// Unread statuses can be paged from Feedly.
|
||||
// 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).
|
||||
XCTAssertTrue(ids.isSubset(of: articleIds), "Some articles in `ids` are not marked as unread.")
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error unwrapping article IDs: \(error)")
|
||||
}
|
||||
}
|
||||
testCase.wait(for: [fetchIdsExpectation], timeout: 2)
|
||||
}
|
||||
|
@ -239,12 +244,17 @@ class FeedlyTestSupport {
|
|||
let items = stream["items"] as! [[String: Any]]
|
||||
let ids = Set(items.map { $0["id"] as! String })
|
||||
let fetchIdsExpectation = testCase.expectation(description: "Fetch Article Ids")
|
||||
testAccount.fetchStarredArticleIDs { articleIds in
|
||||
// Starred articles can be paged from Feedly.
|
||||
// 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).
|
||||
XCTAssertTrue(ids.isSubset(of: articleIds), "Some articles in `ids` are not marked as starred.")
|
||||
fetchIdsExpectation.fulfill()
|
||||
testAccount.fetchStarredArticleIDs { articleIdsResult in
|
||||
do {
|
||||
let articleIds = try articleIdsResult.get()
|
||||
// Starred articles can be paged from Feedly.
|
||||
// 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).
|
||||
XCTAssertTrue(ids.isSubset(of: articleIds), "Some articles in `ids` are not marked as starred.")
|
||||
fetchIdsExpectation.fulfill()
|
||||
} catch {
|
||||
XCTFail("Error unwrapping article IDs: \(error)")
|
||||
}
|
||||
}
|
||||
testCase.wait(for: [fetchIdsExpectation], timeout: 2)
|
||||
}
|
||||
|
|
|
@ -32,7 +32,7 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase {
|
|||
var parsedItemsKeyedByFeedId: [String: Set<ParsedItem>]
|
||||
}
|
||||
|
||||
func testUpdateAccountWithEmptyItems() {
|
||||
func testUpdateAccountWithEmptyItems() throws {
|
||||
let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 0, numberOfItemsInFeeds: 0)
|
||||
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
|
||||
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems)
|
||||
|
@ -52,11 +52,11 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase {
|
|||
let articleIds = Set(entries.compactMap { $0.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)
|
||||
}
|
||||
|
||||
func testUpdateAccountWithOneItem() {
|
||||
func testUpdateAccountWithOneItem() throws {
|
||||
let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 1, numberOfItemsInFeeds: 1)
|
||||
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
|
||||
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems)
|
||||
|
@ -76,7 +76,7 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase {
|
|||
let articleIds = Set(entries.compactMap { $0.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)
|
||||
|
||||
let accountArticleIds = Set(accountArticles.map { $0.articleID })
|
||||
|
@ -84,7 +84,7 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase {
|
|||
XCTAssertTrue(missingIds.isEmpty)
|
||||
}
|
||||
|
||||
func testUpdateAccountWithManyItems() {
|
||||
func testUpdateAccountWithManyItems() throws {
|
||||
let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 100, numberOfItemsInFeeds: 100)
|
||||
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
|
||||
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems)
|
||||
|
@ -104,7 +104,7 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase {
|
|||
let articleIds = Set(entries.compactMap { $0.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)
|
||||
|
||||
let accountArticleIds = Set(accountArticles.map { $0.articleID })
|
||||
|
@ -112,7 +112,7 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase {
|
|||
XCTAssertTrue(missingIds.isEmpty)
|
||||
}
|
||||
|
||||
func testCancelUpdateAccount() {
|
||||
func testCancelUpdateAccount() throws {
|
||||
let testItems = support.makeParsedItemTestDataFor(numberOfFeeds: 1, numberOfItemsInFeeds: 1)
|
||||
let resource = FeedlyCategoryResourceId(id: "user/12345/category/6789")
|
||||
let provider = TestItemsByFeedProvider(providerName: resource.id, parsedItemsKeyedByFeedId: testItems)
|
||||
|
@ -134,7 +134,7 @@ class FeedlyUpdateAccountFeedsWithItemsOperationTests: XCTestCase {
|
|||
let articleIds = Set(entries.compactMap { $0.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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1237,20 +1237,38 @@ private extension FeedbinAccountDelegate {
|
|||
return
|
||||
}
|
||||
|
||||
let feedbinUnreadArticleIDs = Set(articleIDs.map { String($0) } )
|
||||
account.fetchUnreadArticleIDs { articleIDsResult in
|
||||
guard let currentUnreadArticleIDs = try? articleIDsResult.get() else {
|
||||
return
|
||||
database.selectPendingReadStatusArticleIDs() { result in
|
||||
|
||||
func process(_ pendingArticleIDs: Set<String>) {
|
||||
|
||||
let feedbinUnreadArticleIDs = Set(articleIDs.map { String($0) } )
|
||||
let updatableFeedbinUnreadArticleIDs = feedbinUnreadArticleIDs.subtracting(pendingArticleIDs)
|
||||
|
||||
account.fetchUnreadArticleIDs { articleIDsResult in
|
||||
guard let currentUnreadArticleIDs = try? articleIDsResult.get() else {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark articles as unread
|
||||
let deltaUnreadArticleIDs = updatableFeedbinUnreadArticleIDs.subtracting(currentUnreadArticleIDs)
|
||||
account.markAsUnread(deltaUnreadArticleIDs)
|
||||
|
||||
// Mark articles as read
|
||||
let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableFeedbinUnreadArticleIDs)
|
||||
account.markAsRead(deltaReadArticleIDs)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Mark articles as unread
|
||||
let deltaUnreadArticleIDs = feedbinUnreadArticleIDs.subtracting(currentUnreadArticleIDs)
|
||||
account.markAsUnread(deltaUnreadArticleIDs)
|
||||
|
||||
// Mark articles as read
|
||||
let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(feedbinUnreadArticleIDs)
|
||||
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]?) {
|
||||
|
@ -1258,20 +1276,38 @@ private extension FeedbinAccountDelegate {
|
|||
return
|
||||
}
|
||||
|
||||
let feedbinStarredArticleIDs = Set(articleIDs.map { String($0) } )
|
||||
account.fetchStarredArticleIDs { articleIDsResult in
|
||||
guard let currentStarredArticleIDs = try? articleIDsResult.get() else {
|
||||
return
|
||||
database.selectPendingStarredStatusArticleIDs() { result in
|
||||
|
||||
func process(_ pendingArticleIDs: Set<String>) {
|
||||
|
||||
let feedbinStarredArticleIDs = Set(articleIDs.map { String($0) } )
|
||||
let updatableFeedbinUnreadArticleIDs = feedbinStarredArticleIDs.subtracting(pendingArticleIDs)
|
||||
|
||||
account.fetchStarredArticleIDs { articleIDsResult in
|
||||
guard let currentStarredArticleIDs = try? articleIDsResult.get() else {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark articles as starred
|
||||
let deltaStarredArticleIDs = updatableFeedbinUnreadArticleIDs.subtracting(currentStarredArticleIDs)
|
||||
account.markAsStarred(deltaStarredArticleIDs)
|
||||
|
||||
// Mark articles as unstarred
|
||||
let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableFeedbinUnreadArticleIDs)
|
||||
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)
|
||||
}
|
||||
|
||||
// Mark articles as starred
|
||||
let deltaStarredArticleIDs = feedbinStarredArticleIDs.subtracting(currentStarredArticleIDs)
|
||||
account.markAsStarred(deltaStarredArticleIDs)
|
||||
|
||||
// Mark articles as unstarred
|
||||
let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(feedbinStarredArticleIDs)
|
||||
account.markAsUnstarred(deltaUnstarredArticleIDs)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func deleteTagging(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
|
|
@ -17,10 +17,10 @@ struct FeedlyEntryParser {
|
|||
return entry.id
|
||||
}
|
||||
|
||||
var feedUrl: String {
|
||||
var feedUrl: String? {
|
||||
guard let id = entry.origin?.streamId else {
|
||||
assertionFailure()
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
@ -82,7 +82,11 @@ struct FeedlyEntryParser {
|
|||
return attachments.isEmpty ? nil : Set(attachments)
|
||||
}
|
||||
|
||||
var parsedItemRepresentation: ParsedItem {
|
||||
var parsedItemRepresentation: ParsedItem? {
|
||||
guard let feedUrl = feedUrl else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ParsedItem(syncServiceID: id,
|
||||
uniqueID: id, // This value seems to get ignored or replaced.
|
||||
feedURL: feedUrl,
|
||||
|
|
|
@ -10,6 +10,6 @@ import Foundation
|
|||
|
||||
struct FeedlyOrigin: Decodable {
|
||||
var title: String?
|
||||
var streamId: String
|
||||
var htmlUrl: String
|
||||
var streamId: String?
|
||||
var htmlUrl: String?
|
||||
}
|
||||
|
|
|
@ -50,7 +50,17 @@ final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntryProvid
|
|||
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
|
||||
|
||||
return parsed
|
||||
|
|
|
@ -29,14 +29,17 @@ class FeedlyOperation: Operation {
|
|||
}
|
||||
}
|
||||
|
||||
override var isAsynchronous: Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func didFinish() {
|
||||
assert(Thread.isMainThread)
|
||||
assert(!isFinished, "Finished operation is attempting to finish again.")
|
||||
|
||||
downloadProgress = nil
|
||||
|
||||
isExecutingOperation = false
|
||||
isFinishedOperation = true
|
||||
updateExecutingAndFinished(false, true)
|
||||
}
|
||||
|
||||
func didFinish(_ error: Error) {
|
||||
|
@ -58,9 +61,8 @@ class FeedlyOperation: Operation {
|
|||
|
||||
override func start() {
|
||||
guard !isCancelled else {
|
||||
isExecutingOperation = false
|
||||
isFinishedOperation = true
|
||||
|
||||
updateExecutingAndFinished(false, true)
|
||||
|
||||
if downloadProgress != nil {
|
||||
DispatchQueue.main.async {
|
||||
self.downloadProgress = nil
|
||||
|
@ -69,8 +71,8 @@ class FeedlyOperation: Operation {
|
|||
|
||||
return
|
||||
}
|
||||
|
||||
isExecutingOperation = true
|
||||
|
||||
updateExecutingAndFinished(true, false)
|
||||
DispatchQueue.main.async {
|
||||
self.main()
|
||||
}
|
||||
|
@ -80,25 +82,31 @@ class FeedlyOperation: Operation {
|
|||
return isExecutingOperation
|
||||
}
|
||||
|
||||
private var isExecutingOperation = false {
|
||||
willSet {
|
||||
willChangeValue(for: \.isExecuting)
|
||||
}
|
||||
didSet {
|
||||
didChangeValue(for: \.isExecuting)
|
||||
}
|
||||
}
|
||||
|
||||
override var isFinished: Bool {
|
||||
return isFinishedOperation
|
||||
}
|
||||
|
||||
private var isFinishedOperation = false {
|
||||
willSet {
|
||||
willChangeValue(for: \.isFinished)
|
||||
|
||||
private var isExecutingOperation = false
|
||||
private var isFinishedOperation = false
|
||||
|
||||
private func updateExecutingAndFinished(_ executing: Bool, _ finished: Bool) {
|
||||
let isExecutingDidChange = executing != isExecutingOperation
|
||||
let isFinishedDidChange = finished != isFinishedOperation
|
||||
|
||||
if isFinishedDidChange {
|
||||
willChangeValue(forKey: #keyPath(isFinished))
|
||||
}
|
||||
didSet {
|
||||
didChangeValue(for: \.isFinished)
|
||||
if isExecutingDidChange {
|
||||
willChangeValue(forKey: #keyPath(isExecuting))
|
||||
}
|
||||
isExecutingOperation = executing
|
||||
isFinishedOperation = finished
|
||||
|
||||
if isExecutingDidChange {
|
||||
didChangeValue(forKey: #keyPath(isExecuting))
|
||||
}
|
||||
if isFinishedDidChange {
|
||||
didChangeValue(forKey: #keyPath(isFinished))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,13 +23,12 @@ public struct Article: Hashable {
|
|||
public let externalURL: String?
|
||||
public let summary: String?
|
||||
public let imageURL: String?
|
||||
public let bannerImageURL: String?
|
||||
public let datePublished: Date?
|
||||
public let dateModified: Date?
|
||||
public let authors: Set<Author>?
|
||||
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.webFeedID = webFeedID
|
||||
self.uniqueID = uniqueID
|
||||
|
@ -40,7 +39,6 @@ public struct Article: Hashable {
|
|||
self.externalURL = externalURL
|
||||
self.summary = summary
|
||||
self.imageURL = imageURL
|
||||
self.bannerImageURL = bannerImageURL
|
||||
self.datePublished = datePublished
|
||||
self.dateModified = dateModified
|
||||
self.authors = authors
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
841D4D742106B59F00DD04E6 /* Articles.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841D4D732106B59F00DD04E6 /* Articles.framework */; };
|
||||
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 */; };
|
||||
843577161F744FC800F460AE /* DatabaseArticle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843577151F744FC800F460AE /* DatabaseArticle.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 */; };
|
||||
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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
|
@ -176,7 +174,6 @@
|
|||
845580661F0AEBCD003CCFA1 /* Constants.swift */,
|
||||
84E156EB1F0AB80E00F8CC05 /* ArticlesTable.swift */,
|
||||
8477ACBB2221E76F00DF7F37 /* SearchTable.swift */,
|
||||
843577151F744FC800F460AE /* DatabaseArticle.swift */,
|
||||
84E156ED1F0AB81400F8CC05 /* StatusesTable.swift */,
|
||||
84F20F8E1F180D8700D8E682 /* AuthorsTable.swift */,
|
||||
8461462A1F0AC44100870CB3 /* Extensions */,
|
||||
|
@ -350,14 +347,14 @@
|
|||
TargetAttributes = {
|
||||
844BEE361F0AB3AA004AB7CD = {
|
||||
CreatedOnToolsVersion = 8.3.2;
|
||||
DevelopmentTeam = SHJK2V3AJG;
|
||||
DevelopmentTeam = M8L2WTLA8W;
|
||||
LastSwiftMigration = 0830;
|
||||
ProvisioningStyle = Automatic;
|
||||
ProvisioningStyle = Manual;
|
||||
};
|
||||
844BEE3F1F0AB3AB004AB7CD = {
|
||||
CreatedOnToolsVersion = 8.3.2;
|
||||
DevelopmentTeam = SHJK2V3AJG;
|
||||
ProvisioningStyle = Automatic;
|
||||
DevelopmentTeam = M8L2WTLA8W;
|
||||
ProvisioningStyle = Manual;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
@ -522,7 +519,6 @@
|
|||
84F20F8F1F180D8700D8E682 /* AuthorsTable.swift in Sources */,
|
||||
84288A001F6A3C4400395871 /* DatabaseObject+Database.swift in Sources */,
|
||||
8477ACBC2221E76F00DF7F37 /* SearchTable.swift in Sources */,
|
||||
843577161F744FC800F460AE /* DatabaseArticle.swift in Sources */,
|
||||
843702C31F70D15D00B18807 /* ParsedArticle+Database.swift in Sources */,
|
||||
84E156EC1F0AB80E00F8CC05 /* ArticlesTable.swift in Sources */,
|
||||
84E156EE1F0AB81400F8CC05 /* StatusesTable.swift in Sources */,
|
||||
|
|
|
@ -19,14 +19,14 @@ final class ArticlesTable: DatabaseTable {
|
|||
private let queue: DatabaseQueue
|
||||
private let statusesTable: StatusesTable
|
||||
private let authorsLookupTable: DatabaseLookupTable
|
||||
private var databaseArticlesCache = [String: DatabaseArticle]()
|
||||
private var articlesCache = [String: Article]()
|
||||
|
||||
private lazy var searchTable: SearchTable = {
|
||||
return SearchTable(queue: queue, articlesTable: self)
|
||||
}()
|
||||
|
||||
// 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>
|
||||
|
||||
|
@ -212,6 +212,9 @@ final class ArticlesTable: DatabaseTable {
|
|||
|
||||
self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, completion) //7
|
||||
|
||||
self.addArticlesToCache(newArticles)
|
||||
self.addArticlesToCache(updatedArticles)
|
||||
|
||||
// 8. Update search index.
|
||||
if let newArticles = newArticles {
|
||||
self.searchTable.indexNewArticles(newArticles, database)
|
||||
|
@ -302,9 +305,9 @@ final class ArticlesTable: DatabaseTable {
|
|||
queue.runInDatabase { databaseResult in
|
||||
|
||||
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 {
|
||||
completion(.success(UnreadCountDictionary()))
|
||||
}
|
||||
|
@ -449,7 +452,7 @@ final class ArticlesTable: DatabaseTable {
|
|||
|
||||
func emptyCaches() {
|
||||
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> {
|
||||
// 1. Create DatabaseArticles without related objects.
|
||||
// 2. Then fetch the related objects, given the set of articleIDs.
|
||||
// 3. Then create set of Articles with DatabaseArticles and related objects and return it.
|
||||
var cachedArticles = Set<Article>()
|
||||
var fetchedArticles = Set<Article>()
|
||||
|
||||
// 1. Create databaseArticles (intermediate representations).
|
||||
while resultSet.next() {
|
||||
|
||||
let databaseArticles = makeDatabaseArticles(with: resultSet)
|
||||
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 {
|
||||
guard let articleID = resultSet.string(forColumn: DatabaseKey.articleID) else {
|
||||
assertionFailure("Expected articleID.")
|
||||
return nil
|
||||
continue
|
||||
}
|
||||
|
||||
// Articles are removed from the cache when they’re updated.
|
||||
// See saveUpdatedArticles.
|
||||
if let databaseArticle = databaseArticlesCache[articleID] {
|
||||
return databaseArticle
|
||||
if let article = articlesCache[articleID] {
|
||||
cachedArticles.insert(article)
|
||||
continue
|
||||
}
|
||||
|
||||
// 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.
|
||||
guard let status = statusesTable.statusWithRow(resultSet, articleID: articleID) else {
|
||||
assertionFailure("Expected status.")
|
||||
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
|
||||
continue
|
||||
}
|
||||
|
||||
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 bannerImageURL = row.string(forColumn: DatabaseKey.bannerImageURL)
|
||||
let datePublished = row.date(forColumn: DatabaseKey.datePublished)
|
||||
let dateModified = row.date(forColumn: DatabaseKey.dateModified)
|
||||
guard let article = Article(accountID: accountID, row: resultSet, status: status) else {
|
||||
continue
|
||||
}
|
||||
fetchedArticles.insert(article)
|
||||
}
|
||||
resultSet.close()
|
||||
|
||||
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)
|
||||
databaseArticlesCache[articleID] = databaseArticle
|
||||
return databaseArticle
|
||||
if fetchedArticles.isEmpty {
|
||||
return cachedArticles
|
||||
}
|
||||
|
||||
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> {
|
||||
|
@ -615,8 +587,8 @@ private extension ArticlesTable {
|
|||
// * Must be either 1) starred or 2) dateArrived must be newer than cutoff date.
|
||||
|
||||
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 > ?)));"
|
||||
return articlesWithSQL(sql, parameters + [articleCutoffDate as AnyObject] + [articleCutoffDate as AnyObject], database)
|
||||
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], database)
|
||||
}
|
||||
else {
|
||||
let sql = "select * from articles natural join statuses where \(whereClause);"
|
||||
|
@ -630,8 +602,8 @@ private extension ArticlesTable {
|
|||
// * Must not be deleted.
|
||||
// * 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 > ?)));"
|
||||
return numberWithSQLAndParameters(sql, [webFeedID, articleCutoffDate, articleCutoffDate], in: database)
|
||||
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], in: database)
|
||||
}
|
||||
|
||||
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) {
|
||||
removeArticlesFromDatabaseArticlesCache(updatedArticles)
|
||||
saveUpdatedRelatedObjects(updatedArticles, fetchedArticles, database)
|
||||
|
||||
for updatedArticle in updatedArticles {
|
||||
|
@ -897,10 +868,12 @@ private extension ArticlesTable {
|
|||
updateRowsWithDictionary(changesDictionary, whereKey: DatabaseKey.articleID, matches: updatedArticle.articleID, database: database)
|
||||
}
|
||||
|
||||
func removeArticlesFromDatabaseArticlesCache(_ updatedArticles: Set<Article>) {
|
||||
let articleIDs = updatedArticles.articleIDs()
|
||||
for articleID in articleIDs {
|
||||
databaseArticlesCache[articleID] = nil
|
||||
func addArticlesToCache(_ articles: Set<Article>?) {
|
||||
guard let articles = articles else {
|
||||
return
|
||||
}
|
||||
for article in articles {
|
||||
articlesCache[article.articleID] = article
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -912,9 +885,6 @@ private extension ArticlesTable {
|
|||
if article.status.starred {
|
||||
return false
|
||||
}
|
||||
if let datePublished = article.datePublished {
|
||||
return datePublished < articleCutoffDate
|
||||
}
|
||||
return article.status.dateArrived < articleCutoffDate
|
||||
}
|
||||
|
||||
|
|
|
@ -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. Doesn’t 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 })
|
||||
}
|
||||
}
|
|
@ -13,8 +13,31 @@ import RSParser
|
|||
|
||||
extension Article {
|
||||
|
||||
init(databaseArticle: DatabaseArticle, accountID: String, authors: Set<Author>?) {
|
||||
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)
|
||||
init?(accountID: String, row: FMResultSet, status: ArticleStatus) {
|
||||
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) {
|
||||
|
@ -34,7 +57,7 @@ extension Article {
|
|||
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) {
|
||||
|
@ -42,7 +65,14 @@ extension Article {
|
|||
dictionary[key] = self[keyPath: comparisonKeyPath] ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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? {
|
||||
if self == existingArticle {
|
||||
return nil
|
||||
|
@ -60,7 +90,6 @@ extension Article {
|
|||
addPossibleStringChangeWithKeyPath(\Article.externalURL, existingArticle, DatabaseKey.externalURL, &d)
|
||||
addPossibleStringChangeWithKeyPath(\Article.summary, existingArticle, DatabaseKey.summary, &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.
|
||||
// This is data that’s good to have, and it’s likely that a feed removing dates is doing so in error.
|
||||
|
@ -120,9 +149,6 @@ extension Article: DatabaseObject {
|
|||
if let imageURL = imageURL {
|
||||
d[DatabaseKey.imageURL] = imageURL
|
||||
}
|
||||
if let bannerImageURL = bannerImageURL {
|
||||
d[DatabaseKey.bannerImageURL] = bannerImageURL
|
||||
}
|
||||
if let datePublished = datePublished {
|
||||
d[DatabaseKey.datePublished] = datePublished
|
||||
}
|
||||
|
|
|
@ -13,6 +13,9 @@ import RSDatabase
|
|||
public typealias SyncStatusesResult = Result<Array<SyncStatus>, DatabaseError>
|
||||
public typealias SyncStatusesCompletionBlock = (SyncStatusesResult) -> Void
|
||||
|
||||
public typealias SyncStatusArticleIDsResult = Result<Set<String>, DatabaseError>
|
||||
public typealias SyncStatusArticleIDsCompletionBlock = (SyncStatusArticleIDsResult) -> Void
|
||||
|
||||
public struct SyncDatabase {
|
||||
|
||||
private let syncStatusTable: SyncStatusTable
|
||||
|
@ -41,6 +44,14 @@ public struct SyncDatabase {
|
|||
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) {
|
||||
syncStatusTable.resetSelectedForProcessing(articleIDs, completion: completion)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
queue.runInTransaction { databaseResult in
|
||||
|
||||
|
@ -156,6 +164,38 @@ private extension SyncStatusTable {
|
|||
|
||||
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?) {
|
||||
|
|
|
@ -151,7 +151,6 @@ private extension ArticlePasteboardWriter {
|
|||
d[Key.externalURL] = article.externalURL ?? nil
|
||||
d[Key.summary] = article.summary ?? nil
|
||||
d[Key.imageURL] = article.imageURL ?? nil
|
||||
d[Key.bannerImageURL] = article.bannerImageURL ?? nil
|
||||
d[Key.datePublished] = article.datePublished ?? nil
|
||||
d[Key.dateModified] = article.dateModified ?? nil
|
||||
d[Key.dateArrived] = article.status.dateArrived
|
||||
|
|
|
@ -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 prototypeID = "prototype"
|
||||
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 height = TimelineCellLayout.height(for: 100, cellData: prototypeCellData, appearance: cellAppearance)
|
||||
|
|
|
@ -110,7 +110,7 @@
|
|||
51707439232AA97100A461A3 /* ShareFolderPickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51707438232AA97100A461A3 /* ShareFolderPickerController.swift */; };
|
||||
517630042336215100E15FFF /* 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 */; };
|
||||
5183CCDA226E31A50010922C /* NonIntrinsicImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5183CCD9226E31A50010922C /* NonIntrinsicImageView.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 */; };
|
||||
51A9A5F52380F6A60033AADF /* ModalNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A9A5F42380F6A60033AADF /* ModalNavigationController.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 */; };
|
||||
51BB7C272335A8E5008E8144 /* ArticleActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1320,6 +1321,7 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1950,7 +1952,8 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
51C4527E2265092C00C03939 /* ArticleViewController.swift */,
|
||||
517630222336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift */,
|
||||
51AB8AB223B7F4C6008F147D /* WebViewController.swift */,
|
||||
517630222336657E00E15FFF /* WebViewProvider.swift */,
|
||||
51102164233A7D6C0007A5F7 /* ArticleExtractorButton.swift */,
|
||||
51C266E9238C334800F53014 /* ContextMenuPreviewViewController.swift */,
|
||||
5142192923522B5500E07E2C /* ImageViewController.swift */,
|
||||
|
@ -3835,7 +3838,7 @@
|
|||
511B9807237DCAC90028BCAA /* UserInfoKey.swift in Sources */,
|
||||
51C45269226508F600C03939 /* MasterFeedTableViewCell.swift in Sources */,
|
||||
51F85BFD2275DCA800C787DC /* SingleLineUILabelSizer.swift in Sources */,
|
||||
517630232336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift in Sources */,
|
||||
517630232336657E00E15FFF /* WebViewProvider.swift in Sources */,
|
||||
51E43962238037C400015C31 /* AddWebFeedFolderViewController.swift in Sources */,
|
||||
51C4528F226509BD00C03939 /* UnreadFeed.swift in Sources */,
|
||||
51FD413B2342BD0500880194 /* MasterTimelineUnreadCountView.swift in Sources */,
|
||||
|
@ -3843,6 +3846,7 @@
|
|||
51D87EE12311D34700E63F03 /* ActivityType.swift in Sources */,
|
||||
51C452772265091600C03939 /* MultilineUILabelSizer.swift in Sources */,
|
||||
51C452A522650A2D00C03939 /* SmallIconProvider.swift in Sources */,
|
||||
51AB8AB323B7F4C6008F147D /* WebViewController.swift in Sources */,
|
||||
516A09392360A2AE00EAE89B /* SettingsAccountTableViewCell.swift in Sources */,
|
||||
3B3A32A5238B820900314204 /* FeedWranglerAccountViewController.swift in Sources */,
|
||||
51D5948722668EFA00DFC836 /* MarkStatusCommand.swift in Sources */,
|
||||
|
|
|
@ -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() {
|
||||
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
|
||||
|
@ -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() {
|
||||
var image = document.getElementById("nnwImageIcon");
|
||||
image.src = "nnwImageIcon://";
|
||||
|
@ -37,8 +91,10 @@ function render(data, scrollY) {
|
|||
window.scrollTo(0, scrollY);
|
||||
|
||||
wrapFrames()
|
||||
wrapTables()
|
||||
stripStyles()
|
||||
convertImgSrc()
|
||||
|
||||
flattenPreElements()
|
||||
|
||||
postRenderProcessing()
|
||||
}
|
||||
|
|
|
@ -20,7 +20,22 @@ class AddFolderViewController: UITableViewController, AddContainerViewController
|
|||
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?
|
||||
|
||||
|
@ -32,13 +47,11 @@ class AddFolderViewController: UITableViewController, AddContainerViewController
|
|||
|
||||
nameTextField.delegate = self
|
||||
|
||||
accountLabel.text = (accounts[0] as DisplayNameProvider).nameForDisplay
|
||||
|
||||
if shouldDisplayPicker {
|
||||
accountPickerView.dataSource = 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)
|
||||
}
|
||||
|
||||
|
@ -52,21 +65,26 @@ class AddFolderViewController: UITableViewController, AddContainerViewController
|
|||
NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: nameTextField)
|
||||
|
||||
}
|
||||
|
||||
private func didSelect(_ account: Account) {
|
||||
AppDefaults.addFolderAccountID = account.accountID
|
||||
selectedAccount = account
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
delegate?.processingDidEnd()
|
||||
}
|
||||
|
||||
func add() {
|
||||
let account = accounts[accountPickerView.selectedRow(inComponent: 0)]
|
||||
if let folderName = nameTextField.text {
|
||||
account.addFolder(folderName) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.delegate?.processingDidEnd()
|
||||
case .failure(let error):
|
||||
self.presentError(error)
|
||||
}
|
||||
guard let folderName = nameTextField.text else {
|
||||
return
|
||||
}
|
||||
selectedAccount.addFolder(folderName) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.delegate?.processingDidEnd()
|
||||
case .failure(let error):
|
||||
self.presentError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -100,8 +118,7 @@ extension AddFolderViewController: UIPickerViewDataSource, UIPickerViewDelegate
|
|||
}
|
||||
|
||||
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
|
||||
accountLabel.text = (accounts[row] as DisplayNameProvider).nameForDisplay
|
||||
AppDefaults.addFolderAccountID = accounts[row].accountID
|
||||
didSelect(accounts[row])
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -117,11 +117,11 @@ struct AppAssets {
|
|||
return UIImage(systemName: "asterisk.circle")!
|
||||
}()
|
||||
|
||||
static var markOlderAsReadDownImage: UIImage = {
|
||||
static var markBelowAsReadImage: UIImage = {
|
||||
return UIImage(systemName: "arrowtriangle.down.circle")!
|
||||
}()
|
||||
|
||||
static var markOlderAsReadUpImage: UIImage = {
|
||||
static var markAboveAsReadImage: UIImage = {
|
||||
return UIImage(systemName: "arrowtriangle.up.circle")!
|
||||
}()
|
||||
|
||||
|
|
|
@ -59,7 +59,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
appDelegate = self
|
||||
|
||||
// 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()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
|
||||
|
|
|
@ -12,32 +12,20 @@ import Account
|
|||
import Articles
|
||||
import SafariServices
|
||||
|
||||
enum ArticleViewState: Equatable {
|
||||
case noSelection
|
||||
case multipleSelection
|
||||
case loading
|
||||
case article(Article)
|
||||
case extracted(Article, ExtractedArticle)
|
||||
}
|
||||
|
||||
class ArticleViewController: UIViewController {
|
||||
|
||||
private struct MessageName {
|
||||
static let imageWasClicked = "imageWasClicked"
|
||||
static let imageWasShown = "imageWasShown"
|
||||
}
|
||||
|
||||
@IBOutlet private weak var nextUnreadBarButtonItem: UIBarButtonItem!
|
||||
@IBOutlet private weak var prevArticleBarButtonItem: UIBarButtonItem!
|
||||
@IBOutlet private weak var nextArticleBarButtonItem: UIBarButtonItem!
|
||||
@IBOutlet private weak var readBarButtonItem: UIBarButtonItem!
|
||||
@IBOutlet private weak var starBarButtonItem: UIBarButtonItem!
|
||||
@IBOutlet private weak var actionBarButtonItem: UIBarButtonItem!
|
||||
@IBOutlet private weak var webViewContainer: UIView!
|
||||
@IBOutlet private weak var showNavigationView: UIView!
|
||||
@IBOutlet private weak var showToolbarView: UIView!
|
||||
@IBOutlet private weak var showNavigationViewConstraint: NSLayoutConstraint!
|
||||
@IBOutlet private weak var showToolbarViewConstraint: NSLayoutConstraint!
|
||||
|
||||
private var pageViewController: UIPageViewController!
|
||||
|
||||
private var currentWebViewController: WebViewController? {
|
||||
return pageViewController?.viewControllers?.first as? WebViewController
|
||||
}
|
||||
|
||||
private var articleExtractorButton: ArticleExtractorButton = {
|
||||
let button = ArticleExtractorButton(type: .system)
|
||||
|
@ -46,44 +34,23 @@ class ArticleViewController: UIViewController {
|
|||
return button
|
||||
}()
|
||||
|
||||
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)?
|
||||
|
||||
weak var coordinator: SceneCoordinator!
|
||||
|
||||
var state: ArticleViewState = .noSelection {
|
||||
var article: Article? {
|
||||
didSet {
|
||||
if state != oldValue {
|
||||
updateUI()
|
||||
reloadHTML()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
updateUI()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -92,68 +59,47 @@ class ArticleViewController: UIViewController {
|
|||
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() {
|
||||
super.viewDidLoad()
|
||||
|
||||
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(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)
|
||||
|
||||
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)
|
||||
toolbarItems?.insert(UIBarButtonItem(customView: articleExtractorButton), at: 6)
|
||||
|
||||
showNavigationView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
|
||||
showToolbarView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
|
||||
pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [:])
|
||||
pageViewController.delegate = self
|
||||
pageViewController.dataSource = self
|
||||
|
||||
ArticleViewControllerWebViewProvider.shared.dequeueWebView() { webView in
|
||||
|
||||
self.webView = webView
|
||||
self.webViewContainer.addChildAndPin(webView)
|
||||
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.webViewContainer.addSubview(webView)
|
||||
NSLayoutConstraint.activate([
|
||||
self.webViewContainer.leadingAnchor.constraint(equalTo: webView.leadingAnchor),
|
||||
self.webViewContainer.trailingAnchor.constraint(equalTo: webView.trailingAnchor),
|
||||
self.webViewContainer.topAnchor.constraint(equalTo: webView.topAnchor),
|
||||
self.webViewContainer.bottomAnchor.constraint(equalTo: webView.bottomAnchor)
|
||||
])
|
||||
|
||||
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())
|
||||
|
||||
}
|
||||
view.addSubview(pageViewController.view)
|
||||
addChild(pageViewController!)
|
||||
NSLayoutConstraint.activate([
|
||||
view.leadingAnchor.constraint(equalTo: pageViewController.view.leadingAnchor),
|
||||
view.trailingAnchor.constraint(equalTo: pageViewController.view.trailingAnchor),
|
||||
view.topAnchor.constraint(equalTo: pageViewController.view.topAnchor),
|
||||
view.bottomAnchor.constraint(equalTo: pageViewController.view.bottomAnchor)
|
||||
])
|
||||
|
||||
let controller = createWebViewController(article)
|
||||
pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
|
||||
|
||||
updateUI()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
if AppDefaults.articleFullscreenEnabled {
|
||||
hideBars()
|
||||
currentWebViewController?.hideBars()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -169,7 +115,7 @@ class ArticleViewController: UIViewController {
|
|||
|
||||
func updateUI() {
|
||||
|
||||
guard let article = currentArticle else {
|
||||
guard let article = article else {
|
||||
articleExtractorButton.isEnabled = false
|
||||
nextUnreadBarButtonItem.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
|
||||
|
||||
@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 {
|
||||
return
|
||||
}
|
||||
guard let currentArticle = currentArticle else {
|
||||
guard let article = article else {
|
||||
return
|
||||
}
|
||||
if articleIDs.contains(currentArticle.articleID) {
|
||||
if articleIDs.contains(article.articleID) {
|
||||
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) {
|
||||
// The toolbar will come back on you if you don't hide it again
|
||||
if AppDefaults.articleFullscreenEnabled {
|
||||
hideBars()
|
||||
currentWebViewController?.hideBars()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
@objc func didTapNavigationBar() {
|
||||
currentWebViewController?.hideBars()
|
||||
}
|
||||
|
||||
@objc func showBars(_ sender: Any) {
|
||||
showBars()
|
||||
currentWebViewController?.showBars()
|
||||
}
|
||||
|
||||
@IBAction func toggleArticleExtractor(_ sender: Any) {
|
||||
coordinator.toggleArticleExtractor()
|
||||
currentWebViewController?.toggleArticleExtractor()
|
||||
}
|
||||
|
||||
@IBAction func nextUnread(_ sender: Any) {
|
||||
|
@ -304,7 +203,7 @@ class ArticleViewController: UIViewController {
|
|||
}
|
||||
|
||||
@IBAction func showActivityDialog(_ sender: Any) {
|
||||
showActivityDialog()
|
||||
currentWebViewController?.showActivityDialog(popOverBarButtonItem: actionBarButtonItem)
|
||||
}
|
||||
|
||||
// MARK: Keyboard Shortcuts
|
||||
|
@ -315,339 +214,81 @@ class ArticleViewController: UIViewController {
|
|||
// MARK: API
|
||||
|
||||
func focus() {
|
||||
webView.becomeFirstResponder()
|
||||
currentWebViewController?.focus()
|
||||
}
|
||||
|
||||
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 {
|
||||
return webView.scrollView.contentOffset.y < finalScrollPosition()
|
||||
return currentWebViewController?.canScrollDown() ?? false
|
||||
}
|
||||
|
||||
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();")
|
||||
currentWebViewController?.scrollPageDown()
|
||||
}
|
||||
|
||||
func fullReload() {
|
||||
if let offset = webView?.scrollView.contentOffset.y {
|
||||
restoreOffset = Int(offset)
|
||||
webView?.reload()
|
||||
currentWebViewController?.fullReload()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func didTapNavigationBar() {
|
||||
hideBars()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UIContextMenuInteractionDelegate
|
||||
|
||||
extension ArticleViewController: UIContextMenuInteractionDelegate {
|
||||
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
||||
extension ArticleViewController: UIPageViewControllerDataSource {
|
||||
|
||||
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 ArticleViewController: 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 pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
|
||||
guard let article = coordinator.prevArticle else {
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
self.updateUI()
|
||||
self.reloadHTML()
|
||||
return createWebViewController(article)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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
|
||||
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
|
||||
guard let article = coordinator.nextArticle else {
|
||||
return nil
|
||||
}
|
||||
return createWebViewController(article)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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
|
||||
// MARK: UIPageViewControllerDelegate
|
||||
|
||||
extension ArticleViewController: UIPageViewControllerDelegate {
|
||||
|
||||
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||
guard finished, completed else { return }
|
||||
guard let article = currentWebViewController?.article else { return }
|
||||
coordinator.selectArticle(article)
|
||||
articleExtractorButton.buttonState = currentWebViewController?.articleExtractorButtonState ?? .off
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
private extension ArticleViewController {
|
||||
|
||||
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, 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()
|
||||
}
|
||||
func createWebViewController(_ article: Article?) -> WebViewController {
|
||||
let controller = WebViewController()
|
||||
controller.coordinator = coordinator
|
||||
controller.delegate = self
|
||||
controller.article = article
|
||||
return controller
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -10,15 +10,15 @@ import UIKit
|
|||
|
||||
class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
|
||||
private weak var articleController: ArticleViewController?
|
||||
private weak var webViewController: WebViewController?
|
||||
private let duration = 0.4
|
||||
var presenting = true
|
||||
var originFrame: CGRect!
|
||||
var maskFrame: CGRect!
|
||||
var originImage: UIImage!
|
||||
|
||||
init(controller: ArticleViewController) {
|
||||
self.articleController = controller
|
||||
init(controller: WebViewController) {
|
||||
self.webViewController = controller
|
||||
}
|
||||
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
|
@ -44,7 +44,7 @@ class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning {
|
|||
transitionContext.containerView.backgroundColor = AppAssets.fullScreenBackgroundColor
|
||||
transitionContext.containerView.addSubview(imageView)
|
||||
|
||||
articleController?.hideClickedImage()
|
||||
webViewController?.hideClickedImage()
|
||||
|
||||
UIView.animate(
|
||||
withDuration: duration,
|
||||
|
@ -93,7 +93,7 @@ class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning {
|
|||
animations: {
|
||||
imageView.frame = self.originFrame
|
||||
}, completion: { _ in
|
||||
self.articleController?.showClickedImage() {
|
||||
self.webViewController?.showClickedImage() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
imageView.removeFromSuperview()
|
||||
transitionContext.completeTransition(true)
|
||||
|
|
|
@ -12,8 +12,10 @@ class ImageViewController: UIViewController {
|
|||
|
||||
@IBOutlet weak var shareButton: UIButton!
|
||||
@IBOutlet weak var imageScrollView: ImageScrollView!
|
||||
@IBOutlet weak var titleLabel: UILabel!
|
||||
|
||||
var image: UIImage!
|
||||
var imageTitle: String?
|
||||
var zoomedFrame: CGRect {
|
||||
return imageScrollView.zoomedFrame
|
||||
}
|
||||
|
@ -26,6 +28,8 @@ class ImageViewController: UIViewController {
|
|||
imageScrollView.imageContentMode = .aspectFit
|
||||
imageScrollView.initialOffset = .center
|
||||
imageScrollView.display(image: image)
|
||||
|
||||
titleLabel.text = imageTitle ?? ""
|
||||
}
|
||||
|
||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// ArticleViewControllerWebViewProvider.swift
|
||||
// WebViewProvider.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// 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.
|
||||
/// 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()
|
||||
|
|
@ -23,7 +23,18 @@ class ArticleActivityItemSource: NSObject, UIActivityItemSource {
|
|||
}
|
||||
|
||||
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
||||
return url
|
||||
guard let activityType = activityType,
|
||||
let subject = subject else {
|
||||
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 {
|
||||
|
|
|
@ -15,39 +15,7 @@
|
|||
<view key="view" contentMode="scaleToFill" id="svH-Pt-448">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<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"/>
|
||||
</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"/>
|
||||
</view>
|
||||
<toolbarItems>
|
||||
|
@ -75,7 +43,7 @@
|
|||
<userDefinedRuntimeAttribute type="string" keyPath="accLabelText" value="Next Unread"/>
|
||||
</userDefinedRuntimeAttributes>
|
||||
<connections>
|
||||
<action selector="nextUnread:" destination="JEX-9P-axG" id="USD-hC-C6z"/>
|
||||
<action selector="nextUnread:" destination="JEX-9P-axG" id="nI3-pz-tc8"/>
|
||||
</connections>
|
||||
</barButtonItem>
|
||||
<barButtonItem style="plain" systemItem="flexibleSpace" id="vAq-iW-Yyo"/>
|
||||
|
@ -117,15 +85,10 @@
|
|||
<connections>
|
||||
<outlet property="actionBarButtonItem" destination="9Ut-5B-JKP" id="9bO-kz-cTz"/>
|
||||
<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="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="webViewContainer" destination="DNb-lt-KzC" id="Fc1-Ae-pWK"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="FJe-Yq-33r" sceneMemberID="firstResponder"/>
|
||||
|
@ -156,13 +119,20 @@
|
|||
</connections>
|
||||
</tableView>
|
||||
<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>
|
||||
<action selector="markAllAsRead:" destination="Kyk-vK-QRX" id="4nd-Gg-APm"/>
|
||||
</connections>
|
||||
</barButtonItem>
|
||||
<barButtonItem style="plain" systemItem="flexibleSpace" id="53V-wq-bat"/>
|
||||
<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>
|
||||
<action selector="firstUnread:" destination="Kyk-vK-QRX" id="d5y-x5-Qht"/>
|
||||
</connections>
|
||||
|
@ -251,6 +221,12 @@
|
|||
<viewLayoutGuide key="contentLayoutGuide" id="phv-DN-krZ"/>
|
||||
<viewLayoutGuide key="frameLayoutGuide" id="NNU-C8-Fsz"/>
|
||||
</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">
|
||||
<rect key="frame" x="362" y="44" width="44" height="44"/>
|
||||
<constraints>
|
||||
|
@ -280,9 +256,12 @@
|
|||
<constraints>
|
||||
<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 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 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="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="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"/>
|
||||
|
@ -292,6 +271,7 @@
|
|||
<connections>
|
||||
<outlet property="imageScrollView" destination="msG-pz-EKk" id="dGi-M6-dcO"/>
|
||||
<outlet property="shareButton" destination="RmY-a3-hUg" id="Z54-ah-WAI"/>
|
||||
<outlet property="titleLabel" destination="eMj-1g-3xm" id="6wF-IZ-fNw"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="ZPN-tH-JAG" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
|
@ -387,6 +367,7 @@
|
|||
</scene>
|
||||
</scenes>
|
||||
<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.circle" catalog="system" width="64" height="60"/>
|
||||
<image name="chevron.up" catalog="system" width="64" height="36"/>
|
||||
|
|
|
@ -168,8 +168,11 @@ private extension KeyboardManager {
|
|||
let toggleReadTitle = NSLocalizedString("Toggle Read Status", comment: "Toggle Read Status")
|
||||
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")
|
||||
keys.append(KeyboardManager.createKeyCommand(title: markOlderAsReadTitle, action: "markOlderArticlesAsRead:", input: "k", modifiers: [.command, .shift]))
|
||||
let markAboveAsReadTitle = NSLocalizedString("Mark Above as Read", comment: "Mark Above as Read")
|
||||
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")
|
||||
keys.append(KeyboardManager.createKeyCommand(title: toggleStarredTitle, action: "toggleStarred:", input: "l", modifiers: [.command, .shift]))
|
||||
|
|
|
@ -301,13 +301,17 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
if node.representedObject is WebFeed {
|
||||
return makeFeedContextMenu(node: node, indexPath: indexPath, includeDeleteRename: true)
|
||||
} else {
|
||||
} else if node.representedObject is Folder {
|
||||
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
|
||||
guard let parent = node.parent else {
|
||||
guard let parent = node.parent, parent.representedObject is Folder else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
|
@ -618,7 +622,14 @@ extension MasterFeedViewController: UIContextMenuInteractionDelegate {
|
|||
return UIContextMenuConfiguration(identifier: sectionIndex as NSCopying, previewProvider: nil) { suggestedActions in
|
||||
let accountInfoAction = self.getAccountInfoAction(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
|
||||
|
||||
let spaceItemButton1 = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
|
||||
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(_:)))
|
||||
setToolbarItems([refreshProgressItemButton, spaceItemButton, addNewItemButton], animated: false)
|
||||
|
||||
setToolbarItems([spaceItemButton1,
|
||||
refreshProgressItemButton,
|
||||
spaceItemButton2,
|
||||
addNewItemButton
|
||||
], animated: false)
|
||||
}
|
||||
|
||||
func updateUI() {
|
||||
|
@ -865,10 +882,14 @@ private extension MasterFeedViewController {
|
|||
if let copyHomePageAction = self.copyHomePageAction(indexPath: indexPath) {
|
||||
actions.append(copyHomePageAction)
|
||||
}
|
||||
|
||||
if let markAllAction = self.markAllAsReadAction(indexPath: indexPath) {
|
||||
actions.append(markAllAction)
|
||||
}
|
||||
|
||||
if includeDeleteRename {
|
||||
actions.append(self.deleteAction(indexPath: indexPath))
|
||||
actions.append(self.renameAction(indexPath: indexPath))
|
||||
actions.append(self.deleteAction(indexPath: indexPath))
|
||||
}
|
||||
|
||||
return UIMenu(title: "", children: actions)
|
||||
|
@ -885,12 +906,26 @@ private extension MasterFeedViewController {
|
|||
var actions = [UIAction]()
|
||||
actions.append(self.deleteAction(indexPath: indexPath))
|
||||
actions.append(self.renameAction(indexPath: indexPath))
|
||||
|
||||
if let markAllAction = self.markAllAsReadAction(indexPath: indexPath) {
|
||||
actions.append(markAllAction)
|
||||
}
|
||||
|
||||
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? {
|
||||
guard coordinator.homePageURLForFeed(indexPath) != nil else {
|
||||
return nil
|
||||
|
@ -1033,6 +1068,45 @@ private extension MasterFeedViewController {
|
|||
}
|
||||
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) {
|
||||
|
||||
|
|
|
@ -14,33 +14,40 @@ class RefreshProgressView: UIView {
|
|||
@IBOutlet weak var progressView: UIProgressView!
|
||||
@IBOutlet weak var label: UILabel!
|
||||
private lazy var progressWidth = progressView.widthAnchor.constraint(equalToConstant: 100.0)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
commonInit()
|
||||
}
|
||||
private var lastLabelDisplayedTime: Date? = nil
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
func commonInit() {
|
||||
override func awakeFromNib() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(progressDidChange(_:)), name: .AccountRefreshProgressDidChange, object: nil)
|
||||
|
||||
if !AccountManager.shared.combinedRefreshProgress.isComplete {
|
||||
progressChanged()
|
||||
} else {
|
||||
updateRefreshLabel()
|
||||
}
|
||||
}
|
||||
|
||||
func updateRefreshLabel() {
|
||||
if let accountLastArticleFetchEndTime = AccountManager.shared.lastArticleFetchEndTime {
|
||||
|
||||
if let lastLabelDisplayedTime = lastLabelDisplayedTime, lastLabelDisplayedTime.addingTimeInterval(2) > Date() {
|
||||
return
|
||||
}
|
||||
|
||||
lastLabelDisplayedTime = Date()
|
||||
|
||||
if Date() > accountLastArticleFetchEndTime.addingTimeInterval(1) {
|
||||
|
||||
let relativeDateTimeFormatter = RelativeDateTimeFormatter()
|
||||
relativeDateTimeFormatter.dateTimeStyle = .named
|
||||
let refreshed = relativeDateTimeFormatter.localizedString(for: accountLastArticleFetchEndTime, relativeTo: Date())
|
||||
let localizedRefreshText = NSLocalizedString("Updated %@", comment: "Updated")
|
||||
let refreshText = NSString.localizedStringWithFormat(localizedRefreshText as NSString, refreshed) as String
|
||||
label.text = refreshText
|
||||
|
||||
} else {
|
||||
label.text = NSLocalizedString("Updated just now", comment: "Updated Just Now")
|
||||
}
|
||||
|
||||
} else {
|
||||
label.text = ""
|
||||
}
|
||||
|
@ -48,7 +55,20 @@ class RefreshProgressView: UIView {
|
|||
}
|
||||
|
||||
@objc func progressDidChange(_ note: Notification) {
|
||||
|
||||
progressChanged()
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private extension RefreshProgressView {
|
||||
|
||||
func progressChanged() {
|
||||
let progress = AccountManager.shared.combinedRefreshProgress
|
||||
|
||||
if progress.isComplete {
|
||||
|
@ -60,18 +80,12 @@ class RefreshProgressView: UIView {
|
|||
self.progressWidth.isActive = false
|
||||
}
|
||||
} else {
|
||||
lastLabelDisplayedTime = nil
|
||||
label.isHidden = true
|
||||
progressView.isHidden = false
|
||||
self.progressWidth.isActive = true
|
||||
let percent = Float(progress.numberCompleted) / Float(progress.numberOfTasks)
|
||||
progressView.progress = percent
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,8 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
|
|||
private var iconSize = IconSize.medium
|
||||
private lazy var feedTapGestureRecognizer = UITapGestureRecognizer(target: self, action:#selector(showFeedInspector(_:)))
|
||||
|
||||
private var refreshProgressView: RefreshProgressView?
|
||||
|
||||
@IBOutlet weak var filterButton: UIBarButtonItem!
|
||||
@IBOutlet weak var markAllAsReadButton: UIBarButtonItem!
|
||||
@IBOutlet weak var firstUnreadButton: UIBarButtonItem!
|
||||
|
@ -72,13 +74,18 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
|
|||
if let titleView = Bundle.main.loadNibNamed("MasterTimelineTitleView", owner: self, options: nil)?[0] as? MasterTimelineTitleView {
|
||||
navigationItem.titleView = titleView
|
||||
}
|
||||
|
||||
resetUI(resetScroll: true)
|
||||
applyChanges(animated: false)
|
||||
|
||||
// Restore the scroll position if we have one stored
|
||||
if let restoreIndexPath = coordinator.timelineMiddleIndexPath {
|
||||
tableView.scrollToRow(at: restoreIndexPath, at: .middle, animated: false)
|
||||
refreshControl = UIRefreshControl()
|
||||
refreshControl!.addTarget(self, action: #selector(refreshAccounts(_:)), for: .valueChanged)
|
||||
|
||||
configureToolbar()
|
||||
resetUI(resetScroll: true)
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
@objc func selectNextUp(_ sender: Any?) {
|
||||
|
@ -242,8 +258,14 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
|
|||
popoverController.sourceView = view
|
||||
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) {
|
||||
alert.addAction(action)
|
||||
|
@ -290,7 +312,14 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
|
|||
var actions = [UIAction]()
|
||||
actions.append(self.toggleArticleReadStatusAction(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) {
|
||||
actions.append(action)
|
||||
|
@ -450,7 +479,7 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
|
|||
|
||||
let prototypeID = "prototype"
|
||||
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)
|
||||
|
||||
|
@ -502,6 +531,22 @@ extension MasterTimelineViewController: UISearchBarDelegate {
|
|||
|
||||
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) {
|
||||
|
||||
title = coordinator.timelineFeed?.nameForDisplay ?? "Timeline"
|
||||
|
@ -544,6 +589,7 @@ private extension MasterTimelineViewController {
|
|||
}
|
||||
|
||||
func updateUI() {
|
||||
refreshProgressView?.updateRefreshLabel()
|
||||
updateTitleUnreadCount()
|
||||
updateToolbar()
|
||||
}
|
||||
|
@ -633,20 +679,54 @@ private extension MasterTimelineViewController {
|
|||
|
||||
return action
|
||||
}
|
||||
|
||||
func markOlderAsReadAction(_ article: Article) -> UIAction {
|
||||
let title = NSLocalizedString("Mark Older as Read", comment: "Mark Older as Read")
|
||||
let image = coordinator.sortDirection == .orderedDescending ? AppAssets.markOlderAsReadDownImage : AppAssets.markOlderAsReadUpImage
|
||||
|
||||
func markAboveAsReadAction(_ article: Article) -> UIAction? {
|
||||
guard coordinator.canMarkAboveAsRead(for: article) else {
|
||||
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
|
||||
self?.coordinator.markAsReadOlderArticlesInTimeline(article)
|
||||
self?.coordinator.markAboveAsRead(article)
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
func markOlderAsReadAlertAction(_ article: Article, completion: @escaping (Bool) -> Void) -> UIAlertAction {
|
||||
let title = NSLocalizedString("Mark Older as Read", comment: "Mark Older as Read")
|
||||
func markBelowAsReadAction(_ article: Article) -> UIAction? {
|
||||
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
|
||||
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)
|
||||
}
|
||||
return action
|
||||
|
|
|
@ -37,7 +37,8 @@ async function imageWasClicked(img) {
|
|||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
height: rect.height,
|
||||
imageTitle: img.title
|
||||
};
|
||||
message.imageURL = reader.result;
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
|
|
@ -8,6 +8,7 @@ body {
|
|||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
-webkit-hyphens: auto;
|
||||
-webkit-text-size-adjust: none;
|
||||
}
|
||||
|
||||
a {
|
||||
|
@ -130,6 +131,9 @@ pre {
|
|||
word-break: normal;
|
||||
-webkit-hyphens: none;
|
||||
}
|
||||
.nnw-overflow {
|
||||
overflow-x: auto;
|
||||
}
|
||||
code, pre {
|
||||
font-family: "SF Mono", Menlo, "Courier New", Courier, monospace;
|
||||
font-size: 14px;
|
||||
|
@ -185,6 +189,13 @@ sub {
|
|||
width: 100% !important;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
blockquote {
|
||||
margin-inline-start: 25px;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/*Block ads and junk*/
|
||||
|
||||
iframe[src*="feedads"],
|
||||
|
@ -220,12 +231,6 @@ img[src*="share-buttons"] {
|
|||
display: none !important;
|
||||
}
|
||||
|
||||
/* Site specific styles */
|
||||
.wp-smiley {
|
||||
height: 1em;
|
||||
max-height: 1em;
|
||||
}
|
||||
|
||||
/* Newsfoot specific styles. Structural styles come first, theme styles second */
|
||||
.newsfoot-footnote-container {
|
||||
position: relative;
|
||||
|
|
|
@ -53,9 +53,13 @@ class RootSplitViewController: UISplitViewController {
|
|||
coordinator.markAllAsReadInTimeline()
|
||||
coordinator.selectNextUnread()
|
||||
}
|
||||
|
||||
@objc func markAboveAsRead(_ sender: Any?) {
|
||||
coordinator.markAboveAsRead()
|
||||
}
|
||||
|
||||
@objc func markOlderArticlesAsRead(_ sender: Any?) {
|
||||
coordinator.markAsReadOlderArticlesInTimeline()
|
||||
@objc func markBelowAsRead(_ sender: Any?) {
|
||||
coordinator.markBelowAsRead()
|
||||
}
|
||||
|
||||
@objc func markUnread(_ sender: Any?) {
|
||||
|
|
|
@ -34,9 +34,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||
|
||||
private var activityManager = ActivityManager()
|
||||
|
||||
private var isShowingExtractedArticle = false
|
||||
private var articleExtractor: ArticleExtractor? = nil
|
||||
|
||||
private var rootSplitViewController: RootSplitViewController!
|
||||
private var masterNavigationController: UINavigationController!
|
||||
private var masterFeedViewController: MasterFeedViewController!
|
||||
|
@ -723,7 +720,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||
func selectArticle(_ article: Article?, animated: Bool = false) {
|
||||
guard article != currentArticle else { return }
|
||||
|
||||
stopArticleExtractor()
|
||||
currentArticle = article
|
||||
activityManager.reading(feed: timelineFeed, article: article)
|
||||
|
||||
|
@ -733,7 +729,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||
masterNavigationController.popViewController(animated: animated)
|
||||
}
|
||||
} else {
|
||||
articleViewController?.state = .noSelection
|
||||
articleViewController?.article = nil
|
||||
}
|
||||
masterTimelineViewController?.updateArticleSelection(animated: animated)
|
||||
return
|
||||
|
@ -747,13 +743,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||
}
|
||||
|
||||
masterTimelineViewController?.updateArticleSelection(animated: animated)
|
||||
|
||||
if article!.webFeed?.isArticleExtractorAlwaysOn ?? false {
|
||||
startArticleExtractorForCurrentLink()
|
||||
currentArticleViewController.state = .loading
|
||||
} else {
|
||||
currentArticleViewController.state = .article(article!)
|
||||
}
|
||||
currentArticleViewController.article = article
|
||||
|
||||
markArticles(Set([article!]), statusKey: .read, flag: true)
|
||||
|
||||
|
@ -880,19 +870,53 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||
markAllAsRead(articles)
|
||||
masterNavigationController.popViewController(animated: true)
|
||||
}
|
||||
|
||||
func markAsReadOlderArticlesInTimeline() {
|
||||
if let article = currentArticle {
|
||||
markAsReadOlderArticlesInTimeline(article)
|
||||
}
|
||||
|
||||
func canMarkAboveAsRead(for article: Article) -> Bool {
|
||||
return articles.first != article
|
||||
}
|
||||
|
||||
func markAsReadOlderArticlesInTimeline(_ article: Article) {
|
||||
let articlesToMark = articles.filter { $0.logicalDatePublished < article.logicalDatePublished }
|
||||
if articlesToMark.isEmpty {
|
||||
|
||||
func markAboveAsRead() {
|
||||
guard let currentArticle = currentArticle else {
|
||||
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() {
|
||||
|
@ -998,45 +1022,15 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||
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)
|
||||
imageVC.image = image
|
||||
imageVC.imageTitle = imageTitle
|
||||
imageVC.modalPresentationStyle = .currentContext
|
||||
imageVC.transitioningDelegate = transitioningDelegate
|
||||
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? {
|
||||
guard let node = nodeFor(indexPath),
|
||||
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
|
||||
// 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 {
|
||||
stopArticleExtractor()
|
||||
currentArticle = nil
|
||||
masterTimelineViewController?.updateArticleSelection(animated: animated)
|
||||
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
|
||||
|
||||
private extension SceneCoordinator {
|
||||
|
@ -1533,24 +1507,9 @@ private extension SceneCoordinator {
|
|||
|
||||
// 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() {
|
||||
if !articles.isEmpty {
|
||||
timelineMiddleIndexPath = nil
|
||||
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
|
||||
// and need to update the article colors. An example is in dark mode. Split screen doesn't use true black
|
||||
// like darkmode usually does.
|
||||
|
||||
// TODO: This should probably only happen to recycled article controllers
|
||||
articleController.fullReload()
|
||||
return articleController
|
||||
|
||||
|
|
|
@ -380,6 +380,7 @@ private extension SettingsViewController {
|
|||
|
||||
func exportOPML(sourceView: UIView, sourceRect: CGRect) {
|
||||
if AccountManager.shared.accounts.count == 1 {
|
||||
opmlAccount = AccountManager.shared.accounts.first!
|
||||
exportOPMLDocumentPicker()
|
||||
} else {
|
||||
exportOPMLAccountPicker(sourceView: sourceView, sourceRect: sourceRect)
|
||||
|
|
|
@ -67,7 +67,7 @@ private extension TimelinePreviewTableViewController {
|
|||
|
||||
let prototypeID = "prototype"
|
||||
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))
|
||||
|
||||
|
|
|
@ -100,6 +100,9 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont
|
|||
account = containerAccount
|
||||
} else if let containerFolder = container as? Folder, let containerAccount = containerFolder.account {
|
||||
account = containerAccount
|
||||
} else {
|
||||
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
|
||||
return
|
||||
}
|
||||
|
||||
if let urlString = url?.absoluteString, account!.hasWebFeed(withURL: urlString) {
|
||||
|
|
|
@ -8,10 +8,6 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
protocol InteractiveNavigationControllerTappable {
|
||||
func didTapNavigationBar()
|
||||
}
|
||||
|
||||
class InteractiveNavigationController: UINavigationController {
|
||||
|
||||
private let poppableDelegate = PoppableGestureRecognizerDelegate()
|
||||
|
@ -33,8 +29,6 @@ class InteractiveNavigationController: UINavigationController {
|
|||
poppableDelegate.originalDelegate = interactivePopGestureRecognizer?.delegate
|
||||
poppableDelegate.navigationController = self
|
||||
interactivePopGestureRecognizer?.delegate = poppableDelegate
|
||||
|
||||
navigationBar.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapNavigationBar)))
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
|
@ -44,13 +38,7 @@ class InteractiveNavigationController: UINavigationController {
|
|||
}
|
||||
}
|
||||
|
||||
@objc func didTapNavigationBar() {
|
||||
if let tappable = topViewController as? InteractiveNavigationControllerTappable {
|
||||
tappable.didTapNavigationBar()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 16a33dad14992190f354006205387b8ea18058a1
|
||||
Subproject commit a23d10cbd7adce19841300ad9e5d57c54ea150f6
|
|
@ -1 +1 @@
|
|||
Subproject commit 2ebad90d4e9c586e9daadf98b025058541963566
|
||||
Subproject commit d86cafc5d8593c28bf5a1454af07fc8fac82c297
|
|
@ -1,7 +1,7 @@
|
|||
|
||||
// High Level Settings common to both the iOS application and any extensions we bundle with it
|
||||
MARKETING_VERSION = 5.0
|
||||
CURRENT_PROJECT_VERSION = 23
|
||||
CURRENT_PROJECT_VERSION = 24
|
||||
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
|
||||
|
|
Loading…
Reference in New Issue