diff --git a/Frameworks/Account/AccountMetadata.swift b/Frameworks/Account/AccountMetadata.swift index 526c29049..f4c1a2cdb 100644 --- a/Frameworks/Account/AccountMetadata.swift +++ b/Frameworks/Account/AccountMetadata.swift @@ -24,7 +24,6 @@ final class AccountMetadata: Codable { case lastArticleFetchEndTime case endpointURL case externalID - case lastCredentialRenewTime = "lastCredentialRenewTime" case performedApril2020RetentionPolicyChange } @@ -83,16 +82,6 @@ final class AccountMetadata: Codable { } } } - - /// The last moment an account successfully renewed its credentials, or `nil` if no such moment exists. - /// An account delegate can use this value to decide when to next ask the service provider to renew credentials. - var lastCredentialRenewTime: Date? { - didSet { - if lastCredentialRenewTime != oldValue { - valueDidChange(.lastCredentialRenewTime) - } - } - } var performedApril2020RetentionPolicyChange: Bool? { didSet { diff --git a/Frameworks/Account/Feedly/FeedlyAPICaller.swift b/Frameworks/Account/Feedly/FeedlyAPICaller.swift index f26c6d8e8..5ae2cbc2f 100644 --- a/Frameworks/Account/Feedly/FeedlyAPICaller.swift +++ b/Frameworks/Account/Feedly/FeedlyAPICaller.swift @@ -10,6 +10,12 @@ import Foundation import RSWeb import Secrets +protocol FeedlyAPICallerDelegate: class { + /// Implemented by the `FeedlyAccountDelegate` reauthorize the client with a fresh OAuth token so the client can retry the unauthorized request. + /// Pass `true` to the completion handler if the failing request should be retried with a fresh token or `false` if the unauthorized request should complete with the original failure error. + func reauthorizeFeedlyAPICaller(_ caller: FeedlyAPICaller, completionHandler: @escaping (Bool) -> ()) +} + final class FeedlyAPICaller { enum API { @@ -48,6 +54,8 @@ final class FeedlyAPICaller { self.baseUrlComponents = api.baseUrlComponents } + weak var delegate: FeedlyAPICallerDelegate? + var credentials: Credentials? var server: String? { @@ -70,6 +78,54 @@ final class FeedlyAPICaller { isSuspended = false } + func send(request: URLRequest, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void) { + transport.send(request: request, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding) { [weak self] result in + assert(Thread.isMainThread) + + switch result { + case .success: + completion(result) + case .failure(let error): + switch error { + case TransportError.httpError(let statusCode) where statusCode == 401: + + assert(self == nil ? true : self?.delegate != nil, "Check the delegate is set to \(FeedlyAccountDelegate.self).") + + guard let self = self, let delegate = self.delegate else { + completion(result) + return + } + + /// Capture the credentials before the reauthorization to check for a change. + let credentialsBefore = self.credentials + + delegate.reauthorizeFeedlyAPICaller(self) { [weak self] isReauthorizedAndShouldRetry in + assert(Thread.isMainThread) + + guard isReauthorizedAndShouldRetry, let self = self else { + completion(result) + return + } + + // Check for a change. Not only would it help debugging, but it'll also catch an infinitely recursive attempt to refresh. + guard let accessToken = self.credentials?.secret, accessToken != credentialsBefore?.secret else { + assertionFailure("Could not update the request with a new OAuth token. Did \(String(describing: self.delegate)) set them on \(self)?") + completion(result) + return + } + + var reauthorizedRequest = request + reauthorizedRequest.setValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) + + self.send(request: reauthorizedRequest, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding, completion: completion) + } + default: + completion(result) + } + } + } + } + func importOpml(_ opmlData: Data, completion: @escaping (Result) -> ()) { guard !isSuspended else { return DispatchQueue.main.async { @@ -96,7 +152,7 @@ final class FeedlyAPICaller { request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) request.httpBody = opmlData - transport.send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success(let (httpResponse, _)): if httpResponse.statusCode == 200 { @@ -148,7 +204,7 @@ final class FeedlyAPICaller { } } - transport.send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success(let (httpResponse, collections)): if httpResponse.statusCode == 200, let collection = collections?.first { @@ -201,7 +257,7 @@ final class FeedlyAPICaller { } } - transport.send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success(let (httpResponse, collections)): if httpResponse.statusCode == 200, let collection = collections?.first { @@ -249,7 +305,7 @@ final class FeedlyAPICaller { request.addValue("application/json", forHTTPHeaderField: "Accept-Type") request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) - transport.send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success(let (httpResponse, _)): if httpResponse.statusCode == 200 { @@ -282,14 +338,8 @@ final class FeedlyAPICaller { } } - guard let encodedFeedId = encodeForURLPath(feedId) else { - return DispatchQueue.main.async { - completion(.failure(FeedlyAccountDelegateError.unexpectedResourceId(feedId))) - } - } - var components = baseUrlComponents - components.percentEncodedPath = "/v3/collections/\(encodedCollectionId)/feeds/\(encodedFeedId)" + components.percentEncodedPath = "/v3/collections/\(encodedCollectionId)/feeds/.mdelete" guard let url = components.url else { fatalError("\(components) does not produce a valid URL.") @@ -301,7 +351,20 @@ final class FeedlyAPICaller { request.addValue("application/json", forHTTPHeaderField: "Accept-Type") request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) - transport.send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + do { + struct RemovableFeed: Encodable { + let id: String + } + let encoder = JSONEncoder() + let data = try encoder.encode([RemovableFeed(id: feedId)]) + request.httpBody = data + } catch { + return DispatchQueue.main.async { + completion(.failure(error)) + } + } + + send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success((let httpResponse, _)): if httpResponse.statusCode == 200 { @@ -363,7 +426,7 @@ extension FeedlyAPICaller: FeedlyAddFeedToCollectionService { } } - transport.send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success((_, let collectionFeeds)): if let feeds = collectionFeeds { @@ -429,7 +492,7 @@ extension FeedlyAPICaller: OAuthAuthorizationCodeGrantRequesting { return } - transport.send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in + send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success(let (_, tokenResponse)): if let response = tokenResponse { @@ -476,7 +539,7 @@ extension FeedlyAPICaller: OAuthAcessTokenRefreshRequesting { return } - transport.send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in + send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success(let (_, tokenResponse)): if let response = tokenResponse { @@ -517,7 +580,7 @@ extension FeedlyAPICaller: FeedlyGetCollectionsService { request.addValue("application/json", forHTTPHeaderField: "Accept-Type") request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) - transport.send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success(let (_, collections)): if let response = collections { @@ -585,7 +648,7 @@ extension FeedlyAPICaller: FeedlyGetStreamContentsService { request.addValue("application/json", forHTTPHeaderField: "Accept-Type") request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) - transport.send(request: request, resultType: FeedlyStream.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + send(request: request, resultType: FeedlyStream.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success(let (_, collections)): if let response = collections { @@ -653,7 +716,7 @@ extension FeedlyAPICaller: FeedlyGetStreamIdsService { request.addValue("application/json", forHTTPHeaderField: "Accept-Type") request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) - transport.send(request: request, resultType: FeedlyStreamIds.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + send(request: request, resultType: FeedlyStreamIds.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success(let (_, collections)): if let response = collections { @@ -708,7 +771,7 @@ extension FeedlyAPICaller: FeedlyGetEntriesService { request.addValue("application/json", forHTTPHeaderField: "Accept-Type") request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) - transport.send(request: request, resultType: [FeedlyEntry].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + send(request: request, resultType: [FeedlyEntry].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success(let (_, entries)): if let response = entries { @@ -767,7 +830,7 @@ extension FeedlyAPICaller: FeedlyMarkArticlesService { } } - transport.send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success(let (httpResponse, _)): if httpResponse.statusCode == 200 { @@ -811,7 +874,7 @@ extension FeedlyAPICaller: FeedlySearchService { request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType) request.addValue("application/json", forHTTPHeaderField: "Accept-Type") - transport.send(request: request, resultType: FeedlyFeedsSearchResponse.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + send(request: request, resultType: FeedlyFeedsSearchResponse.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success(let (_, searchResponse)): if let response = searchResponse { @@ -853,7 +916,7 @@ extension FeedlyAPICaller: FeedlyLogoutService { request.addValue("application/json", forHTTPHeaderField: "Accept-Type") request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) - transport.send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { case .success(let (httpResponse, _)): if httpResponse.statusCode == 200 { diff --git a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift index 0ef73d556..a5be6f994 100644 --- a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift +++ b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift @@ -38,12 +38,14 @@ final class FeedlyAccountDelegate: AccountDelegate { var credentials: Credentials? { didSet { + #if DEBUG // https://developer.feedly.com/v3/developer/ if let devToken = ProcessInfo.processInfo.environment["FEEDLY_DEV_ACCESS_TOKEN"], !devToken.isEmpty { caller.credentials = Credentials(type: .oauthAccessToken, username: "Developer", secret: devToken) - } else { - caller.credentials = credentials + return } + #endif + caller.credentials = credentials } } @@ -53,6 +55,10 @@ final class FeedlyAccountDelegate: AccountDelegate { var refreshProgress = DownloadProgress(numberOfTasks: 0) + /// Set on `accountDidInitialize` for the purposes of refreshing OAuth tokens when they expire. + /// See the implementation for `FeedlyAPICallerDelegate`. + private weak var initializedAccount: Account? + internal let caller: FeedlyAPICaller private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feedly") @@ -92,6 +98,8 @@ final class FeedlyAccountDelegate: AccountDelegate { let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") self.database = SyncDatabase(databaseFilePath: databaseFilePath) self.oauthAuthorizationClient = api.oauthAuthorizationClient + + self.caller.delegate = self } // MARK: Account API @@ -117,17 +125,10 @@ final class FeedlyAccountDelegate: AccountDelegate { let log = self.log - let refreshAccessToken = FeedlyRefreshAccessTokenOperation(account: account, service: self, oauthClient: oauthAuthorizationClient, refreshDate: Date(), log: log) - refreshAccessToken.downloadProgress = refreshProgress - operationQueue.add(refreshAccessToken) - let syncAllOperation = FeedlySyncAllOperation(account: account, feedlyUserId: credentials.username, caller: caller, database: database, lastSuccessfulFetchStartDate: accountMetadata?.lastArticleFetchStartTime, downloadProgress: refreshProgress, log: log) syncAllOperation.downloadProgress = refreshProgress - // Ensure the sync uses the latest credential. - syncAllOperation.addDependency(refreshAccessToken) - let date = Date() syncAllOperation.syncCompletionHandler = { [weak self] result in if case .success = result { @@ -505,6 +506,7 @@ final class FeedlyAccountDelegate: AccountDelegate { } func accountDidInitialize(_ account: Account) { + initializedAccount = account credentials = try? account.retrieveCredentials(type: .oauthAccessToken) } @@ -538,3 +540,37 @@ final class FeedlyAccountDelegate: AccountDelegate { caller.resume() } } + +extension FeedlyAccountDelegate: FeedlyAPICallerDelegate { + + func reauthorizeFeedlyAPICaller(_ caller: FeedlyAPICaller, completionHandler: @escaping (Bool) -> ()) { + guard let account = initializedAccount else { + completionHandler(false) + return + } + + /// Captures a failure to refresh a token, assuming that it was refreshed unless told otherwise. + final class RefreshAccessTokenOperationDelegate: FeedlyOperationDelegate { + + private(set) var didReauthorize = true + + func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) { + didReauthorize = false + } + } + + let refreshAccessToken = FeedlyRefreshAccessTokenOperation(account: account, service: self, oauthClient: oauthAuthorizationClient, log: log) + refreshAccessToken.downloadProgress = refreshProgress + + /// This must be strongly referenced by the completionBlock of the `FeedlyRefreshAccessTokenOperation`. + let refreshAccessTokenDelegate = RefreshAccessTokenOperationDelegate() + refreshAccessToken.delegate = refreshAccessTokenDelegate + + refreshAccessToken.completionBlock = { operation in + assert(Thread.isMainThread) + completionHandler(refreshAccessTokenDelegate.didReauthorize && !operation.isCanceled) + } + + MainThreadOperationQueue.shared.add(refreshAccessToken) + } +} diff --git a/Frameworks/Account/Feedly/OAuthAuthorizationClient+Feedly.swift b/Frameworks/Account/Feedly/OAuthAuthorizationClient+Feedly.swift index 56b28361a..7802ef29b 100644 --- a/Frameworks/Account/Feedly/OAuthAuthorizationClient+Feedly.swift +++ b/Frameworks/Account/Feedly/OAuthAuthorizationClient+Feedly.swift @@ -26,11 +26,11 @@ extension OAuthAuthorizationClient { /// See https://developer.feedly.com/v3/sandbox/ for more information. /// The return value models public sandbox API values found at: /// https://groups.google.com/forum/#!topic/feedly-cloud/WwQWMgDmOuw - /// They are due to expire on January 31 2020. + /// They are due to expire on May 31st 2020. /// Verify the sandbox URL host in the FeedlyAPICaller.API.baseUrlComponents method, too. return OAuthAuthorizationClient(id: "sandbox", redirectUri: "urn:ietf:wg:oauth:2.0:oob", state: nil, - secret: "nZmS4bqxgRQkdPks") + secret: "4ZfZ5DvqmJ8vKgMj") } } diff --git a/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift index 096ec5c5e..ad8271d12 100644 --- a/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift +++ b/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift @@ -18,32 +18,14 @@ final class FeedlyRefreshAccessTokenOperation: FeedlyOperation { let account: Account let log: OSLog - /// The moment the refresh is being requested. The token will refresh only if the account's `lastCredentialRenewTime` is not on the same day as this moment. When nil, the operation will always refresh the token. - let refreshDate: Date? - - init(account: Account, service: OAuthAccessTokenRefreshing, oauthClient: OAuthAuthorizationClient, refreshDate: Date?, log: OSLog) { + init(account: Account, service: OAuthAccessTokenRefreshing, oauthClient: OAuthAuthorizationClient, log: OSLog) { self.oauthClient = oauthClient self.service = service self.account = account - self.refreshDate = refreshDate self.log = log } override func run() { - // Only refresh the token if these dates are not on the same day. - let shouldRefresh: Bool = { - guard let date = refreshDate, let lastRenewDate = account.metadata.lastCredentialRenewTime else { - return true - } - return !Calendar.current.isDate(lastRenewDate, equalTo: date, toGranularity: .day) - }() - - guard shouldRefresh else { - os_log(.debug, log: log, "Skipping access token renewal.") - didFinish() - return - } - let refreshToken: Credentials do { @@ -83,8 +65,6 @@ final class FeedlyRefreshAccessTokenOperation: FeedlyOperation { // Now store the access token because we want the account delegate to use it. try account.storeCredentials(grant.accessToken) - account.metadata.lastCredentialRenewTime = Date() - didFinish() } catch { didFinish(with: error) diff --git a/iOS/Article/WebViewController.swift b/iOS/Article/WebViewController.swift index 0de40c0f9..71f8b259c 100644 --- a/iOS/Article/WebViewController.swift +++ b/iOS/Article/WebViewController.swift @@ -129,8 +129,9 @@ class WebViewController: UIViewController { func scrollPageDown() { guard let webView = webView else { return } + let overlap = 2 * UIFont.systemFont(ofSize: UIFont.systemFontSize).lineHeight * UIScreen.main.scale let scrollToY: CGFloat = { - let fullScroll = webView.scrollView.contentOffset.y + webView.scrollView.layoutMarginsGuide.layoutFrame.height + let fullScroll = webView.scrollView.contentOffset.y + webView.scrollView.layoutMarginsGuide.layoutFrame.height - overlap let final = finalScrollPosition() return fullScroll < final ? fullScroll : final }() diff --git a/iOS/KeyboardManager.swift b/iOS/KeyboardManager.swift index a79dcd834..8d66418e8 100644 --- a/iOS/KeyboardManager.swift +++ b/iOS/KeyboardManager.swift @@ -167,6 +167,9 @@ private extension KeyboardManager { let nextDownTitle = NSLocalizedString("Select Next Down", comment: "Select Next Down") keys.append(KeyboardManager.createKeyCommand(title: nextDownTitle, action: "selectNextDown:", input: UIKeyCommand.inputDownArrow, modifiers: [])) + + let getFeedInfo = NSLocalizedString("Get Feed Info", comment: "Get Feed Info") + keys.append(KeyboardManager.createKeyCommand(title: getFeedInfo, action: "showFeedInspector:", input: "i", modifiers: .command)) return keys } @@ -192,6 +195,9 @@ private extension KeyboardManager { let findInArticleTitle = NSLocalizedString("Find in Article", comment: "Find in Article") keys.append(KeyboardManager.createKeyCommand(title: findInArticleTitle, action: "beginFind:", input: "f", modifiers: [.command])) + let getFeedInfo = NSLocalizedString("Get Feed Info", comment: "Get Feed Info") + keys.append(KeyboardManager.createKeyCommand(title: getFeedInfo, action: "showFeedInspector:", input: "i", modifiers: .command)) + let toggleSidebar = NSLocalizedString("Toggle Sidebar", comment: "Toggle Sidebar") keys.append(KeyboardManager.createKeyCommand(title: toggleSidebar, action: "toggleSidebar:", input: "s", modifiers: [.command, .control])) diff --git a/iOS/MasterFeed/MasterFeedViewController.swift b/iOS/MasterFeed/MasterFeedViewController.swift index 6f6fac62c..a240e4b58 100644 --- a/iOS/MasterFeed/MasterFeedViewController.swift +++ b/iOS/MasterFeed/MasterFeedViewController.swift @@ -470,6 +470,10 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { self?.coordinator.markAllAsReadInTimeline() } } + + @objc func showFeedInspector(_ sender: Any?) { + coordinator.showFeedInspector() + } // MARK: API @@ -636,12 +640,23 @@ private extension MasterFeedViewController { func queueApply(snapshot: NSDiffableDataSourceSnapshot, animatingDifferences: Bool = true, completion: (() -> Void)? = nil) { let operation = MasterFeedDataSourceOperation(dataSource: dataSource, snapshot: snapshot, animating: animatingDifferences) - operation.completionBlock = { _ in + operation.completionBlock = { [weak self] _ in + self?.enableTableViewSelection() completion?() } + disableTableViewSelectionIfNecessary() operationQueue.add(operation) } + private func disableTableViewSelectionIfNecessary() { + // We only need to disable tableView selection if the feeds are filtered by unread + guard coordinator.isReadFeedsFiltered else { return } + tableView.allowsSelection = false + } + + private func enableTableViewSelection() { + tableView.allowsSelection = true + } func makeDataSource() -> MasterFeedDataSource { let dataSource = MasterFeedDataSource(tableView: tableView, cellProvider: { [weak self] tableView, indexPath, node in diff --git a/iOS/MasterTimeline/MasterTimelineViewController.swift b/iOS/MasterTimeline/MasterTimelineViewController.swift index def6a1674..4fa7410a0 100644 --- a/iOS/MasterTimeline/MasterTimelineViewController.swift +++ b/iOS/MasterTimeline/MasterTimelineViewController.swift @@ -89,6 +89,14 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner } } + // Disable swipe back on iPad Mice + if #available(iOS 13.4, *) { + guard let gesture = self.navigationController?.interactivePopGestureRecognizer as? UIPanGestureRecognizer else { + return + } + gesture.allowedScrollTypesMask = [] + } + } override func viewWillAppear(_ animated: Bool) { @@ -166,7 +174,7 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner coordinator.navigateToDetail() } - @objc func showFeedInspector(_ sender: UITapGestureRecognizer) { + @objc func showFeedInspector(_ sender: Any?) { coordinator.showFeedInspector() } diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 6d976f567..637c46974 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -1110,7 +1110,9 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } func showFeedInspector() { - guard let feed = timelineFeed as? WebFeed else { + let timelineWebFeed = timelineFeed as? WebFeed + let articleFeed = currentArticle?.webFeed + guard let feed = timelineWebFeed ?? articleFeed else { return } showFeedInspector(for: feed) diff --git a/iOS/TitleActivityItemSource.swift b/iOS/TitleActivityItemSource.swift index 1e84f9002..428b75f04 100644 --- a/iOS/TitleActivityItemSource.swift +++ b/iOS/TitleActivityItemSource.swift @@ -28,7 +28,9 @@ class TitleActivityItemSource: NSObject, UIActivityItemSource { switch activityType.rawValue { case "com.omnigroup.OmniFocus3.iOS.QuickEntry", - "com.culturedcode.ThingsiPhone.ShareExtension": + "com.culturedcode.ThingsiPhone.ShareExtension", + "com.tapbots.Tweetbot4.shareextension", + "com.buffer.buffer.Buffer": return title default: return NSNull() diff --git a/submodules/RSCore b/submodules/RSCore index e1524fcb2..851b3b672 160000 --- a/submodules/RSCore +++ b/submodules/RSCore @@ -1 +1 @@ -Subproject commit e1524fcb2a42ccc5db765599adf3f7addc59fc6f +Subproject commit 851b3b672763dc5d402c54caa5c10adb9f1a8eef