diff --git a/Shared/Extensions/RSImage-Extensions.swift b/Shared/Extensions/RSImage-Extensions.swift index 337f09fa6..985fb7d36 100644 --- a/Shared/Extensions/RSImage-Extensions.swift +++ b/Shared/Extensions/RSImage-Extensions.swift @@ -14,7 +14,7 @@ extension RSImage { static let avatarSize = 48 static func scaledForAvatar(_ data: Data, imageResultBlock: @escaping (RSImage?) -> Void) { - DispatchQueue.global().async { + DispatchQueue.global(qos: .default).async { let image = RSImage.scaledForAvatar(data) DispatchQueue.main.async { imageResultBlock(image) diff --git a/Shared/Favicons/ColorHash.swift b/Shared/Favicons/ColorHash.swift index b275d0c59..5cd55d05a 100644 --- a/Shared/Favicons/ColorHash.swift +++ b/Shared/Favicons/ColorHash.swift @@ -18,7 +18,9 @@ import AppKit public class ColorHash { - public static let defaultLS = [CGFloat(0.45), CGFloat(0.6), CGFloat(0.75)] + public static let defaultSaturation = [CGFloat(0.35), CGFloat(0.5), CGFloat(0.65)] + public static let defaultBrightness = [CGFloat(0.5), CGFloat(0.65), CGFloat(0.80)] + let seed = CGFloat(131.0) let seed2 = CGFloat(137.0) let maxSafeInteger = 9007199254740991.0 / CGFloat(137.0) @@ -28,7 +30,7 @@ public class ColorHash { public private(set) var brightness: [CGFloat] public private(set) var saturation: [CGFloat] - public init(_ str: String, _ saturation: [CGFloat] = defaultLS, _ brightness: [CGFloat] = defaultLS) { + public init(_ str: String, _ saturation: [CGFloat] = defaultSaturation, _ brightness: [CGFloat] = defaultBrightness) { self.str = str self.saturation = saturation self.brightness = brightness diff --git a/Shared/Favicons/FaviconDownloader.swift b/Shared/Favicons/FaviconDownloader.swift index 0cedd923c..8605c29cd 100644 --- a/Shared/Favicons/FaviconDownloader.swift +++ b/Shared/Favicons/FaviconDownloader.swift @@ -18,11 +18,28 @@ extension Notification.Name { final class FaviconDownloader { + private static let saveQueue = CoalescingQueue(name: "Cache Save Queue", interval: 1.0) + private let folder: String private let diskCache: BinaryDiskCache private var singleFaviconDownloaderCache = [String: SingleFaviconDownloader]() // faviconURL: SingleFaviconDownloader + private var homePageToFaviconURLCache = [String: String]() //homePageURL: faviconURL - private var homePageURLsWithNoFaviconURL = Set() + private var homePageToFaviconURLCachePath: String + private var homePageToFaviconURLCacheDirty = false { + didSet { + queueSaveHomePageToFaviconURLCacheIfNeeded() + } + } + + private var homePageURLsWithNoFaviconURLCache = Set() + private var homePageURLsWithNoFaviconURLCachePath: String + private var homePageURLsWithNoFaviconURLCacheDirty = false { + didSet { + queueSaveHomePageURLsWithNoFaviconURLCacheIfNeeded() + } + } + private let queue: DispatchQueue private var cache = [Feed: RSImage]() // faviconURL: RSImage @@ -36,6 +53,11 @@ final class FaviconDownloader { self.diskCache = BinaryDiskCache(folder: folder) self.queue = DispatchQueue(label: "FaviconDownloader serial queue - \(folder)") + self.homePageToFaviconURLCachePath = (folder as NSString).appendingPathComponent("HomePageToFaviconURLCache.plist") + self.homePageURLsWithNoFaviconURLCachePath = (folder as NSString).appendingPathComponent("HomePageURLsWithNoFaviconURLCache.plist") + loadHomePageToFaviconURLCache() + loadHomePageURLsWithNoFaviconURLCache() + NotificationCenter.default.addObserver(self, selector: #selector(didLoadFavicon(_:)), name: .DidLoadFavicon, object: nil) } @@ -92,7 +114,7 @@ final class FaviconDownloader { func favicon(withHomePageURL homePageURL: String) -> RSImage? { let url = homePageURL.rs_normalizedURL() - if homePageURLsWithNoFaviconURL.contains(url) { + if homePageURLsWithNoFaviconURLCache.contains(url) { return nil } @@ -103,10 +125,12 @@ final class FaviconDownloader { findFaviconURL(with: url) { (faviconURL) in if let faviconURL = faviconURL { self.homePageToFaviconURLCache[url] = faviconURL + self.homePageToFaviconURLCacheDirty = true let _ = self.favicon(with: faviconURL) } else { - self.homePageURLsWithNoFaviconURL.insert(url) + self.homePageURLsWithNoFaviconURLCache.insert(url) + self.homePageURLsWithNoFaviconURLCacheDirty = true } } @@ -126,6 +150,18 @@ final class FaviconDownloader { postFaviconDidBecomeAvailableNotification(singleFaviconDownloader.faviconURL) } + + @objc func saveHomePageToFaviconURLCacheIfNeeded() { + if homePageToFaviconURLCacheDirty { + saveHomePageToFaviconURLCache() + } + } + + @objc func saveHomePageURLsWithNoFaviconURLCacheIfNeeded() { + if homePageURLsWithNoFaviconURLCacheDirty { + saveHomePageURLsWithNoFaviconURLCache() + } + } } private extension FaviconDownloader { @@ -175,4 +211,60 @@ private extension FaviconDownloader { NotificationCenter.default.post(name: .FaviconDidBecomeAvailable, object: self, userInfo: userInfo) } } + + func loadHomePageToFaviconURLCache() { + let url = URL(fileURLWithPath: homePageToFaviconURLCachePath) + guard let data = try? Data(contentsOf: url) else { + return + } + let decoder = PropertyListDecoder() + homePageToFaviconURLCache = (try? decoder.decode([String: String].self, from: data)) ?? [String: String]() + } + + func loadHomePageURLsWithNoFaviconURLCache() { + let url = URL(fileURLWithPath: homePageURLsWithNoFaviconURLCachePath) + guard let data = try? Data(contentsOf: url) else { + return + } + let decoder = PropertyListDecoder() + let decoded = (try? decoder.decode([String].self, from: data)) ?? [String]() + homePageURLsWithNoFaviconURLCache = Set(decoded) + } + + func queueSaveHomePageToFaviconURLCacheIfNeeded() { + FaviconDownloader.saveQueue.add(self, #selector(saveHomePageToFaviconURLCacheIfNeeded)) + } + + func queueSaveHomePageURLsWithNoFaviconURLCacheIfNeeded() { + FaviconDownloader.saveQueue.add(self, #selector(saveHomePageURLsWithNoFaviconURLCacheIfNeeded)) + } + + func saveHomePageToFaviconURLCache() { + homePageToFaviconURLCacheDirty = false + + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + let url = URL(fileURLWithPath: homePageToFaviconURLCachePath) + do { + let data = try encoder.encode(homePageToFaviconURLCache) + try data.write(to: url) + } catch { + assertionFailure(error.localizedDescription) + } + } + + func saveHomePageURLsWithNoFaviconURLCache() { + homePageURLsWithNoFaviconURLCacheDirty = false + + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + let url = URL(fileURLWithPath: homePageURLsWithNoFaviconURLCachePath) + do { + let data = try encoder.encode(Array(homePageURLsWithNoFaviconURLCache)) + try data.write(to: url) + } catch { + assertionFailure(error.localizedDescription) + } + } + } diff --git a/Shared/Images/FeedIconDownloader.swift b/Shared/Images/FeedIconDownloader.swift index 2d924e584..e1edec0e3 100644 --- a/Shared/Images/FeedIconDownloader.swift +++ b/Shared/Images/FeedIconDownloader.swift @@ -32,7 +32,14 @@ public final class FeedIconDownloader { } } - private var homePagesWithNoIconURL = Set() + private var homePagesWithNoIconURLCache = Set() + private var homePagesWithNoIconURLCachePath: String + private var homePagesWithNoIconURLCacheDirty = false { + didSet { + queueHomePagesWithNoIconURLCacheIfNeeded() + } + } + private var urlsInProgress = Set() private var cache = [Feed: RSImage]() private var waitingForFeedURLs = [String: Feed]() @@ -40,7 +47,9 @@ public final class FeedIconDownloader { init(imageDownloader: ImageDownloader, folder: String) { self.imageDownloader = imageDownloader self.homePageToIconURLCachePath = (folder as NSString).appendingPathComponent("HomePageToIconURLCache.plist") + self.homePagesWithNoIconURLCachePath = (folder as NSString).appendingPathComponent("HomePagesWithNoIconURLCache.plist") loadHomePageToIconURLCache() + loadHomePagesWithNoIconURLCache() NotificationCenter.default.addObserver(self, selector: #selector(imageDidBecomeAvailable(_:)), name: .ImageDidBecomeAvailable, object: imageDownloader) } @@ -99,13 +108,19 @@ public final class FeedIconDownloader { } } + @objc func saveHomePagesWithNoIconURLCacheIfNeeded() { + if homePagesWithNoIconURLCacheDirty { + saveHomePagesWithNoIconURLCache() + } + } + } private extension FeedIconDownloader { func icon(forHomePageURL homePageURL: String, feed: Feed, _ imageResultBlock: @escaping (RSImage?) -> Void) { - if homePagesWithNoIconURL.contains(homePageURL) { + if homePagesWithNoIconURLCache.contains(homePageURL) { imageResultBlock(nil) return } @@ -141,7 +156,8 @@ private extension FeedIconDownloader { } func cacheIconURL(for homePageURL: String, _ iconURL: String) { - homePagesWithNoIconURL.remove(homePageURL) + homePagesWithNoIconURLCache.remove(homePageURL) + homePagesWithNoIconURLCacheDirty = true homePageToIconURLCache[homePageURL] = iconURL homePageToIconURLCacheDirty = true } @@ -172,7 +188,8 @@ private extension FeedIconDownloader { return } - homePagesWithNoIconURL.insert(homePageURL) + homePagesWithNoIconURLCache.insert(homePageURL) + homePagesWithNoIconURLCacheDirty = true } func loadHomePageToIconURLCache() { @@ -184,10 +201,24 @@ private extension FeedIconDownloader { homePageToIconURLCache = (try? decoder.decode([String: String].self, from: data)) ?? [String: String]() } + func loadHomePagesWithNoIconURLCache() { + let url = URL(fileURLWithPath: homePagesWithNoIconURLCachePath) + guard let data = try? Data(contentsOf: url) else { + return + } + let decoder = PropertyListDecoder() + let decoded = (try? decoder.decode([String].self, from: data)) ?? [String]() + homePagesWithNoIconURLCache = Set(decoded) + } + func queueSaveHomePageToIconURLCacheIfNeeded() { FeedIconDownloader.saveQueue.add(self, #selector(saveHomePageToIconURLCacheIfNeeded)) } + func queueHomePagesWithNoIconURLCacheIfNeeded() { + FeedIconDownloader.saveQueue.add(self, #selector(saveHomePagesWithNoIconURLCacheIfNeeded)) + } + func saveHomePageToIconURLCache() { homePageToIconURLCacheDirty = false @@ -202,4 +233,18 @@ private extension FeedIconDownloader { } } + func saveHomePagesWithNoIconURLCache() { + homePagesWithNoIconURLCacheDirty = false + + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + let url = URL(fileURLWithPath: homePagesWithNoIconURLCachePath) + do { + let data = try encoder.encode(Array(homePagesWithNoIconURLCache)) + try data.write(to: url) + } catch { + assertionFailure(error.localizedDescription) + } + } + } diff --git a/iOS/Add/AddContainerViewController.swift b/iOS/Add/AddContainerViewController.swift index 108a45565..262b348e8 100644 --- a/iOS/Add/AddContainerViewController.swift +++ b/iOS/Add/AddContainerViewController.swift @@ -109,6 +109,7 @@ private extension AddContainerViewController { return } + navigationItem.title = NSLocalizedString("Add Feed", comment: "Add Feed") resetUI() hideCurrentController() @@ -126,6 +127,7 @@ private extension AddContainerViewController { return } + navigationItem.title = NSLocalizedString("Add Folder", comment: "Add Folder") resetUI() hideCurrentController() displayContentController(UIStoryboard.add.instantiateController(ofType: AddFolderViewController.self)) diff --git a/iOS/Add/AddFeedViewController.swift b/iOS/Add/AddFeedViewController.swift index 27c3d2e6c..49b1061db 100644 --- a/iOS/Add/AddFeedViewController.swift +++ b/iOS/Add/AddFeedViewController.swift @@ -44,7 +44,6 @@ class AddFeedViewController: UITableViewController, AddContainerViewControllerCh urlTextField.autocapitalizationType = .none urlTextField.text = initialFeed urlTextField.delegate = self - urlTextField.becomeFirstResponder() if initialFeed != nil { delegate?.readyToAdd(state: true) diff --git a/iOS/Add/AddFolderViewController.swift b/iOS/Add/AddFolderViewController.swift index 61f0f01f9..be70a3d9d 100644 --- a/iOS/Add/AddFolderViewController.swift +++ b/iOS/Add/AddFolderViewController.swift @@ -31,7 +31,6 @@ class AddFolderViewController: UITableViewController, AddContainerViewController accounts = AccountManager.shared.sortedActiveAccounts nameTextField.delegate = self - nameTextField.becomeFirstResponder() accountLabel.text = (accounts[0] as DisplayNameProvider).nameForDisplay diff --git a/iOS/AppDelegate.swift b/iOS/AppDelegate.swift index f0fe5936f..be4c90b19 100644 --- a/iOS/AppDelegate.swift +++ b/iOS/AppDelegate.swift @@ -18,6 +18,7 @@ var appDelegate: AppDelegate! @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, UnreadCountProvider { + private var waitBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid private var syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid var syncTimer: ArticleStatusSyncTimer? @@ -130,28 +131,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func prepareAccountsForBackground() { syncTimer?.invalidate() - - // Schedule background app refresh scheduleBackgroundFeedRefresh() - - // Sync article status - let completeProcessing = { [unowned self] in - UIApplication.shared.endBackgroundTask(self.syncBackgroundUpdateTask) - self.syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid - } - - DispatchQueue.global(qos: .background).async { - self.syncBackgroundUpdateTask = UIApplication.shared.beginBackgroundTask { - completeProcessing() - os_log("Accounts sync processing terminated for running too long.", log: self.log, type: .info) - } - - DispatchQueue.main.async { - AccountManager.shared.syncArticleStatusAll() { - completeProcessing() - } - } - } + waitForProgressToFinish() + syncArticleStatus() } func prepareAccountsForForeground() { @@ -200,12 +182,15 @@ private extension AppDelegate { let faviconsFolderURL = tempDir.appendingPathComponent("Favicons") let imagesFolderURL = tempDir.appendingPathComponent("Images") let homePageToIconURL = tempDir.appendingPathComponent("HomePageToIconURLCache.plist") + let homePagesWithNoIconURL = tempDir.appendingPathComponent("HomePagesWithNoIconURLCache.plist") + let homePageToFaviconURL = tempDir.appendingPathComponent("HomePageToFaviconURLCache.plist") + let homePageURLsWithNoFaviconURL = tempDir.appendingPathComponent("HomePageURLsWithNoFaviconURLCache.plist") // If the image disk cache hasn't been flushed for 3 days and the network is available, delete it if let flushDate = AppDefaults.lastImageCacheFlushDate, flushDate.addingTimeInterval(3600*24*3) < Date() { if let reachability = try? Reachability(hostname: "apple.com") { if reachability.connection != .unavailable { - for tempItem in [faviconsFolderURL, imagesFolderURL, homePageToIconURL] { + for tempItem in [faviconsFolderURL, imagesFolderURL, homePageToIconURL, homePagesWithNoIconURL, homePageToFaviconURL, homePageURLsWithNoFaviconURL] { do { os_log(.info, log: self.log, "Removing cache file: %@", tempItem.absoluteString) try FileManager.default.removeItem(at: tempItem) @@ -253,6 +238,68 @@ private extension AppDelegate { } +// MARK: Go To Background +private extension AppDelegate { + + func waitForProgressToFinish() { + let completeProcessing = { [unowned self] in + AccountManager.shared.saveAll() + UIApplication.shared.endBackgroundTask(self.waitBackgroundUpdateTask) + self.waitBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid + } + + self.waitBackgroundUpdateTask = UIApplication.shared.beginBackgroundTask { + completeProcessing() + os_log("Accounts wait for progress terminated for running too long.", log: self.log, type: .info) + } + + DispatchQueue.main.async { [weak self] in + self?.waitToComplete() { + completeProcessing() + } + } + } + + func waitToComplete(completion: @escaping () -> Void) { + guard UIApplication.shared.applicationState != .active else { + os_log("App came back to forground, no longer waiting.", log: self.log, type: .info) + completion() + return + } + + if AccountManager.shared.refreshInProgress { + os_log("Waiting for refresh progress to finish...", log: self.log, type: .info) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.waitToComplete() { + completion() + } + } + } else { + os_log("Refresh progress complete.", log: self.log, type: .info) + completion() + } + } + + func syncArticleStatus() { + let completeProcessing = { [unowned self] in + UIApplication.shared.endBackgroundTask(self.syncBackgroundUpdateTask) + self.syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid + } + + self.syncBackgroundUpdateTask = UIApplication.shared.beginBackgroundTask { + completeProcessing() + os_log("Accounts sync processing terminated for running too long.", log: self.log, type: .info) + } + + DispatchQueue.main.async { + AccountManager.shared.syncArticleStatusAll() { + completeProcessing() + } + } + } + +} + // MARK: Background Tasks private extension AppDelegate { diff --git a/iOS/Article/ArticleViewController.swift b/iOS/Article/ArticleViewController.swift index a479e693f..36d43bf72 100644 --- a/iOS/Article/ArticleViewController.swift +++ b/iOS/Article/ArticleViewController.swift @@ -85,6 +85,9 @@ class ArticleViewController: UIViewController { 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 @@ -109,8 +112,6 @@ class ArticleViewController: UIViewController { webView.navigationDelegate = self webView.uiDelegate = self - webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasClicked) - webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasShown) webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked) webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown) @@ -118,8 +119,6 @@ class ArticleViewController: UIViewController { // 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()) -// let request = URLRequest(url: url) -// webView.load(request) } @@ -184,6 +183,7 @@ class ArticleViewController: UIViewController { render = "render(\(json));" } + webView?.scrollView.setZoomScale(1.0, animated: false) webView?.evaluateJavaScript(render) } diff --git a/iOS/AvatarView.swift b/iOS/AvatarView.swift index 265ee8042..3bce3e986 100644 --- a/iOS/AvatarView.swift +++ b/iOS/AvatarView.swift @@ -16,7 +16,7 @@ final class AvatarView: UIView { imageView.image = image if self.traitCollection.userInterfaceStyle == .dark { - DispatchQueue.global(qos: .background).async { + DispatchQueue.global(qos: .default).async { if self.image?.isDark() ?? false { DispatchQueue.main.async { self.isDisconcernable = false @@ -74,7 +74,7 @@ final class AvatarView: UIView { override func layoutSubviews() { imageView.setFrameIfNotEqual(rectForImageView()) - if (isVerticalBackgroundExposed && !isSymbolImage) || !isDisconcernable { + if (image != nil && isVerticalBackgroundExposed && !isSymbolImage) || !isDisconcernable { backgroundColor = AppAssets.avatarBackgroundColor } else { backgroundColor = nil diff --git a/iOS/Inspector/FeedInspectorView.swift b/iOS/Inspector/FeedInspectorView.swift index 363103263..1e66ed5d8 100644 --- a/iOS/Inspector/FeedInspectorView.swift +++ b/iOS/Inspector/FeedInspectorView.swift @@ -69,7 +69,6 @@ struct FeedInspectorView : View { .onDisappear { self.viewModel.save() } .navigationBarTitle(Text(verbatim: self.viewModel.nameForDisplay), displayMode: .inline) .navigationBarItems(leading: Button(action: { - self.viewModel.save() self.viewController?.dismiss(animated: true) }) { Text("Done") } ) } @@ -137,7 +136,8 @@ struct FeedInspectorView : View { func save() { if name != nameForDisplay { - feed.editedName = name.isEmpty ? nil : name + let newName = name.isEmpty ? (feed.name ?? NSLocalizedString("Untitled", comment: "Feed name")) : name + feed.rename(to: newName) { _ in } } } diff --git a/iOS/MasterFeed/Cell/MasterFeedTableViewCell.swift b/iOS/MasterFeed/Cell/MasterFeedTableViewCell.swift index 1e1427f95..9e1b7523b 100644 --- a/iOS/MasterFeed/Cell/MasterFeedTableViewCell.swift +++ b/iOS/MasterFeed/Cell/MasterFeedTableViewCell.swift @@ -87,6 +87,7 @@ class MasterFeedTableViewCell : VibrantTableViewCell { label.numberOfLines = 0 label.allowsDefaultTighteningForTruncation = false label.adjustsFontForContentSizeCategory = true + label.lineBreakMode = .byTruncatingTail label.font = .preferredFont(forTextStyle: .body) return label }() diff --git a/iOS/MasterFeed/Cell/MasterFeedTableViewCellLayout.swift b/iOS/MasterFeed/Cell/MasterFeedTableViewCellLayout.swift index c4f83bf86..13cdc57c6 100644 --- a/iOS/MasterFeed/Cell/MasterFeedTableViewCellLayout.swift +++ b/iOS/MasterFeed/Cell/MasterFeedTableViewCellLayout.swift @@ -37,10 +37,6 @@ struct MasterFeedTableViewCellLayout { if indent { initialIndent += MasterFeedTableViewCellLayout.disclosureButtonSize.width } - if showingEditingControl { - initialIndent += MasterFeedTableViewCellLayout.editingControlIndent - } - let bounds = CGRect(x: initialIndent, y: 0.0, width: floor(cellWidth - initialIndent - insets.right), height: 0.0) // Disclosure Button @@ -68,18 +64,12 @@ struct MasterFeedTableViewCellLayout { if !unreadCountIsHidden { rUnread.size = unreadCountSize rUnread.origin.x = bounds.maxX - (MasterFeedTableViewCellLayout.unreadCountMarginRight + unreadCountSize.width) - if showingEditingControl { - rUnread.origin.x = rUnread.origin.x - MasterFeedTableViewCellLayout.editingControlIndent - } } // Title - - var rLabelx = CGFloat.zero - if shouldShowDisclosure { - rLabelx = bounds.minX + MasterFeedTableViewCellLayout.disclosureButtonSize.width - } else { - rLabelx = rFavicon.maxX + MasterFeedTableViewCellLayout.imageMarginRight + var rLabelx = insets.left + MasterFeedTableViewCellLayout.disclosureButtonSize.width + if !shouldShowDisclosure { + rLabelx = rLabelx + MasterFeedTableViewCellLayout.imageSize.width + MasterFeedTableViewCellLayout.imageMarginRight } let rLabely = UIFontMetrics.default.scaledValue(for: MasterFeedTableViewCellLayout.verticalPadding) @@ -91,7 +81,23 @@ struct MasterFeedTableViewCellLayout { } let labelSizeInfo = MultilineUILabelSizer.size(for: label.text ?? "", font: label.font, numberOfLines: 0, width: Int(floor(labelWidth))) - let rLabel = CGRect(x: rLabelx, y: rLabely, width: labelSizeInfo.size.width, height: labelSizeInfo.size.height) + + // Now that we've got everything (especially the label) computed without the editing controls, update for them. + // We do this because we don't want the row height to change when the editing controls are brought out. We will + // handle the missing space, but removing it from the label and truncating. + if showingEditingControl { + rDisclosure.origin.x += MasterFeedTableViewCellLayout.editingControlIndent + rFavicon.origin.x += MasterFeedTableViewCellLayout.editingControlIndent + rLabelx += MasterFeedTableViewCellLayout.editingControlIndent + if !unreadCountIsHidden { + rUnread.origin.x -= MasterFeedTableViewCellLayout.editingControlIndent + labelWidth = cellWidth - (rLabelx + MasterFeedTableViewCellLayout.labelMarginRight + (cellWidth - rUnread.minX)) + } else { + labelWidth = cellWidth - (rLabelx + MasterFeedTableViewCellLayout.labelMarginRight + MasterFeedTableViewCellLayout.editingControlIndent) + } + } + + let rLabel = CGRect(x: rLabelx, y: rLabely, width: labelWidth, height: labelSizeInfo.size.height) // Determine cell height let paddedLabelHeight = rLabel.maxY + UIFontMetrics.default.scaledValue(for: MasterFeedTableViewCellLayout.verticalPadding) diff --git a/iOS/MasterFeed/MasterFeedViewController.swift b/iOS/MasterFeed/MasterFeedViewController.swift index e6f0723ff..41994f17e 100644 --- a/iOS/MasterFeed/MasterFeedViewController.swift +++ b/iOS/MasterFeed/MasterFeedViewController.swift @@ -14,7 +14,7 @@ import RSTree class MasterFeedViewController: UITableViewController, UndoableCommandRunner { - private var refreshProgressView: RefreshProgressView! + private var refreshProgressView: RefreshProgressView? private var addNewItemButton: UIBarButtonItem! private lazy var dataSource = makeDataSource() @@ -99,10 +99,8 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { node = coordinator.rootNode.descendantNodeRepresentingObject(representedObject as AnyObject) } - if let node = node, let indexPath = dataSource.indexPath(for: node), let unreadCountProvider = node.representedObject as? UnreadCountProvider { - if let cell = tableView.cellForRow(at: indexPath) as? MasterFeedTableViewCell { - cell.unreadCount = unreadCountProvider.unreadCount - } + if let node = node, dataSource.indexPath(for: node) != nil { + reloadNode(node) } } @@ -131,7 +129,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { guard let feed = notification.userInfo?[UserInfoKey.feed] as? Feed else { return } - discloseFeed(feed) + discloseFeed(feed, animated: true) } @objc func contentSizeCategoryDidChange(_ note: Notification) { @@ -483,7 +481,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { } } - func discloseFeed(_ feed: Feed, completion: (() -> Void)? = nil) { + func discloseFeed(_ feed: Feed, animated: Bool, completion: (() -> Void)? = nil) { guard let node = coordinator.rootNode.descendantNodeRepresentingObject(feed as AnyObject) else { completion?() @@ -492,7 +490,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { if let indexPath = dataSource.indexPath(for: node) { tableView.selectRowAndScrollIfNotVisible(at: indexPath, animated: true) - coordinator.selectFeed(indexPath) + coordinator.selectFeed(indexPath, animated: animated) completion?() return } @@ -507,7 +505,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { self.applyChanges(animate: true, adjustScroll: true) { [weak self] in if let indexPath = self?.dataSource.indexPath(for: node) { - self?.coordinator.selectFeed(indexPath) + self?.coordinator.selectFeed(indexPath, animated: animated) completion?() } } @@ -572,8 +570,8 @@ private extension MasterFeedViewController { } func updateUI() { - refreshProgressView.updateRefreshLabel() - addNewItemButton.isEnabled = !AccountManager.shared.activeAccounts.isEmpty + refreshProgressView?.updateRefreshLabel() + addNewItemButton?.isEnabled = !AccountManager.shared.activeAccounts.isEmpty } func reloadNode(_ node: Node) { @@ -656,7 +654,7 @@ private extension MasterFeedViewController { return feedIconImage } - if let faviconImage = appDelegate.faviconDownloader.faviconAsAvatar(for: feed) { + if let faviconImage = appDelegate.faviconDownloader.favicon(for: feed) { return faviconImage } diff --git a/iOS/MasterTimeline/MasterTimelineViewController.swift b/iOS/MasterTimeline/MasterTimelineViewController.swift index 03b8d7ba0..4c22bca01 100644 --- a/iOS/MasterTimeline/MasterTimelineViewController.swift +++ b/iOS/MasterTimeline/MasterTimelineViewController.swift @@ -57,7 +57,6 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner NSLocalizedString("Here", comment: "Here"), NSLocalizedString("All Articles", comment: "All Articles") ] - navigationItem.searchController = searchController definesPresentationContext = true // Configure the table @@ -74,6 +73,13 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner super.viewWillAppear(animated) } + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + // You have to assign the search controller here to avoid showing it by default + // https://stackoverflow.com/questions/57581557/how-to-initally-hide-searchbar-in-navigation-controller-on-ios-13 + navigationItem.searchController = searchController + } + // MARK: Actions @IBAction func markAllAsRead(_ sender: Any) { @@ -580,7 +586,8 @@ private extension MasterTimelineViewController { let title = NSLocalizedString("Select Feed", comment: "Select Feed") let action = UIAction(title: title, image: AppAssets.openInSidebarImage) { [weak self] action in - self?.coordinator.discloseFeed(feed) + self?.coordinator.selectFeed(nil, animated: true) + self?.coordinator.discloseFeed(feed, animated: true) } return action } @@ -590,7 +597,8 @@ private extension MasterTimelineViewController { let title = NSLocalizedString("Select Feed", comment: "Select Feed") let action = UIAlertAction(title: title, style: .default) { [weak self] action in - self?.coordinator.discloseFeed(feed) + self?.coordinator.selectFeed(nil, animated: true) + self?.coordinator.discloseFeed(feed, animated: true) completionHandler(true) } return action diff --git a/iOS/Resources/main_ios.js b/iOS/Resources/main_ios.js index 2af2e5a81..7a87e51c1 100644 --- a/iOS/Resources/main_ios.js +++ b/iOS/Resources/main_ios.js @@ -1,23 +1,37 @@ -var imageIsLoading = false; +var controller = new AbortController() + +// Cancel any pending image loads (there might be none) and reset the controller +function cancelImageLoad() { + controller.abort(); + controller = new AbortController(); +} // Used to pop a resizable image view async function imageWasClicked(img) { - img.classList.add("nnwClicked"); + cancelImageLoad(); + showNetworkLoading(img); try { - showNetworkLoading(img); - const response = await fetch(img.src); + + const signal = controller.signal; + const response = await fetch(img.src, { signal: signal }); if (!response.ok) { throw new Error('Network response was not ok.'); } const imgBlob = await response.blob(); + if (signal.aborted) { + throw new Error('Network response was aborted.'); + } + hideNetworkLoading(img); - + var reader = new FileReader(); reader.readAsDataURL(imgBlob); reader.onloadend = function() { + img.classList.add("nnwClicked"); + const rect = img.getBoundingClientRect(); var message = { x: rect.x, @@ -29,8 +43,9 @@ async function imageWasClicked(img) { var jsonMessage = JSON.stringify(message); window.webkit.messageHandlers.imageWasClicked.postMessage(jsonMessage); - + } + } catch (error) { hideNetworkLoading(img); console.log('There has been a problem with your fetch operation: ', error.message); @@ -39,7 +54,6 @@ async function imageWasClicked(img) { } function showNetworkLoading(img) { - imageIsLoading = true; var wrapper = document.createElement("div"); wrapper.classList.add("activityIndicatorWrap"); @@ -64,8 +78,6 @@ function hideNetworkLoading(img) { var wrapperParent = wrapper.parentNode; wrapperParent.insertBefore(img, wrapper); wrapperParent.removeChild(wrapper); - - imageIsLoading = false; } // Used to animate the transition to a fullscreen image @@ -85,7 +97,7 @@ function showClickedImage() { // Add the click listener for images function imageClicks() { window.onclick = function(event) { - if (event.target.matches('img') && !imageIsLoading) { + if (event.target.matches('img')) { imageWasClicked(event.target); } } @@ -101,8 +113,9 @@ function inlineVideos() { } function postRenderProcessing() { - imageClicks() - inlineVideos() + cancelImageLoad(); + imageClicks(); + inlineVideos(); } const activityIndicator = "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2ZXJzaW9uPSIxLjAiIHdpZHRoPSI2NHB4IiBoZWlnaHQ9IjY0cHgiIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB4bWw6c3BhY2U9InByZXNlcnZlIj48Zz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiMwMDAwMDAiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiNjY2NjY2MiIHRyYW5zZm9ybT0icm90YXRlKDMwIDY0IDY0KSIvPjxwYXRoIGQ9Ik01OS42IDBoOHY0MGgtOFYweiIgZmlsbD0iI2NjY2NjYyIgdHJhbnNmb3JtPSJyb3RhdGUoNjAgNjQgNjQpIi8+PHBhdGggZD0iTTU5LjYgMGg4djQwaC04VjB6IiBmaWxsPSIjY2NjY2NjIiB0cmFuc2Zvcm09InJvdGF0ZSg5MCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiNjY2NjY2MiIHRyYW5zZm9ybT0icm90YXRlKDEyMCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiNiMmIyYjIiIHRyYW5zZm9ybT0icm90YXRlKDE1MCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiM5OTk5OTkiIHRyYW5zZm9ybT0icm90YXRlKDE4MCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiM3ZjdmN2YiIHRyYW5zZm9ybT0icm90YXRlKDIxMCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiM2NjY2NjYiIHRyYW5zZm9ybT0icm90YXRlKDI0MCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiM0YzRjNGMiIHRyYW5zZm9ybT0icm90YXRlKDI3MCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiMzMzMzMzMiIHRyYW5zZm9ybT0icm90YXRlKDMwMCA2NCA2NCkiLz48cGF0aCBkPSJNNTkuNiAwaDh2NDBoLThWMHoiIGZpbGw9IiMxOTE5MTkiIHRyYW5zZm9ybT0icm90YXRlKDMzMCA2NCA2NCkiLz48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIHR5cGU9InJvdGF0ZSIgdmFsdWVzPSIwIDY0IDY0OzMwIDY0IDY0OzYwIDY0IDY0OzkwIDY0IDY0OzEyMCA2NCA2NDsxNTAgNjQgNjQ7MTgwIDY0IDY0OzIxMCA2NCA2NDsyNDAgNjQgNjQ7MjcwIDY0IDY0OzMwMCA2NCA2NDszMzAgNjQgNjQiIGNhbGNNb2RlPSJkaXNjcmV0ZSIgZHVyPSIxMDgwbXMiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIj48L2FuaW1hdGVUcmFuc2Zvcm0+PC9nPjwvc3ZnPg=="; diff --git a/iOS/RootSplitViewController.swift b/iOS/RootSplitViewController.swift index cf7040c42..48bf64b9f 100644 --- a/iOS/RootSplitViewController.swift +++ b/iOS/RootSplitViewController.swift @@ -14,10 +14,10 @@ class RootSplitViewController: UISplitViewController { var coordinator: SceneCoordinator! override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + if UIApplication.shared.applicationState != .background { + self.coordinator.configureThreePanelMode(for: size) + } super.viewWillTransition(to: size, with: coordinator) - coordinator.animate(alongsideTransition: { [weak self] context in - self?.coordinator.configureThreePanelMode(for: size) - }) } // MARK: Keyboard Shortcuts diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 10153efe4..1be094103 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -65,6 +65,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { private var lastSearchScope: SearchScope? = nil private var isSearching: Bool = false private var searchArticleIds: Set? = nil + private var isTimelineViewControllerPending = false private var isArticleViewControllerPending = false private(set) var sortDirection = AppDefaults.timelineSortDirection { @@ -782,8 +783,8 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { markArticlesWithUndo([article], statusKey: .starred, flag: !article.status.starred) } - func discloseFeed(_ feed: Feed, completion: (() -> Void)? = nil) { - masterFeedViewController.discloseFeed(feed) { + func discloseFeed(_ feed: Feed, animated: Bool, completion: (() -> Void)? = nil) { + masterFeedViewController.discloseFeed(feed, animated: animated) { completion?() } } @@ -952,11 +953,15 @@ extension SceneCoordinator: UINavigationControllerDelegate { } // If we are showing the Feeds and only the feeds start clearing stuff - if viewController === masterFeedViewController && !isThreePanelMode { + if viewController === masterFeedViewController && !isThreePanelMode && !isTimelineViewControllerPending { activityManager.invalidateCurrentActivities() selectFeed(nil) return } + + if viewController is MasterTimelineViewController { + isTimelineViewControllerPending = false + } // If we are using a phone and navigate away from the detail, clear up the article resources (including activity). // Don't clear it if we have pushed an ArticleViewController, but don't yet see it on the navigation stack. @@ -1473,6 +1478,9 @@ private extension SceneCoordinator { // MARK: Double Split func installTimelineControllerIfNecessary(animated: Bool) { + + isTimelineViewControllerPending = true + if navControllerForTimeline().viewControllers.filter({ $0 is MasterTimelineViewController }).count < 1 { masterTimelineViewController = UIStoryboard.main.instantiateController(ofType: MasterTimelineViewController.self) masterTimelineViewController!.coordinator = self @@ -1654,7 +1662,7 @@ private extension SceneCoordinator { return } if let feed = feedNode.representedObject as? Feed { - discloseFeed(feed) + discloseFeed(feed, animated: false) } } @@ -1663,7 +1671,7 @@ private extension SceneCoordinator { return } - discloseFeed(feedNode.representedObject as! Feed) { + discloseFeed(feedNode.representedObject as! Feed, animated: false) { guard let articleID = userInfo?[DeepLinkKey.articleID.rawValue] as? String else { return } if let article = self.articles.first(where: { $0.articleID == articleID }) { diff --git a/iOS/Settings/AboutViewController.swift b/iOS/Settings/AboutViewController.swift index a472efd63..59fc79207 100644 --- a/iOS/Settings/AboutViewController.swift +++ b/iOS/Settings/AboutViewController.swift @@ -33,8 +33,11 @@ class AboutViewController: UITableViewController { buildLabel.numberOfLines = 0 buildLabel.sizeToFit() buildLabel.translatesAutoresizingMaskIntoConstraints = false - tableView.tableFooterView = buildLabel + let wrapperView = UIView(frame: CGRect(x: 0, y: 0, width: buildLabel.frame.width, height: buildLabel.frame.height + 10.0)) + wrapperView.translatesAutoresizingMaskIntoConstraints = false + wrapperView.addSubview(buildLabel) + tableView.tableFooterView = wrapperView } override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { diff --git a/iOS/Settings/Settings.storyboard b/iOS/Settings/Settings.storyboard index c8497b2e5..a49be81f1 100644 --- a/iOS/Settings/Settings.storyboard +++ b/iOS/Settings/Settings.storyboard @@ -57,10 +57,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -90,7 +169,7 @@ - + @@ -120,7 +199,7 @@ - + @@ -151,72 +230,10 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -233,7 +250,7 @@ - + @@ -250,7 +267,7 @@ - + @@ -267,7 +284,7 @@ - + @@ -284,7 +301,7 @@ - + @@ -301,7 +318,7 @@ - + @@ -317,23 +334,6 @@ - - - - - - - - - - - diff --git a/iOS/Settings/SettingsViewController.swift b/iOS/Settings/SettingsViewController.swift index cab947682..b7c926536 100644 --- a/iOS/Settings/SettingsViewController.swift +++ b/iOS/Settings/SettingsViewController.swift @@ -66,7 +66,11 @@ class SettingsViewController: UITableViewController { buildLabel.text = "\(Bundle.main.appName) v \(Bundle.main.versionNumber) (Build \(Bundle.main.buildNumber))" buildLabel.sizeToFit() buildLabel.translatesAutoresizingMaskIntoConstraints = false - tableView.tableFooterView = buildLabel + + let wrapperView = UIView(frame: CGRect(x: 0, y: 0, width: buildLabel.frame.width, height: buildLabel.frame.height + 10.0)) + wrapperView.translatesAutoresizingMaskIntoConstraints = false + wrapperView.addSubview(buildLabel) + tableView.tableFooterView = wrapperView } @@ -81,7 +85,7 @@ class SettingsViewController: UITableViewController { switch section { case 1: return AccountManager.shared.accounts.count + 1 - case 4: + case 2: let defaultNumberOfRows = super.tableView(tableView, numberOfRowsInSection: section) if AccountManager.shared.activeAccounts.isEmpty || AccountManager.shared.anyAccountHasFeedWithURL(appNewsURLString) { return defaultNumberOfRows - 1 @@ -135,7 +139,7 @@ class SettingsViewController: UITableViewController { controller.account = sortedAccounts[indexPath.row] self.navigationController?.pushViewController(controller, animated: true) } - case 3: + case 2: switch indexPath.row { case 0: let timeline = UIStoryboard.settings.instantiateController(ofType: RefreshIntervalViewController.self) @@ -152,6 +156,9 @@ class SettingsViewController: UITableViewController { let sourceRect = tableView.rectForRow(at: indexPath) exportOPML(sourceView: sourceView, sourceRect: sourceRect) } + case 3: + addFeed() + tableView.selectRow(at: nil, animated: true, scrollPosition: .none) default: break } @@ -175,9 +182,6 @@ class SettingsViewController: UITableViewController { case 5: openURL("https://github.com/brentsimmons/NetNewsWire/tree/master/Technotes") tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - case 6: - addFeed() - tableView.selectRow(at: nil, animated: true, scrollPosition: .none) default: break } @@ -264,7 +268,15 @@ extension SettingsViewController: UIDocumentPickerDelegate { func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { for url in urls { - opmlAccount?.importOPML(url) { result in} + opmlAccount?.importOPML(url) { result in + switch result { + case .success: + break + case .failure(let error): + let title = NSLocalizedString("Import Failed", comment: "Import Failed") + self.presentError(title: title, message: error.localizedDescription) + } + } } }