From e4a3ad5a30159bdd56084391bc645dfca50f34da Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Fri, 19 Feb 2021 10:41:43 -0500 Subject: [PATCH 01/17] WIP: Add RSS support to Brave Today --- BraveShared/BraveStrings.swift | 2 +- Client.xcodeproj/project.pbxproj | 60 ++++- .../xcshareddata/swiftpm/Package.resolved | 9 + Client/Assets/About/Licenses.html | 32 +++ .../Composer/FeedDataSource+RSS.swift | 161 +++++++++++ .../Brave Today/Composer/FeedDataSource.swift | 72 ++++- ...eTodayAddSourceResultsViewController.swift | 114 ++++++++ .../BraveTodayAddSourceViewController.swift | 254 ++++++++++++++++++ .../BraveTodayDebugSettingsController.swift | 10 + .../BraveTodaySettingsViewController.swift | 38 ++- .../Frontend/Settings/SettingsRowViews.swift | 3 +- .../Model.xcdatamodeld/.xccurrentversion | 2 +- .../Model11.xcdatamodel/contents | 123 +++++++++ Data/models/RSSFeedSource.swift | 52 ++++ 14 files changed, 921 insertions(+), 11 deletions(-) create mode 100644 Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift create mode 100644 Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift create mode 100644 Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift rename Client/Frontend/Settings/{ => Brave Today}/BraveTodayDebugSettingsController.swift (90%) rename Client/Frontend/Settings/{ => Brave Today}/BraveTodaySettingsViewController.swift (66%) create mode 100644 Data/models/Model.xcdatamodeld/Model11.xcdatamodel/contents create mode 100644 Data/models/RSSFeedSource.swift diff --git a/BraveShared/BraveStrings.swift b/BraveShared/BraveStrings.swift index 2b69842f9ed..3273ca082af 100644 --- a/BraveShared/BraveStrings.swift +++ b/BraveShared/BraveStrings.swift @@ -1703,7 +1703,7 @@ extension Strings { public static let settingsSourceHeaderTitle = NSLocalizedString( "today.settingsSourceHeaderTitle", bundle: .braveShared, - value: "Sources", + value: "Default Sources", comment: "" ) public static let resetSourceSettingsButtonTitle = NSLocalizedString( diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index 3b4a5ae38db..df3d0edc0be 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -366,6 +366,8 @@ 27B68DE525C48F36002D0826 /* BraveRewards.xcframework in Copy Frameworks */ = {isa = PBXBuildFile; fileRef = 27B68DD725C48EE9002D0826 /* BraveRewards.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 27B68DF025C48F39002D0826 /* MaterialComponents.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 27B68DD625C48EE9002D0826 /* MaterialComponents.xcframework */; }; 27B68DF125C48F39002D0826 /* MaterialComponents.xcframework in Copy Frameworks */ = {isa = PBXBuildFile; fileRef = 27B68DD625C48EE9002D0826 /* MaterialComponents.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 27B68E5D25C88CA7002D0826 /* FeedKit in Frameworks */ = {isa = PBXBuildFile; productRef = 27B68E5C25C88CA7002D0826 /* FeedKit */; }; + 27B68E9425C8911D002D0826 /* BraveTodayAddSourceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27B68E9325C8911D002D0826 /* BraveTodayAddSourceViewController.swift */; }; 27C405E3242559AE00347246 /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 288A2D861AB8B3260023ABC3 /* Shared.framework */; }; 27C405E924255A2B00347246 /* BraveShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DE7688420B3456C00FF5533 /* BraveShared.framework */; }; 27C461DE211B76500088A441 /* ShieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C461DD211B76500088A441 /* ShieldsView.swift */; }; @@ -398,6 +400,9 @@ 27FD2CAB2146C31C00A5A779 /* RequestDesktopSiteActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FD2CA12146C31C00A5A779 /* RequestDesktopSiteActivity.swift */; }; 27FD2CAC2146C31C00A5A779 /* FindInPageActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FD2CA92146C31C00A5A779 /* FindInPageActivity.swift */; }; 27FD2CAD2146C31C00A5A779 /* AddToFavoritesActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FD2CAA2146C31C00A5A779 /* AddToFavoritesActivity.swift */; }; + 27FD3F7825C8B0E700696156 /* BraveTodayAddSourceResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FD3F7725C8B0E700696156 /* BraveTodayAddSourceResultsViewController.swift */; }; + 27FD3FA625C8CE4B00696156 /* RSSFeedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FD3FA525C8CE4B00696156 /* RSSFeedSource.swift */; }; + 27FD3FBA25C8D20200696156 /* FeedDataSource+RSS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FD3FB925C8D20200696156 /* FeedDataSource+RSS.swift */; }; 28126F481C2F948E006466CC /* SQLiteBookmarksHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28126F471C2F948E006466CC /* SQLiteBookmarksHelpers.swift */; }; 28126F6E1C2F94F9006466CC /* SQLiteBookmarksModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28126F6D1C2F94F9006466CC /* SQLiteBookmarksModel.swift */; }; 2816F0001B33E05400522243 /* UIConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2816EFFF1B33E05400522243 /* UIConstants.swift */; }; @@ -1289,6 +1294,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 27FD3F9225C8B91500696156 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; 2F3444EC1AB2378200FD9731 /* Copy Files */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -1825,6 +1840,8 @@ 27B3DDD424AF98EA0006A7ED /* FeedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItem.swift; sourceTree = ""; }; 27B68DD625C48EE9002D0826 /* MaterialComponents.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MaterialComponents.xcframework; path = "node_modules/brave-core-ios/MaterialComponents.xcframework"; sourceTree = ""; }; 27B68DD725C48EE9002D0826 /* BraveRewards.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BraveRewards.xcframework; path = "node_modules/brave-core-ios/BraveRewards.xcframework"; sourceTree = ""; }; + 27B68E8725C88F28002D0826 /* Model11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model11.xcdatamodel; sourceTree = ""; }; + 27B68E9325C8911D002D0826 /* BraveTodayAddSourceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BraveTodayAddSourceViewController.swift; sourceTree = ""; }; 27C461DD211B76500088A441 /* ShieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShieldsView.swift; sourceTree = ""; }; 27C626CA25BA198700418F40 /* WalletTransferExpiredViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletTransferExpiredViewController.swift; sourceTree = ""; }; 27C647762550AE16006D72FC /* WalletTransferCompleteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletTransferCompleteViewController.swift; sourceTree = ""; }; @@ -1857,6 +1874,9 @@ 27FD2CA12146C31C00A5A779 /* RequestDesktopSiteActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestDesktopSiteActivity.swift; sourceTree = ""; }; 27FD2CA92146C31C00A5A779 /* FindInPageActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FindInPageActivity.swift; sourceTree = ""; }; 27FD2CAA2146C31C00A5A779 /* AddToFavoritesActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddToFavoritesActivity.swift; sourceTree = ""; }; + 27FD3F7725C8B0E700696156 /* BraveTodayAddSourceResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BraveTodayAddSourceResultsViewController.swift; sourceTree = ""; }; + 27FD3FA525C8CE4B00696156 /* RSSFeedSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSFeedSource.swift; sourceTree = ""; }; + 27FD3FB925C8D20200696156 /* FeedDataSource+RSS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedDataSource+RSS.swift"; sourceTree = ""; }; 28126F471C2F948E006466CC /* SQLiteBookmarksHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SQLiteBookmarksHelpers.swift; sourceTree = ""; }; 28126F6D1C2F94F9006466CC /* SQLiteBookmarksModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SQLiteBookmarksModel.swift; sourceTree = ""; }; 2816EFFF1B33E05400522243 /* UIConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIConstants.swift; sourceTree = ""; }; @@ -2706,6 +2726,7 @@ 277568F525ACF92400C129AF /* SwiftyJSON in Frameworks */, 277568F725ACF92400C129AF /* Lottie in Frameworks */, 277568EB25ACF92400C129AF /* ZIPFoundation in Frameworks */, + 27B68E5D25C88CA7002D0826 /* FeedKit in Frameworks */, 277568ED25ACF92400C129AF /* SnapKit in Frameworks */, 277568EF25ACF92400C129AF /* ObjcExceptionBridging in Frameworks */, 27756BEA25AD14B600C129AF /* GCDWebServers in Frameworks */, @@ -3797,6 +3818,7 @@ isa = PBXGroup; children = ( 27A1AC0B2485A1C200344503 /* FeedDataSource.swift */, + 27FD3FB925C8D20200696156 /* FeedDataSource+RSS.swift */, 27B3DDD424AF98EA0006A7ED /* FeedItem.swift */, 274398E124E4827800E79605 /* FeedCard.swift */, 274398E424E4829900E79605 /* FeedFillStrategy.swift */, @@ -3804,6 +3826,17 @@ path = Composer; sourceTree = ""; }; + 27B68E9225C8910D002D0826 /* Brave Today */ = { + isa = PBXGroup; + children = ( + 27D67CEC24D07EB800066D83 /* BraveTodaySettingsViewController.swift */, + 273FCB9925A7BC5500F279B5 /* BraveTodayDebugSettingsController.swift */, + 27B68E9325C8911D002D0826 /* BraveTodayAddSourceViewController.swift */, + 27FD3F7725C8B0E700696156 /* BraveTodayAddSourceResultsViewController.swift */, + ); + path = "Brave Today"; + sourceTree = ""; + }; 27C647A42551F071006D72FC /* QA */ = { isa = PBXGroup; children = ( @@ -3933,6 +3966,7 @@ 2F44FC551A9E83E200FD20CC /* Settings */ = { isa = PBXGroup; children = ( + 27B68E9225C8910D002D0826 /* Brave Today */, 2746D26C24A291ED00E38852 /* Rewards Internals */, 0B62EFD11AD63CD100ACB9CD /* Clearables.swift */, 2F44FCCA1A9E972E00FD20CC /* SearchEnginePicker.swift */, @@ -3949,8 +3983,6 @@ 0AEFB84822244135007AF600 /* AdblockDebugMenuTableViewController.swift */, 27D114D32358FBBF00166534 /* BraveRewardsSettingsViewController.swift */, 27D114D52358FCA400166534 /* SettingsRowViews.swift */, - 27D67CEC24D07EB800066D83 /* BraveTodaySettingsViewController.swift */, - 273FCB9925A7BC5500F279B5 /* BraveTodayDebugSettingsController.swift */, 278C700924F96D7000A246C8 /* BraveShieldsAndPrivacySettingsController.swift */, ); path = Settings; @@ -4464,6 +4496,7 @@ 5D1DC54220AE004600905E5A /* TabMO.swift */, 5D1DC54620AE004600905E5A /* Favorite.swift */, 27B3DDCF24AE83710006A7ED /* FeedSourceOverride.swift */, + 27FD3FA525C8CE4B00696156 /* RSSFeedSource.swift */, 5D1DC54720AE004600905E5A /* FaviconMO.swift */, 5D1DC54820AE004600905E5A /* Domain.swift */, 5D1DC54A20AE004600905E5A /* History.swift */, @@ -5585,6 +5618,7 @@ 277568CC25ACF91500C129AF /* Sources */, 277568CD25ACF91500C129AF /* Frameworks */, 277568CE25ACF91500C129AF /* Resources */, + 27FD3F9225C8B91500696156 /* Embed Frameworks */, ); buildRules = ( ); @@ -5603,6 +5637,7 @@ 27756B0E25AD08FA00C129AF /* SDWebImage */, 27756BE925AD14B600C129AF /* GCDWebServers */, 27F4798B25AF94B0004922E4 /* YubiKit */, + 27B68E5C25C88CA7002D0826 /* FeedKit */, ); productName = SPMLibraries; productReference = 277568D025ACF91500C129AF /* SPMLibraries.framework */; @@ -6011,6 +6046,7 @@ 27756B0D25AD08FA00C129AF /* XCRemoteSwiftPackageReference "SDWebImage" */, 27756BE825AD14B600C129AF /* XCRemoteSwiftPackageReference "GCDWebServer" */, 27F4798A25AF94B0004922E4 /* XCRemoteSwiftPackageReference "yubikit-ios" */, + 27B68E5B25C88CA7002D0826 /* XCRemoteSwiftPackageReference "FeedKit" */, ); productRefGroup = F84B21BF1A090F8100AAB793 /* Products */; projectDirPath = ""; @@ -6627,6 +6663,7 @@ 5D1DC55A20AE004600905E5A /* FaviconMO.swift in Sources */, 5D1DC55D20AE004600905E5A /* History.swift in Sources */, 44FA2F3021F8DB1000EFA86A /* DataPreferences.swift in Sources */, + 27FD3FA625C8CE4B00696156 /* RSSFeedSource.swift in Sources */, 5D1DC55620AE004600905E5A /* DataController.swift in Sources */, 5D1DC55520AE004600905E5A /* TabMO.swift in Sources */, 0A53F3E721E6560A0086E80C /* InMemoryDataController.swift in Sources */, @@ -6915,6 +6952,7 @@ 0A764F31230EE5CA003A1D9B /* OnboardingState.swift in Sources */, D8D33A7D1FBD080300A20A28 /* SnapKitExtensions.swift in Sources */, 27AC7CFA24C77EBC00441317 /* FeedActionAlertView.swift in Sources */, + 27FD3F7825C8B0E700696156 /* BraveTodayAddSourceResultsViewController.swift in Sources */, 27E0652824CB6AE300134946 /* BraveTodayErrorView.swift in Sources */, E650755C1E37F747006961AC /* Swizzling.m in Sources */, 2FE63DB8258BCC29004B219D /* BrowserViewController+OpenSearch.swift in Sources */, @@ -6985,6 +7023,7 @@ E6927EC01C7B6FB800D03F75 /* ErrorToast.swift in Sources */, 27C647832550AF34006D72FC /* WalletTransferCompleteView.swift in Sources */, 4422D4B821BFFB7600BF1855 /* histogram.cc in Sources */, + 27FD3FBA25C8D20200696156 /* FeedDataSource+RSS.swift in Sources */, 0A0D3D3921A4BD0600BEE65B /* SafeBrowsing.swift in Sources */, 278C6FFF24F6EA3700A246C8 /* ReportBrokenSiteView.swift in Sources */, 27676D472555F34D00BC955A /* BraveRewardsStatusView.swift in Sources */, @@ -7155,6 +7194,7 @@ 0A1E84462190A57F0042F782 /* SyncAddDeviceViewController.swift in Sources */, 4422D42C21BFCF8900BF1855 /* HttpsEverywhere.cpp in Sources */, 4422D50021BFFB7600BF1855 /* db_iter.cc in Sources */, + 27B68E9425C8911D002D0826 /* BraveTodayAddSourceViewController.swift in Sources */, D0625C98208E87F10081F3B2 /* DownloadQueue.swift in Sources */, 4422D54621BFFB7E00BF1855 /* rune.cc in Sources */, EB11A1052044A90E0018F749 /* TrackingProtectionPageStats.swift in Sources */, @@ -12463,6 +12503,14 @@ revision = 16bbfa795eef8c8c0715e49ab3ea75d816907661; }; }; + 27B68E5B25C88CA7002D0826 /* XCRemoteSwiftPackageReference "FeedKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/nmdias/FeedKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 9.1.2; + }; + }; 27F4798A25AF94B0004922E4 /* XCRemoteSwiftPackageReference "yubikit-ios" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Yubico/yubikit-ios"; @@ -12524,6 +12572,11 @@ package = 27756BE825AD14B600C129AF /* XCRemoteSwiftPackageReference "GCDWebServer" */; productName = GCDWebServers; }; + 27B68E5C25C88CA7002D0826 /* FeedKit */ = { + isa = XCSwiftPackageProductDependency; + package = 27B68E5B25C88CA7002D0826 /* XCRemoteSwiftPackageReference "FeedKit" */; + productName = FeedKit; + }; 27F4798B25AF94B0004922E4 /* YubiKit */ = { isa = XCSwiftPackageProductDependency; package = 27F4798A25AF94B0004922E4 /* XCRemoteSwiftPackageReference "yubikit-ios" */; @@ -12535,6 +12588,7 @@ 5D1DC54C20AE004600905E5A /* Model.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + 27B68E8725C88F28002D0826 /* Model11.xcdatamodel */, 0A977C97249B7B380004216A /* Model10.xcdatamodel */, 27D922B221C9830F00345BF3 /* Model9.xcdatamodel */, 0A9B6A3420E6453400712BC9 /* Model8.xcdatamodel */, @@ -12546,7 +12600,7 @@ 5D1DC55220AE004600905E5A /* Model5.xcdatamodel */, 5D1DC55320AE004600905E5A /* Model.xcdatamodel */, ); - currentVersion = 0A977C97249B7B380004216A /* Model10.xcdatamodel */; + currentVersion = 27B68E8725C88F28002D0826 /* Model11.xcdatamodel */; path = Model.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d160781461c..e16fe4ae5fc 100644 --- a/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { "object": { "pins": [ + { + "package": "FeedKit", + "repositoryURL": "https://github.com/nmdias/FeedKit.git", + "state": { + "branch": null, + "revision": "68493a33d862c33c9a9f67ec729b3b7df1b20ade", + "version": "9.1.2" + } + }, { "package": "Fuzi", "repositoryURL": "https://github.com/cezheng/Fuzi", diff --git a/Client/Assets/About/Licenses.html b/Client/Assets/About/Licenses.html index 6e217382062..c66839aaa06 100644 --- a/Client/Assets/About/Licenses.html +++ b/Client/Assets/About/Licenses.html @@ -1064,6 +1064,38 @@

Exhibit B - “

-

+
+ + +
+ +
+

MIT License

+ +

Copyright (c) 2016 - 2018 Nuno Manuel Dias

+ +

Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions:

+ +

The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software.

+ +

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE.

+
+
+
+

-

+
diff --git a/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift new file mode 100644 index 00000000000..8c84ed9d3a1 --- /dev/null +++ b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift @@ -0,0 +1,161 @@ +// Copyright 2020 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import Data +import FeedKit +import Fuzi + +struct RSSFeedLocation: Hashable { + var title: String? + var url: URL + + var id: String { + url.absoluteString + } +} + +extension FeedDataSource { + + // MARK: - RSS Sources + + var rssFeedLocations: [RSSFeedLocation] { + RSSFeedSource.all().compactMap { + guard let url = URL(string: $0.feedUrl) else { return nil } + return RSSFeedLocation(title: $0.title, url: url) + } + } + + /// Add a users custom RSS feed to the list of sources + @discardableResult + func addRSSFeedLocation(_ location: RSSFeedLocation) -> Bool { + let feedUrl = location.url.absoluteString + if let _ = RSSFeedSource.get(with: feedUrl) { + return false + } + RSSFeedSource.insert(title: location.title, + feedUrl: feedUrl) + setNeedsReloadCards() + return true + } + + /// Remove a users custom RSS feed to the list of sources + func removeRSSFeed(with url: URL) { + let feedUrl = url.absoluteString + if RSSFeedSource.get(with: feedUrl) == nil { + return + } + RSSFeedSource.delete(with: url.absoluteString) + setNeedsReloadCards() + } + + /// Whether or not an RSS feed is currently enabled + /// + /// - note: RSS Feeds are enabled by default since they are added by the user + func isRSSFeedEnabled(_ location: RSSFeedLocation) -> Bool { + FeedSourceOverride.get(fromId: location.id)?.enabled ?? true + } + + /// Toggle an RSS feed enabled state + func toggleRSSFeedEnabled(_ location: RSSFeedLocation, enabled: Bool) { + FeedSourceOverride.setEnabled(forId: location.id, enabled: enabled) + setNeedsReloadCards() + } +} + +extension FeedItem.Content { + init?(from feedItem: JSONFeedItem) { + return nil + } + init?(from feedItem: AtomFeedEntry, location: RSSFeedLocation) { + guard let publishTime = feedItem.published, + let href = feedItem.links?.first?.attributes?.href, + let url = URL(string: href), + let title = feedItem.title else { + return nil + } + var description = "" + var imageURL = feedItem.media?.mediaThumbnails?.first?.attributes?.url?.asURL + if feedItem.summary?.attributes?.type == "text" { + description = feedItem.summary?.value ?? "" + } else if feedItem.content?.attributes?.type == "html", let html = feedItem.content?.value { + // Find one in description? + let doc = try? HTMLDocument(string: html) + if imageURL == nil { + imageURL = doc?.firstChild(xpath: "//img[@src]")?.attr("src")?.asURL + } + if let text = doc?.root?.childNodes(ofTypes: [.Text, .Element]).map({ node in + node.stringValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\n", with: " ") + }).joined(separator: " ") { + description = text + } + } + self.init( + publishTime: publishTime, + url: url, + imageURL: imageURL, + title: title, + description: description, + contentType: .article, + publisherID: location.id, + urlHash: url.absoluteString, + baseScore: 0, + offersCategory: nil + ) + } + init?(from feedItem: RSSFeedItem, location: RSSFeedLocation) { + guard let publishTime = feedItem.pubDate, + let href = feedItem.link, + let url = URL(string: href), + let title = feedItem.title else { + return nil + } + var description = "" + var imageURL = feedItem.media?.mediaThumbnails?.first?.attributes?.url?.asURL + if let html = feedItem.description { + // Find one in description? + let doc = try? HTMLDocument(string: html) + if imageURL == nil { + imageURL = doc?.firstChild(xpath: "//img[@src]")?.attr("src")?.asURL + } + if let text = doc?.root?.childNodes(ofTypes: [.Text, .Element]).map({ node in + node.stringValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\n", with: " ") + }).joined(separator: " ") { + description = text + } + } + self.init( + publishTime: publishTime, + url: url, + imageURL: imageURL, + title: title, + description: description, + contentType: .article, + publisherID: location.id, + urlHash: url.absoluteString, + baseScore: 0, + offersCategory: nil + ) + } +} +extension FeedItem.Source { + init?(from feed: FeedKit.Feed, location: RSSFeedLocation) { + let id = location.id + switch feed { + case .atom(let feed): + guard let title = feed.title else { return nil } + self.init(id: id, isDefault: true, category: "", name: title) + case .rss(let feed): + guard let title = feed.title else { return nil } + self.init(id: id, isDefault: true, category: "", name: title) + case .json(let feed): + return nil + } + } +} diff --git a/Client/Frontend/Brave Today/Composer/FeedDataSource.swift b/Client/Frontend/Brave Today/Composer/FeedDataSource.swift index 7e4b6b148e0..920c53c9833 100644 --- a/Client/Frontend/Brave Today/Composer/FeedDataSource.swift +++ b/Client/Frontend/Brave Today/Composer/FeedDataSource.swift @@ -8,6 +8,7 @@ import BraveUI import Data import Shared import BraveShared +import FeedKit // Named `logger` because we are using math function `log` private let logger = Logger.browserLogger @@ -302,6 +303,54 @@ class FeedDataSource { } } + private func loadRSSFeeds() -> Deferred> { + let deferred = Deferred>(value: nil, defaultQueue: .main) + var items: [(FeedItem.Source, [FeedItem.Content])] = [] + let group = DispatchGroup() + for feedLocation in rssFeedLocations { + group.enter() + let parser = FeedParser(URL: feedLocation.url) + parser.parseAsync { result in + if case .success(let feed) = result, case .atom(let atomFeed) = feed, let entries = atomFeed.entries { + if let source = FeedItem.Source(from: feed, location: feedLocation) { + let feedItems = entries.compactMap { + FeedItem.Content(from: $0, location: feedLocation) + } + items.append((source, feedItems)) + } + } + if case .success(let feed) = result, case .rss(let rssFeed) = feed, let entries = rssFeed.items { + if let source = FeedItem.Source(from: feed, location: feedLocation) { + let feedItems = entries.compactMap { + FeedItem.Content(from: $0, location: feedLocation) + } + items.append((source, feedItems)) + } + } + group.leave() + } + } + group.notify(queue: .main) { + let sorted = items.map { + ($0.0, self.scored(rssItems: $0.1)) + } + deferred.fill(.success(sorted)) + } + return deferred + } + + private func scored(rssItems: [FeedItem.Content]) -> [FeedItem.Content] { + var varianceBySource: [String: Double] = [:] + return rssItems.map { + var content = $0 + let recency = log(max(1, -content.publishTime.timeIntervalSinceNow)) + let variance = (varianceBySource[content.publisherID] ?? 1.0) * 2.0 + varianceBySource[content.publisherID] = variance + content.baseScore = recency * variance + return content + } + } + /// Whether or not we should load content or just use what's in `state`. /// /// If the data source is already loading, returns `false` @@ -346,7 +395,23 @@ class FeedDataSource { case (.success(let sources), .success(let items)): self.sources = sources self.items = items - self.reloadCards(from: items, sources: sources, completion: completion) + print("Regular feed items count: \(items.count)") + print("Regular sources count: \(sources.count)") + self.loadRSSFeeds().uponQueue(.main) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let feeds): + self.sources.append(contentsOf: feeds.map(\.0)) + for items in feeds.map(\.1) { + self.items.append(contentsOf: items) + } + print("Post RSS feed items count: \(self.items.count)") + print("Post RSS sources count: \(self.sources.count)") + case .failure(_): + break + } + self.reloadCards(from: self.items, sources: self.sources, completion: completion) + } } } } @@ -430,6 +495,11 @@ class FeedDataSource { /// Whether or not cards need to be reloaded next time we attempt to request state data private var needsReloadCards = false + /// Notify the feed data source that it needs to reload cards next time we request state data + func setNeedsReloadCards() { + needsReloadCards = true + } + /// Scores and generates cards from a set of items and sources private func reloadCards( from items: [FeedItem.Content], diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift new file mode 100644 index 00000000000..db47ed7de9b --- /dev/null +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift @@ -0,0 +1,114 @@ +// Copyright 2020 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import BraveUI +import Shared + +class BraveTodayAddSourceResultsViewController: UITableViewController { + + let feedDataSource: FeedDataSource + let searchedURL: URL + let locations: [RSSFeedLocation] + var sourcesAdded: ((Set) -> Void)? + + private var selectedLocations: Set + + init(dataSource: FeedDataSource, + searchedURL: URL, + rssFeedLocations: [RSSFeedLocation], + sourcesAdded: ((Set) -> Void)? + ) { + self.feedDataSource = dataSource + self.searchedURL = searchedURL + self.locations = rssFeedLocations + self.selectedLocations = Set(rssFeedLocations) + self.sourcesAdded = sourcesAdded + + if #available(iOS 13.0, *) { + super.init(style: .insetGrouped) + } else { + super.init(style: .grouped) + } + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError() + } + + private lazy var doneButton = UIBarButtonItem( + title: "Add", + style: .done, + target: self, + action: #selector(tappedAdd) + ) + + override func viewDidLoad() { + super.viewDidLoad() + + title = searchedURL.baseDomain + + navigationItem.largeTitleDisplayMode = .never + navigationItem.rightBarButtonItem = doneButton + + tableView.register(FeedLocationCell.self) + } + + @objc private func tappedAdd() { + // Add selected sources to feed + for location in selectedLocations { + feedDataSource.addRSSFeedLocation(location) + } + sourcesAdded?(selectedLocations) + dismiss(animated: true) + } + + // MARK: - UITableViewDelegate + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if let location = locations[safe: indexPath.row], + let cell = tableView.cellForRow(at: indexPath) as? FeedLocationCell { + if selectedLocations.remove(location) == nil { + selectedLocations.insert(location) + } + cell.accessoryType = selectedLocations.contains(location) ? .checkmark : .none + doneButton.isEnabled = !selectedLocations.isEmpty + } + tableView.deselectRow(at: indexPath, animated: true) + } + + // MARK: - UITableViewDataSource + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let location = locations[safe: indexPath.row] else { + assertionFailure() + return UITableViewCell() + } + let cell = tableView.dequeueReusableCell(for: indexPath) as FeedLocationCell + cell.textLabel?.text = location.title + cell.detailTextLabel?.text = location.url.absoluteString + cell.accessoryType = selectedLocations.contains(location) ? .checkmark : .none + return cell + } + + override func numberOfSections(in tableView: UITableView) -> Int { + 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + locations.count + } +} + +private class FeedLocationCell: UITableViewCell, TableViewReusable { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) + } + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError() + } +} diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift new file mode 100644 index 00000000000..6b919d7234b --- /dev/null +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift @@ -0,0 +1,254 @@ +// Copyright 2020 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import Shared +import BraveShared +import BraveUI +import Fuzi +import FeedKit + +class BraveTodayAddSourceViewController: UITableViewController { + + let feedDataSource: FeedDataSource + var sourcesAdded: ((Set) -> Void)? + + private var isLoading: Bool = false + + init(dataSource: FeedDataSource) { + self.feedDataSource = dataSource + if #available(iOS 13.0, *) { + super.init(style: .insetGrouped) + } else { + super.init(style: .grouped) + } + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError() + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Add Source" + + navigationController?.navigationBar.prefersLargeTitles = true + navigationItem.largeTitleDisplayMode = .always + navigationItem.backButtonTitle = "" + navigationItem.leftBarButtonItem = .init(barButtonSystemItem: .cancel, target: self, action: #selector(tappedCancel)) + + textField.addTarget(self, action: #selector(textFieldTextChanged), for: .editingChanged) + textField.delegate = self + + tableView.register(FeedSearchCellClass.self) + tableView.register(CenteredButtonCell.self) + tableView.tableHeaderView = UIView(frame: .init(x: 0, y: 0, width: 0, height: 10)) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + textField.becomeFirstResponder() + } + + @objc private func tappedCancel() { + dismiss(animated: true, completion: nil) + } + + @objc private func textFieldTextChanged() { + if let cell = tableView.cellForRow(at: IndexPath(row: 1, section: 0)) as? CenteredButtonCell { + // Update the color of the search row when text field is non empty + cell.tintColor = isSearchEnabled && !isLoading ? BraveUX.braveOrange : Colors.grey500 + } + } + + private let session: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 5 + return URLSession(configuration: configuration, delegate: nil, delegateQueue: .main) + }() + + private func displayError() { + let alert = UIAlertController(title: "Failed to Add Source", message: "Brave could not find a feed at the url provided.", preferredStyle: .alert) + alert.addAction(.init(title: Strings.OKString, style: .default, handler: nil)) + present(alert, animated: true) + } + + private func searchPageForFeeds() { + guard var text = textField.text else { return } + if text.hasPrefix("feed:"), let range = text.range(of: "feed:") { + text.replaceSubrange(range, with: []) + } + guard let url = URIFixup.getURL(text) else { return } + downloadPageData(for: url) { [weak self] data in + guard let self = self else { return } + if let data = data { + let resultsController = BraveTodayAddSourceResultsViewController( + dataSource: self.feedDataSource, + searchedURL: url, + rssFeedLocations: data, + sourcesAdded: self.sourcesAdded + ) + self.navigationController?.pushViewController(resultsController, animated: true) + } else { + self.displayError() + } + } + } + + private var pageTask: URLSessionDataTask? + private func downloadPageData(for url: URL, _ completion: @escaping ([RSSFeedLocation]?) -> Void) { + pageTask = session.dataTask(with: url) { [weak self] (data, response, error) in + guard let self = self else { return } + if let error = error as? URLError, error.code == .cancelled { + return + } + guard let data = data, error == nil, let root = try? HTMLDocument(data: data) else { + completion(nil) + return + } + let parser = FeedParser(data: data) + let result = parser.parse() + if case .success(let feed) = result { + // User provided a direct feed + var title: String? + switch feed { + case .atom(let atom): + title = atom.title + case .json(let json): + title = json.title + case .rss(let rss): + title = rss.title + } + completion([.init(title: title, url: url)]) + return + } + // Ensure page is reloaded to final landing page before looking for + // favicons + var reloadUrl: URL? + for meta in root.xpath("//head/meta") { + if let refresh = meta["http-equiv"], refresh == "Refresh", + let content = meta["content"], + let index = content.range(of: "URL="), + let url = NSURL(string: String(content.suffix(from: index.upperBound))) { + reloadUrl = url as URL + } + } + + if let url = reloadUrl { + self.downloadPageData(for: url, completion) + return + } + + var feeds: [RSSFeedLocation] = [] + let xpath = "//head//link[contains(@type, 'application/rss+xml') or contains(@type, 'application/atom+xml')]" + for link in root.xpath(xpath) { + guard let href = link["href"], let url = URL(string: href, relativeTo: url) else { continue } + feeds.append(.init(title: link["title"], url: url)) + } + completion(feeds) + } + pageTask?.resume() + } + + private let textField = UITextField().then { + $0.attributedPlaceholder = NSAttributedString( + string: "Feed or Site URL", + attributes: [.foregroundColor: UIColor.lightGray] + ) + $0.font = .preferredFont(forTextStyle: .body) + $0.keyboardType = .URL + $0.autocorrectionType = .no + $0.autocapitalizationType = .none + $0.returnKeyType = .search + } + + private var isSearchEnabled: Bool { + if let text = textField.text { + return URIFixup.getURL(text) != nil + } + return false + } + + // MARK: - UITableViewDelegate + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if indexPath.section == 0 && indexPath.row == 1, isSearchEnabled, !isLoading { + searchPageForFeeds() + } + tableView.deselectRow(at: indexPath, animated: true) + } + + override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { + if indexPath.row == 1 { + return isSearchEnabled && !isLoading + } + return false + } + + // MARK: - UITableViewDataSource + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch indexPath.row { + case 0: + let cell = tableView.dequeueReusableCell(for: indexPath) as FeedSearchCellClass + cell.textField = textField + return cell + case 1: + let cell = tableView.dequeueReusableCell(for: indexPath) as CenteredButtonCell + cell.textLabel?.text = "Search" + cell.tintColor = isSearchEnabled && !isLoading ? BraveUX.braveOrange : Colors.grey500 + return cell + default: + fatalError("No cell available for index path: \(indexPath)") + } + } + + override func numberOfSections(in tableView: UITableView) -> Int { + 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + 2 + } +} + +extension BraveTodayAddSourceViewController: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if isSearchEnabled { + textField.resignFirstResponder() + searchPageForFeeds() + return true + } + return false + } +} + +private class FeedSearchCellClass: UITableViewCell, TableViewReusable { + var textField: UITextField? { + willSet { + textField?.removeFromSuperview() + } + didSet { + if let textField = textField { + contentView.addSubview(textField) + textField.snp.makeConstraints { + $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)) + $0.height.greaterThanOrEqualTo(44) + } + } + } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError() + } +} diff --git a/Client/Frontend/Settings/BraveTodayDebugSettingsController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayDebugSettingsController.swift similarity index 90% rename from Client/Frontend/Settings/BraveTodayDebugSettingsController.swift rename to Client/Frontend/Settings/Brave Today/BraveTodayDebugSettingsController.swift index 81ede28ee99..ff2e70afb94 100644 --- a/Client/Frontend/Settings/BraveTodayDebugSettingsController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayDebugSettingsController.swift @@ -96,6 +96,16 @@ class BraveTodayDebugSettingsController: TableViewController { rows: [ Row(text: "State", detailText: feedDataSource.description(of: feedDataSource.state)), ] + feedDataSource.detailRows(for: feedDataSource.state) + ), + .init( + rows: [ + Row(text: "Disable All Default Sources", selection: { [unowned self] in + let categories = Set(self.feedDataSource.sources.map(\.category)) + for category in categories where !category.isEmpty { + self.feedDataSource.toggleCategory(category, enabled: false) + } + }, cellClass: ButtonCell.self) + ] ) ] } diff --git a/Client/Frontend/Settings/BraveTodaySettingsViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodaySettingsViewController.swift similarity index 66% rename from Client/Frontend/Settings/BraveTodaySettingsViewController.swift rename to Client/Frontend/Settings/Brave Today/BraveTodaySettingsViewController.swift index f0032a45c53..6eaa9acf376 100644 --- a/Client/Frontend/Settings/BraveTodaySettingsViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodaySettingsViewController.swift @@ -7,6 +7,7 @@ import UIKit import Static import Shared import BraveShared +import Data /// Displays relevant Brave Today settings such as toggling the feature on/off, and selecting sources /// @@ -40,11 +41,43 @@ class BraveTodaySettingsViewController: TableViewController { navigationItem.rightBarButtonItem = .init(barButtonSystemItem: .done, target: self, action: #selector(tappedDone)) } + reloadSections() + } + + private func reloadSections() { dataSource.sections = [ .init( rows: [ .boolRow(title: Strings.BraveToday.isEnabledToggleLabel, option: Preferences.BraveToday.isEnabled) ] + ), + .init( + header: .title("Your Sources"), + rows: feedDataSource.rssFeedLocations.map { location in + let enabled = self.feedDataSource.isRSSFeedEnabled(location) + return Row( + text: location.title, + detailText: location.url.absoluteString, + accessory: .switchToggle(value: enabled, { [unowned self] newValue in + self.feedDataSource.toggleRSSFeedEnabled(location, enabled: newValue) + }), + cellClass: SubtitleCell.self, + editActions: [.init(title: "Delete", style: .destructive, selection: { [unowned self] indexPath in + guard let location = feedDataSource.rssFeedLocations[safe: indexPath.row] else { return } + self.feedDataSource.removeRSSFeed(with: location.url) + dataSource.sections[1].rows.remove(at: indexPath.row) + })] + ) + } + [ + Row(text: "Add Source", selection: { [unowned self] in + let controller = BraveTodayAddSourceViewController(dataSource: self.feedDataSource) + controller.sourcesAdded = { [weak self] _ in + self?.reloadSections() + } + let container = UINavigationController(rootViewController: controller) + self.present(container, animated: true) + }, image: nil, accessory: .disclosureIndicator) + ] ) ] @@ -68,10 +101,6 @@ class BraveTodaySettingsViewController: TableViewController { ]) ) } - - if !AppConstants.buildChannel.isPublic { - // TODO: Add debug settings here - } } private var categoryRows: [Row] { @@ -90,6 +119,7 @@ class BraveTodaySettingsViewController: TableViewController { } rows.append(contentsOf: categories + .filter { !$0.isEmpty } .sorted() .map(row(for:)) ) diff --git a/Client/Frontend/Settings/SettingsRowViews.swift b/Client/Frontend/Settings/SettingsRowViews.swift index de075816ee8..d53513e6eac 100644 --- a/Client/Frontend/Settings/SettingsRowViews.swift +++ b/Client/Frontend/Settings/SettingsRowViews.swift @@ -5,6 +5,7 @@ import Foundation import Static import BraveShared +import BraveUI /// The same style switch accessory view as in Static framework, except will not be recreated each time the Cell /// is configured, since it will be stored as is in `Row.Accessory.view` @@ -55,7 +56,7 @@ class MultilineButtonCell: ButtonCell { } } -class CenteredButtonCell: ButtonCell { +class CenteredButtonCell: ButtonCell, TableViewReusable { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) textLabel?.textAlignment = .center diff --git a/Data/models/Model.xcdatamodeld/.xccurrentversion b/Data/models/Model.xcdatamodeld/.xccurrentversion index d02814afbeb..cf2d01ebf9f 100644 --- a/Data/models/Model.xcdatamodeld/.xccurrentversion +++ b/Data/models/Model.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Model10.xcdatamodel + Model11.xcdatamodel diff --git a/Data/models/Model.xcdatamodeld/Model11.xcdatamodel/contents b/Data/models/Model.xcdatamodeld/Model11.xcdatamodel/contents new file mode 100644 index 00000000000..47b91cb0cac --- /dev/null +++ b/Data/models/Model.xcdatamodeld/Model11.xcdatamodel/contents @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Data/models/RSSFeedSource.swift b/Data/models/RSSFeedSource.swift new file mode 100644 index 00000000000..afe25f8e3ee --- /dev/null +++ b/Data/models/RSSFeedSource.swift @@ -0,0 +1,52 @@ +// Copyright 2020 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import CoreData + +public final class RSSFeedSource: NSManagedObject, CRUD { + @NSManaged public var title: String? + @NSManaged public var feedUrl: String + + public class func get(with feedUrl: String) -> RSSFeedSource? { + getInternal(with: feedUrl) + } + + public class func all() -> [RSSFeedSource] { + all() ?? [] + } + + public class func delete(with feedUrl: String) { + deleteInternal(feedUrl: feedUrl, context: .existing(DataController.viewContext)) + } + + public class func insert(title: String?, feedUrl: String) { + insertInternal(title: title, feedUrl: feedUrl, context: .existing(DataController.viewContext)) + } + + class func getInternal(with feedUrl: String, context: NSManagedObjectContext = DataController.viewContext) -> RSSFeedSource? { + let predicate = NSPredicate(format: "\(#keyPath(RSSFeedSource.feedUrl)) == %@", feedUrl) + return first(where: predicate, context: context) + } + + class func insertInternal(title: String?, feedUrl: String, context: WriteContext = .new(inMemory: false)) { + DataController.perform(context: context) { context in + let source = RSSFeedSource(entity: entity(in: context), insertInto: context) + + source.title = title + source.feedUrl = feedUrl + } + } + + class func deleteInternal(feedUrl: String, context: WriteContext = .new(inMemory: false)) { + if let item = getInternal(with: feedUrl, context: DataController.viewContext) { + item.delete(context: context) + } + } + + private class func entity(in context: NSManagedObjectContext) -> NSEntityDescription { + NSEntityDescription.entity(forEntityName: "RSSFeedSource", in: context)! + } +} From 114d35bd441685c548a159f325120dd2bae0216d Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Fri, 5 Feb 2021 15:45:39 -0500 Subject: [PATCH 02/17] Support JSON feeds. Parse relative images URLs correctly --- .../Composer/FeedDataSource+RSS.swift | 69 ++++++++++++++++--- Data/models/RSSFeedSource.swift | 37 ++++------ 2 files changed, 73 insertions(+), 33 deletions(-) diff --git a/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift index 8c84ed9d3a1..542f75949c0 100644 --- a/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift +++ b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift @@ -7,6 +7,7 @@ import Foundation import Data import FeedKit import Fuzi +import Shared struct RSSFeedLocation: Hashable { var title: String? @@ -66,8 +67,45 @@ extension FeedDataSource { } extension FeedItem.Content { - init?(from feedItem: JSONFeedItem) { - return nil + init?(from feedItem: JSONFeedItem, location: RSSFeedLocation) { + guard let publishTime = feedItem.datePublished, + let url = feedItem.url?.asURL, + let title = feedItem.title else { + return nil + } + var description = "" + var imageURL: URL? + if let image = feedItem.image { + imageURL = URL(string: image, relativeTo: location.url.domainURL) + } + if let text = feedItem.contentText { + description = text + } + if let html = feedItem.contentHtml { + let doc = try? HTMLDocument(string: html) + if imageURL == nil, let src = doc?.firstChild(xpath: "//img[@src]")?.attr("src") { + imageURL = URL(string: src, relativeTo: location.url.domainURL) + } + if description.isEmpty, let text = doc?.root?.childNodes(ofTypes: [.Text, .Element]).map({ node in + node.stringValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\n", with: " ") + }).joined(separator: " ") { + description = text + } + } + self.init( + publishTime: publishTime, + url: url, + imageURL: imageURL, + title: title, + description: description, + contentType: .article, + publisherID: location.id, + urlHash: url.absoluteString, + baseScore: 0, + offersCategory: nil + ) } init?(from feedItem: AtomFeedEntry, location: RSSFeedLocation) { guard let publishTime = feedItem.published, @@ -77,14 +115,17 @@ extension FeedItem.Content { return nil } var description = "" - var imageURL = feedItem.media?.mediaThumbnails?.first?.attributes?.url?.asURL + var imageURL: URL? + if let thumbnail = feedItem.media?.mediaThumbnails?.first?.attributes?.url { + imageURL = URL(string: thumbnail, relativeTo: location.url.domainURL) + } if feedItem.summary?.attributes?.type == "text" { description = feedItem.summary?.value ?? "" } else if feedItem.content?.attributes?.type == "html", let html = feedItem.content?.value { // Find one in description? let doc = try? HTMLDocument(string: html) - if imageURL == nil { - imageURL = doc?.firstChild(xpath: "//img[@src]")?.attr("src")?.asURL + if imageURL == nil, let src = doc?.firstChild(xpath: "//img[@src]")?.attr("src") { + imageURL = URL(string: src, relativeTo: location.url.domainURL) } if let text = doc?.root?.childNodes(ofTypes: [.Text, .Element]).map({ node in node.stringValue @@ -115,12 +156,15 @@ extension FeedItem.Content { return nil } var description = "" - var imageURL = feedItem.media?.mediaThumbnails?.first?.attributes?.url?.asURL + var imageURL: URL? + if let thumbnail = feedItem.media?.mediaThumbnails?.first?.attributes?.url { + imageURL = URL(string: thumbnail, relativeTo: location.url.domainURL) + } if let html = feedItem.description { // Find one in description? let doc = try? HTMLDocument(string: html) - if imageURL == nil { - imageURL = doc?.firstChild(xpath: "//img[@src]")?.attr("src")?.asURL + if imageURL == nil, let src = doc?.firstChild(xpath: "//img[@src]")?.attr("src") { + imageURL = URL(string: src, relativeTo: location.url.domainURL) } if let text = doc?.root?.childNodes(ofTypes: [.Text, .Element]).map({ node in node.stringValue @@ -147,15 +191,18 @@ extension FeedItem.Content { extension FeedItem.Source { init?(from feed: FeedKit.Feed, location: RSSFeedLocation) { let id = location.id + let feedTitle: String switch feed { case .atom(let feed): guard let title = feed.title else { return nil } - self.init(id: id, isDefault: true, category: "", name: title) + feedTitle = title case .rss(let feed): guard let title = feed.title else { return nil } - self.init(id: id, isDefault: true, category: "", name: title) + feedTitle = title case .json(let feed): - return nil + guard let title = feed.title else { return nil } + feedTitle = title } + self.init(id: id, isDefault: true, category: "", name: feedTitle) } } diff --git a/Data/models/RSSFeedSource.swift b/Data/models/RSSFeedSource.swift index afe25f8e3ee..e675a300ca0 100644 --- a/Data/models/RSSFeedSource.swift +++ b/Data/models/RSSFeedSource.swift @@ -11,7 +11,8 @@ public final class RSSFeedSource: NSManagedObject, CRUD { @NSManaged public var feedUrl: String public class func get(with feedUrl: String) -> RSSFeedSource? { - getInternal(with: feedUrl) + let predicate = NSPredicate(format: "\(#keyPath(RSSFeedSource.feedUrl)) == %@", feedUrl) + return first(where: predicate, context: DataController.viewContext) } public class func all() -> [RSSFeedSource] { @@ -19,30 +20,22 @@ public final class RSSFeedSource: NSManagedObject, CRUD { } public class func delete(with feedUrl: String) { - deleteInternal(feedUrl: feedUrl, context: .existing(DataController.viewContext)) - } - - public class func insert(title: String?, feedUrl: String) { - insertInternal(title: title, feedUrl: feedUrl, context: .existing(DataController.viewContext)) - } - - class func getInternal(with feedUrl: String, context: NSManagedObjectContext = DataController.viewContext) -> RSSFeedSource? { - let predicate = NSPredicate(format: "\(#keyPath(RSSFeedSource.feedUrl)) == %@", feedUrl) - return first(where: predicate, context: context) - } - - class func insertInternal(title: String?, feedUrl: String, context: WriteContext = .new(inMemory: false)) { - DataController.perform(context: context) { context in - let source = RSSFeedSource(entity: entity(in: context), insertInto: context) - - source.title = title - source.feedUrl = feedUrl + let context = DataController.viewContext + if let item = get(with: feedUrl) { + item.delete(context: .existing(context)) + if context.hasChanges { + try? context.save() + } } } - class func deleteInternal(feedUrl: String, context: WriteContext = .new(inMemory: false)) { - if let item = getInternal(with: feedUrl, context: DataController.viewContext) { - item.delete(context: context) + public class func insert(title: String?, feedUrl: String) { + let context = DataController.viewContext + let source = RSSFeedSource(entity: entity(in: context), insertInto: context) + source.title = title + source.feedUrl = feedUrl + if context.hasChanges { + try? context.save() } } From d3369ffb6e8b1c948e2b39735a7c1037015dbfb2 Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Tue, 16 Feb 2021 11:04:36 -0500 Subject: [PATCH 03/17] Add support for adding RSS feed from share sheet --- BraveShared/BraveStrings.swift | 6 ++++ Client.xcodeproj/project.pbxproj | 4 +++ .../Browser/BrowserViewController.swift | 23 +++++++++++++ ...eTodayAddSourceResultsViewController.swift | 9 ++++++ .../BraveTodayAddSourceViewController.swift | 2 +- .../Share/AddFeedToBraveTodayActivity.swift | 32 +++++++++++++++++++ .../MainFrame/AtDocumentEnd/MetadataHelper.js | 10 +++++- Storage/PageMetadata.swift | 6 +++- 8 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 Client/Frontend/Share/AddFeedToBraveTodayActivity.swift diff --git a/BraveShared/BraveStrings.swift b/BraveShared/BraveStrings.swift index 3273ca082af..feb24d4483f 100644 --- a/BraveShared/BraveStrings.swift +++ b/BraveShared/BraveStrings.swift @@ -1736,6 +1736,12 @@ extension Strings { value: "Promoted", comment: "A button title that is placed on promoted cards" ) + public static let addSourceShareTitle = NSLocalizedString( + "today.addSourceShareTitle", + bundle: .braveShared, + value: "Add Source to Brave Today", + comment: "The action title displayed in the iOS share menu" + ) } } diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index df3d0edc0be..88e49507f50 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -257,6 +257,7 @@ 2755EABD255323C60033C43F /* PublisherInfoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2755EABC255323C60033C43F /* PublisherInfoExtensions.swift */; }; 2755EAD3255329540033C43F /* BraveLedgerExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2755EAD2255329540033C43F /* BraveLedgerExtensions.swift */; }; 275965E224EEC4EA0051A827 /* FeedFillStrategyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 275965E124EEC4EA0051A827 /* FeedFillStrategyTests.swift */; }; + 2760056625D1FFF500D47A75 /* AddFeedToBraveTodayActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2760056525D1FFF500D47A75 /* AddFeedToBraveTodayActivity.swift */; }; 2760D2BF215ACCE20068E131 /* BundleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2760D2BE215ACCE20068E131 /* BundleExtensions.swift */; }; 2765825D2171263A00754B2F /* UserReferralProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2765825C2171263A00754B2F /* UserReferralProgram.swift */; }; 276582692171266900754B2F /* ReferralData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 276582652171266900754B2F /* ReferralData.swift */; }; @@ -1642,6 +1643,7 @@ 2755EABC255323C60033C43F /* PublisherInfoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublisherInfoExtensions.swift; sourceTree = ""; }; 2755EAD2255329540033C43F /* BraveLedgerExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BraveLedgerExtensions.swift; sourceTree = ""; }; 275965E124EEC4EA0051A827 /* FeedFillStrategyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedFillStrategyTests.swift; sourceTree = ""; }; + 2760056525D1FFF500D47A75 /* AddFeedToBraveTodayActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddFeedToBraveTodayActivity.swift; sourceTree = ""; }; 2760D2BE215ACCE20068E131 /* BundleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleExtensions.swift; sourceTree = ""; }; 2765825C2171263A00754B2F /* UserReferralProgram.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserReferralProgram.swift; sourceTree = ""; }; 276582652171266900754B2F /* ReferralData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReferralData.swift; sourceTree = ""; }; @@ -4857,6 +4859,7 @@ D3972BF11C22412B00035B87 /* ShareExtensionHelper.swift */, D3972BF21C22412B00035B87 /* TitleActivityItemProvider.swift */, 2703BE8A24F4508E00CBE6CD /* CreatePDFActivity.swift */, + 2760056525D1FFF500D47A75 /* AddFeedToBraveTodayActivity.swift */, ); path = Share; sourceTree = ""; @@ -6943,6 +6946,7 @@ 0A1E84422190A57F0042F782 /* SyncPairCameraViewController.swift in Sources */, 27C647A62551F082006D72FC /* RewardsDebugSettingsViewController.swift in Sources */, 27829E752549FF13007CF0B2 /* WalletTransferViewController.swift in Sources */, + 2760056625D1FFF500D47A75 /* AddFeedToBraveTodayActivity.swift in Sources */, 44331DDC22561F34007E3E93 /* ToolbarUrlActionsDelegate.swift in Sources */, 27C6478F2551CD2B006D72FC /* RewardsInternalsDebugViewController.swift in Sources */, 4422D43721BFD29E00BF1855 /* NSData+GZIP.m in Sources */, diff --git a/Client/Frontend/Browser/BrowserViewController.swift b/Client/Frontend/Browser/BrowserViewController.swift index a92a84de6ac..24a4620d5e4 100644 --- a/Client/Frontend/Browser/BrowserViewController.swift +++ b/Client/Frontend/Browser/BrowserViewController.swift @@ -1804,6 +1804,29 @@ class BrowserViewController: UIViewController { } activities.append(requestDesktopSiteActivity) + if let metadata = tab?.pageMetadata, !metadata.feeds.isEmpty { + let feeds: [RSSFeedLocation] = metadata.feeds.compactMap { feed in + if let url = URL(string: feed.href) { + return RSSFeedLocation(title: feed.title, url: url) + } + return nil + } + if !feeds.isEmpty { + let addToBraveToday = AddFeedToBraveTodayActivity() { [weak self] in + guard let self = self else { return } + let controller = BraveTodayAddSourceResultsViewController( + dataSource: self.feedDataSource, + searchedURL: url, + rssFeedLocations: feeds, + sourcesAdded: nil + ) + let container = UINavigationController(rootViewController: controller) + self.present(container, animated: true) + } + activities.append(addToBraveToday) + } + } + #if compiler(>=5.3) if #available(iOS 14.0, *), let webView = tab?.webView, tab?.temporaryDocument == nil { let createPDFActivity = CreatePDFActivity(webView: webView) { [weak self] pdfData in diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift index db47ed7de9b..96ca4739c71 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift @@ -55,6 +55,11 @@ class BraveTodayAddSourceResultsViewController: UITableViewController { navigationItem.rightBarButtonItem = doneButton tableView.register(FeedLocationCell.self) + + if navigationController?.viewControllers.first === self { + // Presented via share screen or isolated + navigationItem.leftBarButtonItem = .init(barButtonSystemItem: .cancel, target: self, action: #selector(tappedCancel)) + } } @objc private func tappedAdd() { @@ -66,6 +71,10 @@ class BraveTodayAddSourceResultsViewController: UITableViewController { dismiss(animated: true) } + @objc private func tappedCancel() { + dismiss(animated: true) + } + // MARK: - UITableViewDelegate override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift index 6b919d7234b..7f36c2e2a11 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift @@ -144,7 +144,7 @@ class BraveTodayAddSourceViewController: UITableViewController { } var feeds: [RSSFeedLocation] = [] - let xpath = "//head//link[contains(@type, 'application/rss+xml') or contains(@type, 'application/atom+xml')]" + let xpath = "//head//link[contains(@type, 'application/rss+xml') or contains(@type, 'application/atom+xml') or contains(@type, 'application/json')]" for link in root.xpath(xpath) { guard let href = link["href"], let url = URL(string: href, relativeTo: url) else { continue } feeds.append(.init(title: link["title"], url: url)) diff --git a/Client/Frontend/Share/AddFeedToBraveTodayActivity.swift b/Client/Frontend/Share/AddFeedToBraveTodayActivity.swift new file mode 100644 index 00000000000..8c1870c9ca1 --- /dev/null +++ b/Client/Frontend/Share/AddFeedToBraveTodayActivity.swift @@ -0,0 +1,32 @@ +// Copyright 2020 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import Shared + +class AddFeedToBraveTodayActivity: UIActivity { + fileprivate let callback: () -> Void + + init(callback: @escaping () -> Void) { + self.callback = callback + } + + override var activityTitle: String? { + return Strings.BraveToday.addSourceShareTitle + } + + override var activityImage: UIImage? { + return #imageLiteral(resourceName: "settings-brave-today") + } + + override func perform() { + callback() + activityDidFinish(true) + } + + override func canPerform(withActivityItems activityItems: [Any]) -> Bool { + return true + } +} diff --git a/Client/Frontend/UserContent/UserScripts/MainFrame/AtDocumentEnd/MetadataHelper.js b/Client/Frontend/UserContent/UserScripts/MainFrame/AtDocumentEnd/MetadataHelper.js index 37a8f4e70ee..781b53238e1 100644 --- a/Client/Frontend/UserContent/UserScripts/MainFrame/AtDocumentEnd/MetadataHelper.js +++ b/Client/Frontend/UserContent/UserScripts/MainFrame/AtDocumentEnd/MetadataHelper.js @@ -49,7 +49,15 @@ function MetadataWrapper() { ], processors: customRuleSets.icon.processors }; - return metadataparser(window.document, document.URL, customRuleSets); + const data = metadataparser(window.document, document.URL, customRuleSets); + // Since we want to obtain multiple feeds, we are doing a separate query. + // `page-metadata-parser` only allows a single result from rules passed into `getMetadata` + data.feeds = function() { + const rules = 'link[type="application/rss+xml"], link[type="application/atom+xml"], link[rel="alternate"][type="application/json"]'; + const nodes = window.document.querySelectorAll(rules); + return Array.from(nodes).map(link => {return {href: link.href, title: link.title};}); + }(); + return data }; } diff --git a/Storage/PageMetadata.swift b/Storage/PageMetadata.swift index 65a60cc2186..c893fcde302 100644 --- a/Storage/PageMetadata.swift +++ b/Storage/PageMetadata.swift @@ -16,6 +16,7 @@ public struct PageMetadata: Decodable { public let largeIconURL: String? public let keywordsString: String? public let search: Link? + public let feeds: [Link] public var keywords: Set { guard let string = keywordsString else { @@ -37,6 +38,7 @@ public struct PageMetadata: Decodable { case largeIconURL = "largeIcon" case keywordsString = "keywords" case search + case feeds } public init(siteURL: String, @@ -48,7 +50,8 @@ public struct PageMetadata: Decodable { faviconURL: String? = nil, largeIconURL: String? = nil, keywords: String? = nil, - search: Link? = nil) { + search: Link? = nil, + feeds: [Link] = []) { self.siteURL = siteURL self.mediaURL = mediaURL self.title = title @@ -59,6 +62,7 @@ public struct PageMetadata: Decodable { self.largeIconURL = largeIconURL self.keywordsString = keywords self.search = search + self.feeds = feeds } public struct Link: Decodable { From f4091d779375d0f5cd6e4d6ff17ea84e34865cdb Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Tue, 9 Feb 2021 15:41:44 -0500 Subject: [PATCH 04/17] Hide user sources from the All Sources list --- Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift | 2 +- Client/Frontend/Brave Today/Composer/FeedItem.swift | 1 + .../Brave Today/Source List/FeedSourceListViewController.swift | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift index 542f75949c0..190b34f822c 100644 --- a/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift +++ b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift @@ -203,6 +203,6 @@ extension FeedItem.Source { guard let title = feed.title else { return nil } feedTitle = title } - self.init(id: id, isDefault: true, category: "", name: feedTitle) + self.init(id: id, isDefault: true, category: "", name: feedTitle, isUserSource: true) } } diff --git a/Client/Frontend/Brave Today/Composer/FeedItem.swift b/Client/Frontend/Brave Today/Composer/FeedItem.swift index db448f5f4dd..83ec2fc3100 100644 --- a/Client/Frontend/Brave Today/Composer/FeedItem.swift +++ b/Client/Frontend/Brave Today/Composer/FeedItem.swift @@ -22,6 +22,7 @@ extension FeedItem { var isDefault: Bool var category: String var name: String + var isUserSource: Bool = false enum CodingKeys: String, CodingKey { case id = "publisher_id" diff --git a/Client/Frontend/Brave Today/Source List/FeedSourceListViewController.swift b/Client/Frontend/Brave Today/Source List/FeedSourceListViewController.swift index 1679d5d2d01..087fe7df40c 100644 --- a/Client/Frontend/Brave Today/Source List/FeedSourceListViewController.swift +++ b/Client/Frontend/Brave Today/Source List/FeedSourceListViewController.swift @@ -41,6 +41,8 @@ class FeedSourceListViewController: UITableViewController { var list: [FeedItem.Source] = dataSource.sources if let category = category { list = list.filter { $0.category == category } + } else { + list = list.filter { !$0.isUserSource } } if !searchQuery.isEmpty { list = list From a148880be2c7db397e113de49aadd7721938cb39 Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Tue, 9 Feb 2021 17:18:40 -0500 Subject: [PATCH 05/17] Handle errors when adding user sources --- BraveShared/BraveStrings.swift | 24 ++++++++ .../BraveTodayAddSourceViewController.swift | 59 ++++++++++++++----- 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/BraveShared/BraveStrings.swift b/BraveShared/BraveStrings.swift index feb24d4483f..5d3b8f3e04a 100644 --- a/BraveShared/BraveStrings.swift +++ b/BraveShared/BraveStrings.swift @@ -1742,6 +1742,30 @@ extension Strings { value: "Add Source to Brave Today", comment: "The action title displayed in the iOS share menu" ) + public static let addSourceFailureTitle = NSLocalizedString( + "today.addSourceFailureTitle", + bundle: .braveShared, + value: "Failed to Add Source", + comment: "The title in the alert when a source fails to add" + ) + public static let addSourceNetworkFailureMessage = NSLocalizedString( + "today.addSourceNetworkFailureMessage", + bundle: .braveShared, + value: "Sorry, we couldn’t find that feed address.", + comment: "" + ) + public static let addSourceInvalidDataMessage = NSLocalizedString( + "today.addSourceInvalidDataMessage", + bundle: .braveShared, + value: "Sorry, we couldn’t recognize that feed address.", + comment: "" + ) + public static let addSourceNoFeedsFoundMessage = NSLocalizedString( + "today.addSourceNoFeedsFoundMessage", + bundle: .braveShared, + value: "Sorry, that feed address doesn’t have any content.", + comment: "" + ) } } diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift index 7f36c2e2a11..f78e6f6a871 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift @@ -71,8 +71,8 @@ class BraveTodayAddSourceViewController: UITableViewController { return URLSession(configuration: configuration, delegate: nil, delegateQueue: .main) }() - private func displayError() { - let alert = UIAlertController(title: "Failed to Add Source", message: "Brave could not find a feed at the url provided.", preferredStyle: .alert) + private func displayError(_ error: FindFeedsError) { + let alert = UIAlertController(title: Strings.BraveToday.addSourceFailureTitle, message: error.localizedDescription, preferredStyle: .alert) alert.addAction(.init(title: Strings.OKString, style: .default, handler: nil)) present(alert, animated: true) } @@ -83,9 +83,10 @@ class BraveTodayAddSourceViewController: UITableViewController { text.replaceSubrange(range, with: []) } guard let url = URIFixup.getURL(text) else { return } - downloadPageData(for: url) { [weak self] data in + downloadPageData(for: url) { [weak self] result in guard let self = self else { return } - if let data = data { + switch result { + case .success(let data): let resultsController = BraveTodayAddSourceResultsViewController( dataSource: self.feedDataSource, searchedURL: url, @@ -93,26 +94,53 @@ class BraveTodayAddSourceViewController: UITableViewController { sourcesAdded: self.sourcesAdded ) self.navigationController?.pushViewController(resultsController, animated: true) - } else { - self.displayError() + case .failure(let error): + self.displayError(error) + } + } + } + + private enum FindFeedsError: Error { + /// An error occured while attempting to download the page + case dataTaskError(Error) + /// The data was either not received or is in the incorrect format + case invalidData + /// The data downloaded did not match a + case parserError(ParserError) + /// No feeds were found at the given URL + case noFeedsFound + + var localizedDescription: String { + switch self { + case .dataTaskError(let error as URLError) where error.code == .notConnectedToInternet: + return error.localizedDescription + case .dataTaskError: + return Strings.BraveToday.addSourceNetworkFailureMessage + case .invalidData, .parserError: + return Strings.BraveToday.addSourceInvalidDataMessage + case .noFeedsFound: + return Strings.BraveToday.addSourceNoFeedsFoundMessage } } } private var pageTask: URLSessionDataTask? - private func downloadPageData(for url: URL, _ completion: @escaping ([RSSFeedLocation]?) -> Void) { + private func downloadPageData(for url: URL, _ completion: @escaping (Result<[RSSFeedLocation], FindFeedsError>) -> Void) { pageTask = session.dataTask(with: url) { [weak self] (data, response, error) in guard let self = self else { return } if let error = error as? URLError, error.code == .cancelled { return } - guard let data = data, error == nil, let root = try? HTMLDocument(data: data) else { - completion(nil) + if let error = error { + completion(.failure(.dataTaskError(error))) + return + } + guard let data = data, let root = try? HTMLDocument(data: data) else { + completion(.failure(.invalidData)) return } let parser = FeedParser(data: data) - let result = parser.parse() - if case .success(let feed) = result { + if case .success(let feed) = parser.parse() { // User provided a direct feed var title: String? switch feed { @@ -123,8 +151,7 @@ class BraveTodayAddSourceViewController: UITableViewController { case .rss(let rss): title = rss.title } - completion([.init(title: title, url: url)]) - return + completion(.success([.init(title: title, url: url)])) } // Ensure page is reloaded to final landing page before looking for // favicons @@ -149,7 +176,11 @@ class BraveTodayAddSourceViewController: UITableViewController { guard let href = link["href"], let url = URL(string: href, relativeTo: url) else { continue } feeds.append(.init(title: link["title"], url: url)) } - completion(feeds) + if feeds.isEmpty { + completion(.failure(.noFeedsFound)) + } else { + completion(.success(feeds)) + } } pageTask?.resume() } From 732ab2399a55b3db78da0d0ca18cb2bf4e164432 Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Thu, 11 Mar 2021 17:02:17 -0500 Subject: [PATCH 06/17] Add OPML parsing --- BraveShared/BraveStrings.swift | 18 +++ Client.xcodeproj/project.pbxproj | 32 +++++ .../xcshareddata/swiftpm/Package.resolved | 9 -- Client/Frontend/Brave Today/OPML/OPML.swift | 49 ++++++++ .../BraveTodayAddSourceViewController.swift | 114 +++++++++++++++--- Client/Info.plist | 43 +++++-- ClientTests/OPMLParsingTests.swift | 39 ++++++ ClientTests/opml-test-files/states.opml | 1 + .../opml-test-files/subscriptionList.opml | 1 + 9 files changed, 271 insertions(+), 35 deletions(-) create mode 100644 Client/Frontend/Brave Today/OPML/OPML.swift create mode 100644 ClientTests/OPMLParsingTests.swift create mode 100644 ClientTests/opml-test-files/states.opml create mode 100644 ClientTests/opml-test-files/subscriptionList.opml diff --git a/BraveShared/BraveStrings.swift b/BraveShared/BraveStrings.swift index 5d3b8f3e04a..3c0c747b805 100644 --- a/BraveShared/BraveStrings.swift +++ b/BraveShared/BraveStrings.swift @@ -1766,6 +1766,24 @@ extension Strings { value: "Sorry, that feed address doesn’t have any content.", comment: "" ) + public static let searchTextFieldPlaceholder = NSLocalizedString( + "today.searchTextFieldPlaceholder", + bundle: .braveShared, + value: "Feed or Site URL", + comment: "The placeholder displayed on the text field where a user is expected to type in a website URL" + ) + public static let searchButtonTitle = NSLocalizedString( + "today.searchButtonTitle", + bundle: .braveShared, + value: "Search", + comment: "An action title where the user is executing a search based on inputted text" + ) + public static let importOPML = NSLocalizedString( + "today.importOPML", + bundle: .braveShared, + value: "Import OPML", + comment: "\"OPML\" is a file extension that contains a list of rss feeds." + ) } } diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index 88e49507f50..6ee0795467b 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -373,6 +373,10 @@ 27C405E924255A2B00347246 /* BraveShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DE7688420B3456C00FF5533 /* BraveShared.framework */; }; 27C461DE211B76500088A441 /* ShieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C461DD211B76500088A441 /* ShieldsView.swift */; }; 27C46201211CD8D20088A441 /* DeferredTestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A176323020CF2A6000126F25 /* DeferredTestUtils.swift */; }; + 27C5AC8725D6FA6D00B8F50E /* OPML.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C5AC8625D6FA6D00B8F50E /* OPML.swift */; }; + 27C5AE2E25D72B0A00B8F50E /* OPMLParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C5AE2D25D72B0A00B8F50E /* OPMLParsingTests.swift */; }; + 27C5AE4A25D72B9700B8F50E /* subscriptionList.opml in Resources */ = {isa = PBXBuildFile; fileRef = 27C5AE4425D72B9700B8F50E /* subscriptionList.opml */; }; + 27C5AE4B25D72B9700B8F50E /* states.opml in Resources */ = {isa = PBXBuildFile; fileRef = 27C5AE4525D72B9700B8F50E /* states.opml */; }; 27C626CB25BA198700418F40 /* WalletTransferExpiredViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C626CA25BA198700418F40 /* WalletTransferExpiredViewController.swift */; }; 27C647772550AE16006D72FC /* WalletTransferCompleteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C647762550AE16006D72FC /* WalletTransferCompleteViewController.swift */; }; 27C647832550AF34006D72FC /* WalletTransferCompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C647822550AF34006D72FC /* WalletTransferCompleteView.swift */; }; @@ -1845,6 +1849,10 @@ 27B68E8725C88F28002D0826 /* Model11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model11.xcdatamodel; sourceTree = ""; }; 27B68E9325C8911D002D0826 /* BraveTodayAddSourceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BraveTodayAddSourceViewController.swift; sourceTree = ""; }; 27C461DD211B76500088A441 /* ShieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShieldsView.swift; sourceTree = ""; }; + 27C5AC8625D6FA6D00B8F50E /* OPML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPML.swift; sourceTree = ""; }; + 27C5AE2D25D72B0A00B8F50E /* OPMLParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLParsingTests.swift; sourceTree = ""; }; + 27C5AE4425D72B9700B8F50E /* subscriptionList.opml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = subscriptionList.opml; sourceTree = ""; }; + 27C5AE4525D72B9700B8F50E /* states.opml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = states.opml; sourceTree = ""; }; 27C626CA25BA198700418F40 /* WalletTransferExpiredViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletTransferExpiredViewController.swift; sourceTree = ""; }; 27C647762550AE16006D72FC /* WalletTransferCompleteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletTransferCompleteViewController.swift; sourceTree = ""; }; 27C647822550AF34006D72FC /* WalletTransferCompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletTransferCompleteView.swift; sourceTree = ""; }; @@ -3789,6 +3797,7 @@ 2726637224981B5F0056CFE1 /* FeedSectionHeaderView.swift */, 27AC7CF924C77EBC00441317 /* FeedActionAlertView.swift */, 27E0652324CB699800134946 /* Lottie Assets */, + 27C5AC7A25D6FA5400B8F50E /* OPML */, ); path = "Brave Today"; sourceTree = ""; @@ -3839,6 +3848,23 @@ path = "Brave Today"; sourceTree = ""; }; + 27C5AC7A25D6FA5400B8F50E /* OPML */ = { + isa = PBXGroup; + children = ( + 27C5AC8625D6FA6D00B8F50E /* OPML.swift */, + ); + path = OPML; + sourceTree = ""; + }; + 27C5AE4325D72B9700B8F50E /* opml-test-files */ = { + isa = PBXGroup; + children = ( + 27C5AE4425D72B9700B8F50E /* subscriptionList.opml */, + 27C5AE4525D72B9700B8F50E /* states.opml */, + ); + path = "opml-test-files"; + sourceTree = ""; + }; 27C647A42551F071006D72FC /* QA */ = { isa = PBXGroup; children = ( @@ -5283,6 +5309,7 @@ F9488F12258D6B9800A72C84 /* WKWebViewExtensionsTest.swift */, F95ED17D25A95426001A432D /* UserScriptManagerTest.swift */, 0A2BFB3325F2754600719AA9 /* NTPDownloaderTests.swift */, + 27C5AE2D25D72B0A00B8F50E /* OPMLParsingTests.swift */, ); path = ClientTests; sourceTree = ""; @@ -5290,6 +5317,7 @@ F84B21D71A090F8100AAB793 /* Supporting Files */ = { isa = PBXGroup; children = ( + 27C5AE4325D72B9700B8F50E /* opml-test-files */, A83E5B181C1DA8BF0026D912 /* image.gif */, F84B21D81A090F8100AAB793 /* Info.plist */, A83E5B191C1DA8BF0026D912 /* image.png */, @@ -6397,7 +6425,9 @@ buildActionMask = 2147483647; files = ( A83E5B1A1C1DA8BF0026D912 /* image.gif in Resources */, + 27C5AE4B25D72B9700B8F50E /* states.opml in Resources */, A83E5B1B1C1DA8BF0026D912 /* image.png in Resources */, + 27C5AE4A25D72B9700B8F50E /* subscriptionList.opml in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6901,6 +6931,7 @@ E4CD9E911A6897FB00318571 /* ReaderMode.swift in Sources */, 4422D42321BFCE9200BF1855 /* HttpsEverywhereStats.swift in Sources */, 0A8C69AB225CFCAF00988715 /* AddEditBookmarkTableViewController.swift in Sources */, + 27C5AC8725D6FA6D00B8F50E /* OPML.swift in Sources */, 27FD2CAD2146C31C00A5A779 /* AddToFavoritesActivity.swift in Sources */, 4422D4E121BFFB7600BF1855 /* filter_block.cc in Sources */, 0A8C69BE225E350300988715 /* IndentedImageTableViewCell.swift in Sources */, @@ -7290,6 +7321,7 @@ 2FDB10931A9FBEC5006CF312 /* PrefsTests.swift in Sources */, D8EFFA261FF702A8001D3A09 /* NavigationRouterTests.swift in Sources */, F939FBFE22A596B900D9CD3F /* U2FTests.swift in Sources */, + 27C5AE2E25D72B0A00B8F50E /* OPMLParsingTests.swift in Sources */, 0A4BEFD6221E13830005551A /* ContentBlockerTests.swift in Sources */, 0A2BFB3425F2754600719AA9 /* NTPDownloaderTests.swift in Sources */, A83E5B1D1C1DA8D80026D912 /* UIPasteboardExtensionsTests.swift in Sources */, diff --git a/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e16fe4ae5fc..d160781461c 100644 --- a/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { "object": { "pins": [ - { - "package": "FeedKit", - "repositoryURL": "https://github.com/nmdias/FeedKit.git", - "state": { - "branch": null, - "revision": "68493a33d862c33c9a9f67ec729b3b7df1b20ade", - "version": "9.1.2" - } - }, { "package": "Fuzi", "repositoryURL": "https://github.com/cezheng/Fuzi", diff --git a/Client/Frontend/Brave Today/OPML/OPML.swift b/Client/Frontend/Brave Today/OPML/OPML.swift new file mode 100644 index 00000000000..9f645353ae8 --- /dev/null +++ b/Client/Frontend/Brave Today/OPML/OPML.swift @@ -0,0 +1,49 @@ +// Copyright 2020 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import Fuzi +import Shared + +private let log = Logger.browserLogger + +/// A set of subscription RSS feed URLs defined through Outline Processor Markup Language +struct OPML: Equatable { + /// A node contains a set of named attributes describing an XML feed + struct Outline: Equatable { + /// Some text describing the feed + var text: String? + /// The URL of this feed + var xmlUrl: String? + } + /// The title of the subscription list + var title: String? + /// A list of all the feeds contained in the list + var outlines: [Outline] +} + +/// A simple parser to read part of an OPML files contents +/// +/// In our case, we only care about obtaining a subset of data from an OPML file: +/// - The main OPML's title (for UI purposes) +/// - The set of "outlines", or feed entries, whos type is "rss" and aren't commented out +class OPMLParser { + /// Parses the data passed and returns an OPML object + static func parse(data: Data) -> OPML? { + guard let document = try? XMLDocument(data: data), + let _ = document.firstChild(xpath: "//opml") else { + log.info("Failed to parse XML document") + return nil + } + let title = document.firstChild(xpath: "//head/title")?.stringValue + let outlines = document.xpath("//outline[contains(@type, \"rss\") and not(contains(@isComment, \"true\"))]").map { element in + OPML.Outline( + text: element["text"], + xmlUrl: element["xmlUrl"] + ) + } + return OPML(title: title, outlines: outlines) + } +} diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift index f78e6f6a871..b7cc4529990 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift @@ -65,6 +65,21 @@ class BraveTodayAddSourceViewController: UITableViewController { } } + private func tappedImportOPML() { + let picker = UIDocumentPickerViewController(documentTypes: ["public.opml"], in: .import) + picker.delegate = self + picker.allowsMultipleSelection = false + if #available(iOS 13.0, *) { + picker.shouldShowFileExtensions = true + } + present(picker, animated: true) + } + + private func rssLocationFromOPMLOutline(_ outline: OPML.Outline) -> RSSFeedLocation? { + guard let url = outline.xmlUrl?.asURL else { return nil } + return .init(title: outline.text, url: url) + } + private let session: URLSession = { let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = 5 @@ -139,8 +154,9 @@ class BraveTodayAddSourceViewController: UITableViewController { completion(.failure(.invalidData)) return } - let parser = FeedParser(data: data) - if case .success(let feed) = parser.parse() { + + // Check if `data` is actually an RSS feed + if case .success(let feed) = FeedParser(data: data).parse() { // User provided a direct feed var title: String? switch feed { @@ -152,7 +168,18 @@ class BraveTodayAddSourceViewController: UITableViewController { title = rss.title } completion(.success([.init(title: title, url: url)])) + return } + + // Check if `data` is actually an OPML list + if let opml = OPMLParser.parse(data: data), !opml.outlines.isEmpty { + let locations = opml.outlines.compactMap(self.rssLocationFromOPMLOutline) + if !locations.isEmpty { + completion(.success(locations)) + return + } + } + // Ensure page is reloaded to final landing page before looking for // favicons var reloadUrl: URL? @@ -187,7 +214,7 @@ class BraveTodayAddSourceViewController: UITableViewController { private let textField = UITextField().then { $0.attributedPlaceholder = NSAttributedString( - string: "Feed or Site URL", + string: Strings.BraveToday.searchTextFieldPlaceholder, attributes: [.foregroundColor: UIColor.lightGray] ) $0.font = .preferredFont(forTextStyle: .body) @@ -210,28 +237,44 @@ class BraveTodayAddSourceViewController: UITableViewController { if indexPath.section == 0 && indexPath.row == 1, isSearchEnabled, !isLoading { searchPageForFeeds() } + if indexPath.section == 1 && indexPath.row == 0 { + tappedImportOPML() + } tableView.deselectRow(at: indexPath, animated: true) } override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { - if indexPath.row == 1 { - return isSearchEnabled && !isLoading + if indexPath.section == 0 { + if indexPath.row == 1 { + return isSearchEnabled && !isLoading + } + return false } - return false + return true } // MARK: - UITableViewDataSource override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - switch indexPath.row { + switch indexPath.section { case 0: - let cell = tableView.dequeueReusableCell(for: indexPath) as FeedSearchCellClass - cell.textField = textField - return cell + switch indexPath.row { + case 0: + let cell = tableView.dequeueReusableCell(for: indexPath) as FeedSearchCellClass + cell.textField = textField + return cell + case 1: + let cell = tableView.dequeueReusableCell(for: indexPath) as CenteredButtonCell + cell.textLabel?.text = Strings.BraveToday.searchButtonTitle + cell.tintColor = isSearchEnabled && !isLoading ? BraveUX.braveOrange : Colors.grey500 + return cell + default: + fatalError("No cell available for index path: \(indexPath)") + } case 1: let cell = tableView.dequeueReusableCell(for: indexPath) as CenteredButtonCell - cell.textLabel?.text = "Search" - cell.tintColor = isSearchEnabled && !isLoading ? BraveUX.braveOrange : Colors.grey500 + cell.textLabel?.text = Strings.BraveToday.importOPML + cell.tintColor = BraveUX.braveOrange return cell default: fatalError("No cell available for index path: \(indexPath)") @@ -239,11 +282,54 @@ class BraveTodayAddSourceViewController: UITableViewController { } override func numberOfSections(in tableView: UITableView) -> Int { - 1 + 2 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - 2 + switch section { + case 0: return 2 + case 1: return 1 + default: return 0 + } + } +} + +extension BraveTodayAddSourceViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first, url.isFileURL, let data = try? Data(contentsOf: url) else { + controller.dismiss(animated: true) { + self.displayError(.noFeedsFound) + } + return + } + DispatchQueue.global(qos: .userInitiated).async { + let opml = OPMLParser.parse(data: data) + DispatchQueue.main.async { + guard let opml = opml else { + controller.dismiss(animated: true) { + self.displayError(.invalidData) + } + return + } + let locations = opml.outlines.compactMap(self.rssLocationFromOPMLOutline) + if locations.isEmpty { + controller.dismiss(animated: true) { + self.displayError(.noFeedsFound) + } + return + } + let resultsController = BraveTodayAddSourceResultsViewController( + dataSource: self.feedDataSource, + searchedURL: url, + rssFeedLocations: locations, + sourcesAdded: self.sourcesAdded + ) + self.navigationController?.pushViewController(resultsController, animated: true) + } + } + } + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + controller.dismiss(animated: true) } } diff --git a/Client/Info.plist b/Client/Info.plist index 1c6a68fb970..80ff934985c 100644 --- a/Client/Info.plist +++ b/Client/Info.plist @@ -62,9 +62,9 @@ org-appextension-feature-password-management googlegmail inbox-gmail - whatsapp - http - https + whatsapp + http + https LSRequiresIPhoneOS @@ -74,8 +74,8 @@ $(DEVELOPMENT_TEAM) MozWhatsNewTopic - NFCReaderUsageDescription - The application needs access to NFC reading to communicate with your Yubikey. + NFCReaderUsageDescription + The application needs access to NFC reading to communicate with your Yubikey. NSAppTransportSecurity NSAllowsArbitraryLoads @@ -115,6 +115,8 @@ remote-notification + UIFileSharingEnabled + UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities @@ -155,13 +157,30 @@ UTTypeSize64IconFile + + UTTypeConformsTo + + public.xml + + UTTypeDescription + OPML File + UTTypeIconFiles + + UTTypeIdentifier + public.opml + UTTypeTagSpecification + + public.filename-extension + + opml + + + + + com.apple.developer.nfc.readersession.iso7816.select-identifiers + + A000000527471117 + A0000006472F0001 - UIFileSharingEnabled - - com.apple.developer.nfc.readersession.iso7816.select-identifiers - - A000000527471117 - A0000006472F0001 - diff --git a/ClientTests/OPMLParsingTests.swift b/ClientTests/OPMLParsingTests.swift new file mode 100644 index 00000000000..69b03b2052c --- /dev/null +++ b/ClientTests/OPMLParsingTests.swift @@ -0,0 +1,39 @@ +// Copyright 2020 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import XCTest +@testable import Client + +class OPMLParsingTests: XCTestCase { + + func loadTestData(named testFileName: String) -> Data { + try! Data(contentsOf: URL(fileURLWithPath: Bundle(for: OPMLParsingTests.self).path(forResource: testFileName, ofType: "opml")!)) + } + + func testBasicParsing() throws { + let data = loadTestData(named: "subscriptionList") + let opml = try XCTUnwrap(OPMLParser.parse(data: data)) + XCTAssertEqual(opml.title, "mySubscriptions.opml") + // Test parse all outlines + XCTAssertEqual(opml.outlines.count, 13) + // Test basic parse + XCTAssert(opml.outlines.contains(.init(text: "CNET News.com", xmlUrl: "http://news.com.com/2547-1_3-0-5.xml"))) + // Test parse where title text contained HTML entity ("NYT > Business") + XCTAssert(opml.outlines.contains(.init(text: "NYT > Business", xmlUrl: "http://www.nytimes.com/services/xml/rss/nyt/Business.xml"))) + } + + func testNoFeedsFound() throws { + let data = loadTestData(named: "states") + let opml = try XCTUnwrap(OPMLParser.parse(data: data)) + XCTAssertEqual(opml.outlines.count, 0) + } + + func testParseInvalidData() throws { + let json = try XCTUnwrap(#"{"data": "This isn't XML or OPML"}"#.data(using: .utf8)) + XCTAssertNil(OPMLParser.parse(data: json)) + let html = try XCTUnwrap(#"This isn't OPML"#.data(using: .utf8)) + XCTAssertNil(OPMLParser.parse(data: html)) + } +} diff --git a/ClientTests/opml-test-files/states.opml b/ClientTests/opml-test-files/states.opml new file mode 100644 index 00000000000..28901fff609 --- /dev/null +++ b/ClientTests/opml-test-files/states.opml @@ -0,0 +1 @@ + states.opml Tue, 15 Mar 2005 16:35:45 GMT Thu, 14 Jul 2005 23:41:05 GMT Dave Winer dave@scripting.com 1, 6, 13, 16, 18, 20 1 106 106 558 479 \ No newline at end of file diff --git a/ClientTests/opml-test-files/subscriptionList.opml b/ClientTests/opml-test-files/subscriptionList.opml new file mode 100644 index 00000000000..9cbb72e1dc3 --- /dev/null +++ b/ClientTests/opml-test-files/subscriptionList.opml @@ -0,0 +1 @@ + mySubscriptions.opml Sat, 18 Jun 2005 12:11:52 GMT Tue, 02 Aug 2005 21:42:48 GMT Dave Winer dave@scripting.com 1 61 304 562 842 \ No newline at end of file From ee34db7b6944be87eb8dc5a0595cc25b430e55d6 Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Tue, 16 Feb 2021 14:21:34 -0500 Subject: [PATCH 07/17] Add missed localization strings --- BraveShared/BraveStrings.swift | 24 +++++++++++++++++++ ...eTodayAddSourceResultsViewController.swift | 2 +- .../BraveTodayAddSourceViewController.swift | 2 +- .../BraveTodaySettingsViewController.swift | 6 ++--- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/BraveShared/BraveStrings.swift b/BraveShared/BraveStrings.swift index 3c0c747b805..702cd69ccd0 100644 --- a/BraveShared/BraveStrings.swift +++ b/BraveShared/BraveStrings.swift @@ -1766,6 +1766,12 @@ extension Strings { value: "Sorry, that feed address doesn’t have any content.", comment: "" ) + public static let addSourceAddButtonTitle = NSLocalizedString( + "today.addSourceAddButtonTitle", + bundle: .braveShared, + value: "Add", + comment: "To add a list of 1 or more rss feeds" + ) public static let searchTextFieldPlaceholder = NSLocalizedString( "today.searchTextFieldPlaceholder", bundle: .braveShared, @@ -1784,6 +1790,24 @@ extension Strings { value: "Import OPML", comment: "\"OPML\" is a file extension that contains a list of rss feeds." ) + public static let yourSources = NSLocalizedString( + "today.yourSources", + bundle: .braveShared, + value: "Your Sources", + comment: "The header above a list of the users RSS feed sources" + ) + public static let addSource = NSLocalizedString( + "today.addSource", + bundle: .braveShared, + value: "Add Source", + comment: "The button title for adding a user RSS feed" + ) + public static let deleteUserSourceTitle = NSLocalizedString( + "today.deleteUserSourceTitle", + bundle: .braveShared, + value: "Delete", + comment: "A button title for an action that deletes a users custom source" + ) } } diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift index 96ca4739c71..e214d899e7b 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift @@ -40,7 +40,7 @@ class BraveTodayAddSourceResultsViewController: UITableViewController { } private lazy var doneButton = UIBarButtonItem( - title: "Add", + title: Strings.BraveToday.addSourceAddButtonTitle, style: .done, target: self, action: #selector(tappedAdd) diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift index b7cc4529990..6883882be60 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift @@ -34,7 +34,7 @@ class BraveTodayAddSourceViewController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() - title = "Add Source" + title = Strings.BraveToday.addSource navigationController?.navigationBar.prefersLargeTitles = true navigationItem.largeTitleDisplayMode = .always diff --git a/Client/Frontend/Settings/Brave Today/BraveTodaySettingsViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodaySettingsViewController.swift index 6eaa9acf376..e43454c32e0 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodaySettingsViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodaySettingsViewController.swift @@ -52,7 +52,7 @@ class BraveTodaySettingsViewController: TableViewController { ] ), .init( - header: .title("Your Sources"), + header: .title(Strings.BraveToday.yourSources), rows: feedDataSource.rssFeedLocations.map { location in let enabled = self.feedDataSource.isRSSFeedEnabled(location) return Row( @@ -62,14 +62,14 @@ class BraveTodaySettingsViewController: TableViewController { self.feedDataSource.toggleRSSFeedEnabled(location, enabled: newValue) }), cellClass: SubtitleCell.self, - editActions: [.init(title: "Delete", style: .destructive, selection: { [unowned self] indexPath in + editActions: [.init(title: Strings.BraveToday.deleteUserSourceTitle, style: .destructive, selection: { [unowned self] indexPath in guard let location = feedDataSource.rssFeedLocations[safe: indexPath.row] else { return } self.feedDataSource.removeRSSFeed(with: location.url) dataSource.sections[1].rows.remove(at: indexPath.row) })] ) } + [ - Row(text: "Add Source", selection: { [unowned self] in + Row(text: Strings.BraveToday.addSource, selection: { [unowned self] in let controller = BraveTodayAddSourceViewController(dataSource: self.feedDataSource) controller.sourcesAdded = { [weak self] _ in self?.reloadSections() From 72a1c3403e03ab1ac894642cdb18254dc4f88b73 Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Tue, 16 Feb 2021 16:58:13 -0500 Subject: [PATCH 08/17] Only load RSS feeds that are enabled; Use deferred instead of groups --- .../Composer/FeedDataSource+RSS.swift | 4 +- .../Brave Today/Composer/FeedDataSource.swift | 92 +++++++++++-------- .../Brave Today/Composer/FeedItem.swift | 2 +- ...eTodayAddSourceResultsViewController.swift | 4 - .../BraveTodayAddSourceViewController.swift | 14 +-- Data/models/RSSFeedSource.swift | 2 +- 6 files changed, 66 insertions(+), 52 deletions(-) diff --git a/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift index 190b34f822c..1da6dcdf4ce 100644 --- a/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift +++ b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift @@ -30,10 +30,12 @@ extension FeedDataSource { } /// Add a users custom RSS feed to the list of sources + /// + /// - returns: `true` if the feed is successfully added, `false` if it already exists @discardableResult func addRSSFeedLocation(_ location: RSSFeedLocation) -> Bool { let feedUrl = location.url.absoluteString - if let _ = RSSFeedSource.get(with: feedUrl) { + if RSSFeedSource.get(with: feedUrl) != nil { return false } RSSFeedSource.insert(title: location.title, diff --git a/Client/Frontend/Brave Today/Composer/FeedDataSource.swift b/Client/Frontend/Brave Today/Composer/FeedDataSource.swift index 920c53c9833..04ba0f39422 100644 --- a/Client/Frontend/Brave Today/Composer/FeedDataSource.swift +++ b/Client/Frontend/Brave Today/Composer/FeedDataSource.swift @@ -303,42 +303,58 @@ class FeedDataSource { } } - private func loadRSSFeeds() -> Deferred> { - let deferred = Deferred>(value: nil, defaultQueue: .main) - var items: [(FeedItem.Source, [FeedItem.Content])] = [] - let group = DispatchGroup() - for feedLocation in rssFeedLocations { - group.enter() - let parser = FeedParser(URL: feedLocation.url) - parser.parseAsync { result in - if case .success(let feed) = result, case .atom(let atomFeed) = feed, let entries = atomFeed.entries { - if let source = FeedItem.Source(from: feed, location: feedLocation) { - let feedItems = entries.compactMap { - FeedItem.Content(from: $0, location: feedLocation) + /// Describes a single RSS feed's loaded data set converted into Brave Today based data + private struct RSSDataFeed { + var source: FeedItem.Source + var items: [FeedItem.Content] + } + + private func loadRSSLocation(_ location: RSSFeedLocation) -> Deferred> { + let deferred = Deferred>(value: nil, defaultQueue: .main) + let parser = FeedParser(URL: location.url) + parser.parseAsync { [weak self] result in + switch result { + case .success(let feed): + if let source = FeedItem.Source(from: feed, location: location) { + var content: [FeedItem.Content] = [] + switch feed { + case .atom(let atomFeed): + if let feedItems = atomFeed.entries?.compactMap({ entry -> FeedItem.Content? in + FeedItem.Content(from: entry, location: location) + }) { + content = feedItems } - items.append((source, feedItems)) - } - } - if case .success(let feed) = result, case .rss(let rssFeed) = feed, let entries = rssFeed.items { - if let source = FeedItem.Source(from: feed, location: feedLocation) { - let feedItems = entries.compactMap { - FeedItem.Content(from: $0, location: feedLocation) + case .rss(let rssFeed): + if let feedItems = rssFeed.items?.compactMap({ entry -> FeedItem.Content? in + FeedItem.Content(from: entry, location: location) + }) { + content = feedItems + } + case .json(let jsonFeed): + if let feedItems = jsonFeed.items?.compactMap({ entry -> FeedItem.Content? in + FeedItem.Content(from: entry, location: location) + }) { + content = feedItems } - items.append((source, feedItems)) } + guard let self = self else { return } + content = self.scored(rssItems: content) + deferred.fill(.success(.init(source: source, items: content))) } - group.leave() + case .failure(let error): + deferred.fill(.failure(error)) } } - group.notify(queue: .main) { - let sorted = items.map { - ($0.0, self.scored(rssItems: $0.1)) - } - deferred.fill(.success(sorted)) - } return deferred } + /// Load all RSS feeds that the user has enabled + private func loadRSSFeeds() -> Deferred<[Result]> { + let locations = rssFeedLocations.filter(isRSSFeedEnabled) + return all(locations.map(loadRSSLocation)) + } + + /// Scores RSS items similar to how the backend scores regular Brave Today sources private func scored(rssItems: [FeedItem.Content]) -> [FeedItem.Content] { var varianceBySource: [String: Double] = [:] return rssItems.map { @@ -395,20 +411,18 @@ class FeedDataSource { case (.success(let sources), .success(let items)): self.sources = sources self.items = items - print("Regular feed items count: \(items.count)") - print("Regular sources count: \(sources.count)") - self.loadRSSFeeds().uponQueue(.main) { [weak self] result in + self.loadRSSFeeds().uponQueue(.main) { [weak self] results in guard let self = self else { return } - switch result { - case .success(let feeds): - self.sources.append(contentsOf: feeds.map(\.0)) - for items in feeds.map(\.1) { - self.items.append(contentsOf: items) + for result in results { + switch result { + case .success(let feed): + self.sources.append(feed.source) + self.items.append(contentsOf: feed.items) + case .failure(_): + // At the moment we dont handle any load errors once the feed has been + // added + break } - print("Post RSS feed items count: \(self.items.count)") - print("Post RSS sources count: \(self.sources.count)") - case .failure(_): - break } self.reloadCards(from: self.items, sources: self.sources, completion: completion) } diff --git a/Client/Frontend/Brave Today/Composer/FeedItem.swift b/Client/Frontend/Brave Today/Composer/FeedItem.swift index 83ec2fc3100..00bc50bcf34 100644 --- a/Client/Frontend/Brave Today/Composer/FeedItem.swift +++ b/Client/Frontend/Brave Today/Composer/FeedItem.swift @@ -22,7 +22,7 @@ extension FeedItem { var isDefault: Bool var category: String var name: String - var isUserSource: Bool = false + var isUserSource = false enum CodingKeys: String, CodingKey { case id = "publisher_id" diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift index e214d899e7b..6594cba6bfc 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift @@ -103,10 +103,6 @@ class BraveTodayAddSourceResultsViewController: UITableViewController { return cell } - override func numberOfSections(in tableView: UITableView) -> Int { - 1 - } - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { locations.count } diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift index 6883882be60..32be441b387 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift @@ -12,7 +12,7 @@ import FeedKit class BraveTodayAddSourceViewController: UITableViewController { - let feedDataSource: FeedDataSource + private let feedDataSource: FeedDataSource var sourcesAdded: ((Set) -> Void)? private var isLoading: Bool = false @@ -31,6 +31,10 @@ class BraveTodayAddSourceViewController: UITableViewController { fatalError() } + deinit { + pageTask?.cancel() + } + override func viewDidLoad() { super.viewDidLoad() @@ -81,7 +85,7 @@ class BraveTodayAddSourceViewController: UITableViewController { } private let session: URLSession = { - let configuration = URLSessionConfiguration.default + let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForRequest = 5 return URLSession(configuration: configuration, delegate: nil, delegateQueue: .main) }() @@ -174,10 +178,8 @@ class BraveTodayAddSourceViewController: UITableViewController { // Check if `data` is actually an OPML list if let opml = OPMLParser.parse(data: data), !opml.outlines.isEmpty { let locations = opml.outlines.compactMap(self.rssLocationFromOPMLOutline) - if !locations.isEmpty { - completion(.success(locations)) - return - } + completion(locations.isEmpty ? .failure(.noFeedsFound) : .success(locations)) + return } // Ensure page is reloaded to final landing page before looking for diff --git a/Data/models/RSSFeedSource.swift b/Data/models/RSSFeedSource.swift index e675a300ca0..a980f20bcf4 100644 --- a/Data/models/RSSFeedSource.swift +++ b/Data/models/RSSFeedSource.swift @@ -12,7 +12,7 @@ public final class RSSFeedSource: NSManagedObject, CRUD { public class func get(with feedUrl: String) -> RSSFeedSource? { let predicate = NSPredicate(format: "\(#keyPath(RSSFeedSource.feedUrl)) == %@", feedUrl) - return first(where: predicate, context: DataController.viewContext) + return first(where: predicate) } public class func all() -> [RSSFeedSource] { From 28271c5f3be7ab957c50f3cdb474a3fcce8877ad Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Wed, 17 Feb 2021 12:37:46 -0500 Subject: [PATCH 09/17] Allow adding feeds via share sheet if viewing feed URL directly --- .../Composer/FeedDataSource+RSS.swift | 20 +++++++++-------- .../Browser/BrowserViewController.swift | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift index 1da6dcdf4ce..98ca71c4883 100644 --- a/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift +++ b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift @@ -193,18 +193,20 @@ extension FeedItem.Content { extension FeedItem.Source { init?(from feed: FeedKit.Feed, location: RSSFeedLocation) { let id = location.id - let feedTitle: String - switch feed { + guard let title = feed.title else { return nil } + self.init(id: id, isDefault: true, category: "", name: title, isUserSource: true) + } +} + +extension Feed { + var title: String? { + switch self { case .atom(let feed): - guard let title = feed.title else { return nil } - feedTitle = title + return feed.title case .rss(let feed): - guard let title = feed.title else { return nil } - feedTitle = title + return feed.title case .json(let feed): - guard let title = feed.title else { return nil } - feedTitle = title + return feed.title } - self.init(id: id, isDefault: true, category: "", name: feedTitle, isUserSource: true) } } diff --git a/Client/Frontend/Browser/BrowserViewController.swift b/Client/Frontend/Browser/BrowserViewController.swift index 24a4620d5e4..f090a9412fa 100644 --- a/Client/Frontend/Browser/BrowserViewController.swift +++ b/Client/Frontend/Browser/BrowserViewController.swift @@ -21,6 +21,7 @@ import SafariServices import BraveUI import NetworkExtension import YubiKit +import FeedKit private let log = Logger.browserLogger @@ -1856,6 +1857,27 @@ class BrowserViewController: UIViewController { activities.append(createPDFActivity) } #endif + } else { + // Check if its a feed, url is a temp document file URL + if let selectedTab = tabManager.selectedTab, + (selectedTab.mimeType == "application/xml" || selectedTab.mimeType == "application/json"), + let tabURL = selectedTab.url { + let parser = FeedParser(URL: url) + if case .success(let feed) = parser.parse() { + let addToBraveToday = AddFeedToBraveTodayActivity() { [weak self] in + guard let self = self else { return } + let controller = BraveTodayAddSourceResultsViewController( + dataSource: self.feedDataSource, + searchedURL: tabURL, + rssFeedLocations: [.init(title: feed.title, url: tabURL)], + sourcesAdded: nil + ) + let container = UINavigationController(rootViewController: controller) + self.present(container, animated: true) + } + activities.append(addToBraveToday) + } + } } let controller = helper.createActivityViewController(items: activities) { [weak self] completed, _, documentUrl in From 07a9f852665ef8858a3cd59a6b6a6d87c400c0df Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Fri, 19 Feb 2021 11:43:50 -0500 Subject: [PATCH 10/17] Check for lowercased/normalized refresh http-equiv --- Client/Frontend/Browser/Favicons/FaviconFetcher.swift | 2 +- .../Brave Today/BraveTodayAddSourceViewController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Client/Frontend/Browser/Favicons/FaviconFetcher.swift b/Client/Frontend/Browser/Favicons/FaviconFetcher.swift index 81a546cf7bb..65232cc9385 100644 --- a/Client/Frontend/Browser/Favicons/FaviconFetcher.swift +++ b/Client/Frontend/Browser/Favicons/FaviconFetcher.swift @@ -353,7 +353,7 @@ class FaviconFetcher { // favicons var reloadUrl: URL? for meta in root.xpath("//head/meta") { - if let refresh = meta["http-equiv"], refresh == "Refresh", + if let refresh = meta["http-equiv"]?.lowercased(), refresh == "refresh", let content = meta["content"], let index = content.range(of: "URL="), let url = NSURL(string: String(content.suffix(from: index.upperBound))) { diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift index 32be441b387..0317cb2161d 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift @@ -186,7 +186,7 @@ class BraveTodayAddSourceViewController: UITableViewController { // favicons var reloadUrl: URL? for meta in root.xpath("//head/meta") { - if let refresh = meta["http-equiv"], refresh == "Refresh", + if let refresh = meta["http-equiv"]?.lowercased(), refresh == "refresh", let content = meta["content"], let index = content.range(of: "URL="), let url = NSURL(string: String(content.suffix(from: index.upperBound))) { From dffb50c0fb61401ccea16e6ec303b32aebfb4066 Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Fri, 19 Feb 2021 14:41:56 -0500 Subject: [PATCH 11/17] Add activity indicator when searching for/loading rss feeds --- .../BraveTodayAddSourceViewController.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift index 0317cb2161d..fd77e27d06c 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift @@ -15,7 +15,18 @@ class BraveTodayAddSourceViewController: UITableViewController { private let feedDataSource: FeedDataSource var sourcesAdded: ((Set) -> Void)? - private var isLoading: Bool = false + private var isLoading: Bool = false { + didSet { + if isLoading { + self.activityIndicator.startAnimating() + } else { + self.activityIndicator.stopAnimating() + } + } + } + private let activityIndicator = UIActivityIndicatorView(style: .gray).then { + $0.hidesWhenStopped = true + } init(dataSource: FeedDataSource) { self.feedDataSource = dataSource @@ -44,6 +55,7 @@ class BraveTodayAddSourceViewController: UITableViewController { navigationItem.largeTitleDisplayMode = .always navigationItem.backButtonTitle = "" navigationItem.leftBarButtonItem = .init(barButtonSystemItem: .cancel, target: self, action: #selector(tappedCancel)) + navigationItem.rightBarButtonItem = .init(customView: activityIndicator) textField.addTarget(self, action: #selector(textFieldTextChanged), for: .editingChanged) textField.delegate = self @@ -102,8 +114,10 @@ class BraveTodayAddSourceViewController: UITableViewController { text.replaceSubrange(range, with: []) } guard let url = URIFixup.getURL(text) else { return } + isLoading = true downloadPageData(for: url) { [weak self] result in guard let self = self else { return } + self.isLoading = false switch result { case .success(let data): let resultsController = BraveTodayAddSourceResultsViewController( From a16ec8b51bc8f18dffa922a44bf25c075b0e9231 Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Fri, 19 Feb 2021 14:54:02 -0500 Subject: [PATCH 12/17] Fix presentation on iOS 12 --- Client/Extensions/AppearanceExtensions.swift | 1 + Client/Frontend/Browser/BrowserViewController.swift | 12 ++++++++++++ .../BraveTodaySettingsViewController.swift | 6 ++++++ 3 files changed, 19 insertions(+) diff --git a/Client/Extensions/AppearanceExtensions.swift b/Client/Extensions/AppearanceExtensions.swift index 83d47898b72..114b3ca87e0 100644 --- a/Client/Extensions/AppearanceExtensions.swift +++ b/Client/Extensions/AppearanceExtensions.swift @@ -18,6 +18,7 @@ extension Theme { UIToolbar.appearance().barTintColor = colors.footer UINavigationBar.appearance().tintColor = colors.accent + UINavigationBar.appearance().largeTitleTextAttributes = [.foregroundColor: colors.tints.home] UINavigationBar.appearance().appearanceBarTintColor = colors.header UISwitch.appearance().appearanceOnTintColor = colors.accent diff --git a/Client/Frontend/Browser/BrowserViewController.swift b/Client/Frontend/Browser/BrowserViewController.swift index f090a9412fa..56af2e61695 100644 --- a/Client/Frontend/Browser/BrowserViewController.swift +++ b/Client/Frontend/Browser/BrowserViewController.swift @@ -1822,6 +1822,12 @@ class BrowserViewController: UIViewController { sourcesAdded: nil ) let container = UINavigationController(rootViewController: controller) + let idiom = UIDevice.current.userInterfaceIdiom + if #available(iOS 13.0, *) { + container.modalPresentationStyle = idiom == .phone ? .pageSheet : .formSheet + } else { + container.modalPresentationStyle = idiom == .phone ? .fullScreen : .formSheet + } self.present(container, animated: true) } activities.append(addToBraveToday) @@ -1873,6 +1879,12 @@ class BrowserViewController: UIViewController { sourcesAdded: nil ) let container = UINavigationController(rootViewController: controller) + let idiom = UIDevice.current.userInterfaceIdiom + if #available(iOS 13.0, *) { + container.modalPresentationStyle = idiom == .phone ? .pageSheet : .formSheet + } else { + container.modalPresentationStyle = idiom == .phone ? .fullScreen : .formSheet + } self.present(container, animated: true) } activities.append(addToBraveToday) diff --git a/Client/Frontend/Settings/Brave Today/BraveTodaySettingsViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodaySettingsViewController.swift index e43454c32e0..9573fb99bc7 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodaySettingsViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodaySettingsViewController.swift @@ -75,6 +75,12 @@ class BraveTodaySettingsViewController: TableViewController { self?.reloadSections() } let container = UINavigationController(rootViewController: controller) + let idiom = UIDevice.current.userInterfaceIdiom + if #available(iOS 13.0, *) { + container.modalPresentationStyle = idiom == .phone ? .pageSheet : .formSheet + } else { + container.modalPresentationStyle = idiom == .phone ? .fullScreen : .formSheet + } self.present(container, animated: true) }, image: nil, accessory: .disclosureIndicator) ] From da5ea85ff12a97046b907c12eafa07ceccb2982f Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Thu, 25 Feb 2021 16:03:43 -0500 Subject: [PATCH 13/17] Split insecure sources and secure sources when adding custom sources --- BraveShared/BraveStrings.swift | 6 +++++ ...eTodayAddSourceResultsViewController.swift | 26 ++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/BraveShared/BraveStrings.swift b/BraveShared/BraveStrings.swift index 702cd69ccd0..4792df2e273 100644 --- a/BraveShared/BraveStrings.swift +++ b/BraveShared/BraveStrings.swift @@ -1772,6 +1772,12 @@ extension Strings { value: "Add", comment: "To add a list of 1 or more rss feeds" ) + public static let insecureSourcesHeader = NSLocalizedString( + "today.insecureSourcesHeader", + bundle: .braveShared, + value: "Insecure Sources - Add at your own risk", + comment: "The header above the list of insecure sources" + ) public static let searchTextFieldPlaceholder = NSLocalizedString( "today.searchTextFieldPlaceholder", bundle: .braveShared, diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift index 6594cba6bfc..7e0111bd7dd 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift @@ -11,7 +11,8 @@ class BraveTodayAddSourceResultsViewController: UITableViewController { let feedDataSource: FeedDataSource let searchedURL: URL - let locations: [RSSFeedLocation] + private let secureLocations: [RSSFeedLocation] + private let insecureLocations: [RSSFeedLocation] var sourcesAdded: ((Set) -> Void)? private var selectedLocations: Set @@ -23,8 +24,10 @@ class BraveTodayAddSourceResultsViewController: UITableViewController { ) { self.feedDataSource = dataSource self.searchedURL = searchedURL - self.locations = rssFeedLocations - self.selectedLocations = Set(rssFeedLocations) + let locations = Set(rssFeedLocations) + self.secureLocations = locations.filter { $0.url.scheme == "https" } + self.insecureLocations = Array(locations.subtracting(self.secureLocations)) + self.selectedLocations = locations self.sourcesAdded = sourcesAdded if #available(iOS 13.0, *) { @@ -78,6 +81,7 @@ class BraveTodayAddSourceResultsViewController: UITableViewController { // MARK: - UITableViewDelegate override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let locations = indexPath.section == 0 ? secureLocations : insecureLocations if let location = locations[safe: indexPath.row], let cell = tableView.cellForRow(at: indexPath) as? FeedLocationCell { if selectedLocations.remove(location) == nil { @@ -92,11 +96,14 @@ class BraveTodayAddSourceResultsViewController: UITableViewController { // MARK: - UITableViewDataSource override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let locations = indexPath.section == 0 ? secureLocations : insecureLocations guard let location = locations[safe: indexPath.row] else { assertionFailure() return UITableViewCell() } let cell = tableView.dequeueReusableCell(for: indexPath) as FeedLocationCell + cell.imageView?.image = indexPath.section == 0 ? #imageLiteral(resourceName: "lock_verified").template : #imageLiteral(resourceName: "insecure-site-icon") + cell.imageView?.tintColor = Theme.of(nil).colors.tints.home cell.textLabel?.text = location.title cell.detailTextLabel?.text = location.url.absoluteString cell.accessoryType = selectedLocations.contains(location) ? .checkmark : .none @@ -104,7 +111,18 @@ class BraveTodayAddSourceResultsViewController: UITableViewController { } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - locations.count + return section == 0 ? secureLocations.count : insecureLocations.count + } + + override func numberOfSections(in tableView: UITableView) -> Int { + insecureLocations.isEmpty ? 1 : 2 + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + if section == 1 { + return Strings.BraveToday.insecureSourcesHeader + } + return nil } } From bcb1de3502ddba8171d2a748f0dbbe9e9aa0fe5d Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Thu, 25 Feb 2021 16:04:11 -0500 Subject: [PATCH 14/17] Validate url schemes when parsing out feeds and image urls --- .../Composer/FeedDataSource+RSS.swift | 88 +++++++++++-------- .../BraveTodayAddSourceViewController.swift | 5 +- 2 files changed, 57 insertions(+), 36 deletions(-) diff --git a/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift index 98ca71c4883..53280b72dec 100644 --- a/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift +++ b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift @@ -31,9 +31,13 @@ extension FeedDataSource { /// Add a users custom RSS feed to the list of sources /// - /// - returns: `true` if the feed is successfully added, `false` if it already exists + /// - returns: `true` if the feed is successfully added, `false` if it already exists or the + /// url location is not a web page url @discardableResult func addRSSFeedLocation(_ location: RSSFeedLocation) -> Bool { + if !location.url.isWebPage(includeDataURIs: false) { + return false + } let feedUrl = location.url.absoluteString if RSSFeedSource.get(with: feedUrl) != nil { return false @@ -69,6 +73,26 @@ extension FeedDataSource { } extension FeedItem.Content { + private static func imageURL(from document: HTMLDocument, releativeTo baseURL: URL?) -> URL? { + if let src = document.firstChild(xpath: "//img[@src]")?.attr("src"), + let url = URL(string: src, relativeTo: baseURL), + url.isWebPage(includeDataURIs: false) { + return url + } + return nil + } + + private static func descriptionText(from document: HTMLDocument) -> String? { + if let text = document.root?.childNodes(ofTypes: [.Text, .Element]).map({ node in + node.stringValue + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\n", with: " ") + }).joined(separator: " ") { + return text + } + return nil + } + init?(from feedItem: JSONFeedItem, location: RSSFeedLocation) { guard let publishTime = feedItem.datePublished, let url = feedItem.url?.asURL, @@ -77,22 +101,19 @@ extension FeedItem.Content { } var description = "" var imageURL: URL? - if let image = feedItem.image { - imageURL = URL(string: image, relativeTo: location.url.domainURL) + if let image = feedItem.image, let url = URL(string: image, relativeTo: location.url.domainURL), + url.isWebPage(includeDataURIs: false) { + imageURL = url } if let text = feedItem.contentText { description = text } - if let html = feedItem.contentHtml { - let doc = try? HTMLDocument(string: html) - if imageURL == nil, let src = doc?.firstChild(xpath: "//img[@src]")?.attr("src") { - imageURL = URL(string: src, relativeTo: location.url.domainURL) + if let html = feedItem.contentHtml, let doc = try? HTMLDocument(string: html) { + if imageURL == nil, + let imageURLFromHTML = Self.imageURL(from: doc, releativeTo: location.url.domainURL) { + imageURL = imageURLFromHTML } - if description.isEmpty, let text = doc?.root?.childNodes(ofTypes: [.Text, .Element]).map({ node in - node.stringValue - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: "\n", with: " ") - }).joined(separator: " ") { + if description.isEmpty, let text = Self.descriptionText(from: doc) { description = text } } @@ -118,22 +139,22 @@ extension FeedItem.Content { } var description = "" var imageURL: URL? - if let thumbnail = feedItem.media?.mediaThumbnails?.first?.attributes?.url { - imageURL = URL(string: thumbnail, relativeTo: location.url.domainURL) + if let thumbnail = feedItem.media?.mediaThumbnails?.first?.attributes?.url, + let url = URL(string: thumbnail, relativeTo: location.url.domainURL), + url.isWebPage(includeDataURIs: false) { + imageURL = url } if feedItem.summary?.attributes?.type == "text" { description = feedItem.summary?.value ?? "" - } else if feedItem.content?.attributes?.type == "html", let html = feedItem.content?.value { + } else if feedItem.content?.attributes?.type == "html", + let html = feedItem.content?.value, + let doc = try? HTMLDocument(string: html) { // Find one in description? - let doc = try? HTMLDocument(string: html) - if imageURL == nil, let src = doc?.firstChild(xpath: "//img[@src]")?.attr("src") { - imageURL = URL(string: src, relativeTo: location.url.domainURL) + if imageURL == nil, + let imageURLFromHTML = Self.imageURL(from: doc, releativeTo: location.url.domainURL) { + imageURL = imageURLFromHTML } - if let text = doc?.root?.childNodes(ofTypes: [.Text, .Element]).map({ node in - node.stringValue - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: "\n", with: " ") - }).joined(separator: " ") { + if description.isEmpty, let text = Self.descriptionText(from: doc) { description = text } } @@ -159,20 +180,17 @@ extension FeedItem.Content { } var description = "" var imageURL: URL? - if let thumbnail = feedItem.media?.mediaThumbnails?.first?.attributes?.url { - imageURL = URL(string: thumbnail, relativeTo: location.url.domainURL) + if let thumbnail = feedItem.media?.mediaThumbnails?.first?.attributes?.url, + let url = URL(string: thumbnail, relativeTo: location.url.domainURL), + url.isWebPage(includeDataURIs: false) { + imageURL = url } - if let html = feedItem.description { - // Find one in description? - let doc = try? HTMLDocument(string: html) - if imageURL == nil, let src = doc?.firstChild(xpath: "//img[@src]")?.attr("src") { - imageURL = URL(string: src, relativeTo: location.url.domainURL) + if let html = feedItem.description, let doc = try? HTMLDocument(string: html) { + if imageURL == nil, + let imageURLFromHTML = Self.imageURL(from: doc, releativeTo: location.url.domainURL) { + imageURL = imageURLFromHTML } - if let text = doc?.root?.childNodes(ofTypes: [.Text, .Element]).map({ node in - node.stringValue - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: "\n", with: " ") - }).joined(separator: " ") { + if description.isEmpty, let text = Self.descriptionText(from: doc) { description = text } } diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift index fd77e27d06c..09545f67e72 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift @@ -216,7 +216,10 @@ class BraveTodayAddSourceViewController: UITableViewController { var feeds: [RSSFeedLocation] = [] let xpath = "//head//link[contains(@type, 'application/rss+xml') or contains(@type, 'application/atom+xml') or contains(@type, 'application/json')]" for link in root.xpath(xpath) { - guard let href = link["href"], let url = URL(string: href, relativeTo: url) else { continue } + guard let href = link["href"], let url = URL(string: href, relativeTo: url), + url.isWebPage(includeDataURIs: false) else { + continue + } feeds.append(.init(title: link["title"], url: url)) } if feeds.isEmpty { From 401043bc1eb9b3c30db0f1cf7c3a8249b1c77d7a Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Mon, 1 Mar 2021 14:26:15 -0500 Subject: [PATCH 15/17] Update file header year on new files --- Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift | 2 +- Client/Frontend/Brave Today/OPML/OPML.swift | 2 +- .../Brave Today/BraveTodayAddSourceResultsViewController.swift | 2 +- .../Brave Today/BraveTodayAddSourceViewController.swift | 2 +- Client/Frontend/Share/AddFeedToBraveTodayActivity.swift | 2 +- ClientTests/OPMLParsingTests.swift | 2 +- Data/models/RSSFeedSource.swift | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift index 53280b72dec..96acda6b227 100644 --- a/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift +++ b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift @@ -1,4 +1,4 @@ -// Copyright 2020 The Brave Authors. All rights reserved. +// Copyright 2021 The Brave Authors. All rights reserved. // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. diff --git a/Client/Frontend/Brave Today/OPML/OPML.swift b/Client/Frontend/Brave Today/OPML/OPML.swift index 9f645353ae8..3b45e6340a0 100644 --- a/Client/Frontend/Brave Today/OPML/OPML.swift +++ b/Client/Frontend/Brave Today/OPML/OPML.swift @@ -1,4 +1,4 @@ -// Copyright 2020 The Brave Authors. All rights reserved. +// Copyright 2021 The Brave Authors. All rights reserved. // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift index 7e0111bd7dd..bfdf152101a 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift @@ -1,4 +1,4 @@ -// Copyright 2020 The Brave Authors. All rights reserved. +// Copyright 2021 The Brave Authors. All rights reserved. // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift index 09545f67e72..1d9297f65e3 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift @@ -1,4 +1,4 @@ -// Copyright 2020 The Brave Authors. All rights reserved. +// Copyright 2021 The Brave Authors. All rights reserved. // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. diff --git a/Client/Frontend/Share/AddFeedToBraveTodayActivity.swift b/Client/Frontend/Share/AddFeedToBraveTodayActivity.swift index 8c1870c9ca1..4c6bc56d80e 100644 --- a/Client/Frontend/Share/AddFeedToBraveTodayActivity.swift +++ b/Client/Frontend/Share/AddFeedToBraveTodayActivity.swift @@ -1,4 +1,4 @@ -// Copyright 2020 The Brave Authors. All rights reserved. +// Copyright 2021 The Brave Authors. All rights reserved. // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. diff --git a/ClientTests/OPMLParsingTests.swift b/ClientTests/OPMLParsingTests.swift index 69b03b2052c..ec304d1185f 100644 --- a/ClientTests/OPMLParsingTests.swift +++ b/ClientTests/OPMLParsingTests.swift @@ -1,4 +1,4 @@ -// Copyright 2020 The Brave Authors. All rights reserved. +// Copyright 2021 The Brave Authors. All rights reserved. // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. diff --git a/Data/models/RSSFeedSource.swift b/Data/models/RSSFeedSource.swift index a980f20bcf4..dd81cda08e0 100644 --- a/Data/models/RSSFeedSource.swift +++ b/Data/models/RSSFeedSource.swift @@ -1,4 +1,4 @@ -// Copyright 2020 The Brave Authors. All rights reserved. +// Copyright 2021 The Brave Authors. All rights reserved. // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. From eb189b48c1f1570bb240850829c00f0fd7c0713f Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Thu, 11 Mar 2021 17:48:51 -0500 Subject: [PATCH 16/17] Add freemium constraints --- BraveShared/BraveStrings.swift | 18 +++++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 9 ++++++++ .../Composer/FeedDataSource+RSS.swift | 7 ++++++ ...eTodayAddSourceResultsViewController.swift | 23 +++++++++++++++++++ .../BraveTodayAddSourceViewController.swift | 14 ++++++----- 5 files changed, 65 insertions(+), 6 deletions(-) diff --git a/BraveShared/BraveStrings.swift b/BraveShared/BraveStrings.swift index 4792df2e273..910bf3f6ff3 100644 --- a/BraveShared/BraveStrings.swift +++ b/BraveShared/BraveStrings.swift @@ -1772,6 +1772,24 @@ extension Strings { value: "Add", comment: "To add a list of 1 or more rss feeds" ) + public static let rssFeedLimitExceededAlertTitle = NSLocalizedString( + "today.rssFeedLimitExceededAlertTitle", + bundle: .braveShared, + value: "Feed limit exceeded", + comment: "" + ) + public static let rssFeedLimitExceededAlertMessage = NSLocalizedString( + "today.rssFeedLimitExceededAlertMessage", + bundle: .braveShared, + value: "The free ad-supported version of Brave Today includes 5 RSS feeds. You can customize your feed selections from the Sources list.", + comment: "" + ) + public static let rssFeedLimitRemainingFooter = NSLocalizedString( + "today.rssFeedLimitRemainingFooter", + bundle: .braveShared, + value: "You currently have %d RSS feeds in your Sources list. The free ad-supported version of Brave Today includes 5 RSS feeds. Support for unlimited feeds and OPML import will be coming soon. You can customize your feed selections from the Sources list.", + comment: "%d will be a number (i.e. 4)" + ) public static let insecureSourcesHeader = NSLocalizedString( "today.insecureSourcesHeader", bundle: .braveShared, diff --git a/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d160781461c..e16fe4ae5fc 100644 --- a/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { "object": { "pins": [ + { + "package": "FeedKit", + "repositoryURL": "https://github.com/nmdias/FeedKit.git", + "state": { + "branch": null, + "revision": "68493a33d862c33c9a9f67ec729b3b7df1b20ade", + "version": "9.1.2" + } + }, { "package": "Fuzi", "repositoryURL": "https://github.com/cezheng/Fuzi", diff --git a/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift index 96acda6b227..19a16c407da 100644 --- a/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift +++ b/Client/Frontend/Brave Today/Composer/FeedDataSource+RSS.swift @@ -19,6 +19,10 @@ struct RSSFeedLocation: Hashable { } extension FeedDataSource { + /// The maximum number of RSS sources that can be added + static let maximumNumberOfRSSFeeds = 5 + /// Whether or not OPML parsing is avaialable + static let isOPMLParsingAvailable = false // MARK: - RSS Sources @@ -38,6 +42,9 @@ extension FeedDataSource { if !location.url.isWebPage(includeDataURIs: false) { return false } + if rssFeedLocations.count >= Self.maximumNumberOfRSSFeeds { + return false + } let feedUrl = location.url.absoluteString if RSSFeedSource.get(with: feedUrl) != nil { return false diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift index bfdf152101a..b611e7a7348 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift @@ -65,7 +65,22 @@ class BraveTodayAddSourceResultsViewController: UITableViewController { } } + private func showMaximumReachedAlert() { + let alert = UIAlertController( + title: Strings.BraveToday.rssFeedLimitExceededAlertTitle, + message: Strings.BraveToday.rssFeedLimitExceededAlertMessage, + preferredStyle: .alert + ) + alert.addAction(.init(title: Strings.OKString, style: .default)) + present(alert, animated: true) + } + @objc private func tappedAdd() { + let numberOfAddedFeeds = feedDataSource.rssFeedLocations.count + if numberOfAddedFeeds + selectedLocations.count > FeedDataSource.maximumNumberOfRSSFeeds { + showMaximumReachedAlert() + return + } // Add selected sources to feed for location in selectedLocations { feedDataSource.addRSSFeedLocation(location) @@ -124,6 +139,14 @@ class BraveTodayAddSourceResultsViewController: UITableViewController { } return nil } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + if section == tableView.numberOfSections - 1 { + let feedCount = feedDataSource.rssFeedLocations.count + return String.localizedStringWithFormat(Strings.BraveToday.rssFeedLimitRemainingFooter, feedCount) + } + return nil + } } private class FeedLocationCell: UITableViewCell, TableViewReusable { diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift index 1d9297f65e3..4049ac65988 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift @@ -189,11 +189,13 @@ class BraveTodayAddSourceViewController: UITableViewController { return } - // Check if `data` is actually an OPML list - if let opml = OPMLParser.parse(data: data), !opml.outlines.isEmpty { - let locations = opml.outlines.compactMap(self.rssLocationFromOPMLOutline) - completion(locations.isEmpty ? .failure(.noFeedsFound) : .success(locations)) - return + if FeedDataSource.isOPMLParsingAvailable { + // Check if `data` is actually an OPML list + if let opml = OPMLParser.parse(data: data), !opml.outlines.isEmpty { + let locations = opml.outlines.compactMap(self.rssLocationFromOPMLOutline) + completion(locations.isEmpty ? .failure(.noFeedsFound) : .success(locations)) + return + } } // Ensure page is reloaded to final landing page before looking for @@ -301,7 +303,7 @@ class BraveTodayAddSourceViewController: UITableViewController { } override func numberOfSections(in tableView: UITableView) -> Int { - 2 + FeedDataSource.isOPMLParsingAvailable ? 2 : 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { From 05cd4b3fb8b12566fe94f2d7817101bd45c9cdb4 Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Fri, 12 Mar 2021 11:27:49 -0500 Subject: [PATCH 17/17] Code style cleanup and address minor feedback --- Client/Frontend/Brave Today/Composer/FeedDataSource.swift | 2 +- Client/Frontend/Brave Today/OPML/OPML.swift | 2 +- .../Brave Today/BraveTodayAddSourceResultsViewController.swift | 2 +- .../Brave Today/BraveTodayAddSourceViewController.swift | 3 --- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Client/Frontend/Brave Today/Composer/FeedDataSource.swift b/Client/Frontend/Brave Today/Composer/FeedDataSource.swift index 04ba0f39422..5e13372dabb 100644 --- a/Client/Frontend/Brave Today/Composer/FeedDataSource.swift +++ b/Client/Frontend/Brave Today/Composer/FeedDataSource.swift @@ -418,7 +418,7 @@ class FeedDataSource { case .success(let feed): self.sources.append(feed.source) self.items.append(contentsOf: feed.items) - case .failure(_): + case .failure: // At the moment we dont handle any load errors once the feed has been // added break diff --git a/Client/Frontend/Brave Today/OPML/OPML.swift b/Client/Frontend/Brave Today/OPML/OPML.swift index 3b45e6340a0..2474028d7f3 100644 --- a/Client/Frontend/Brave Today/OPML/OPML.swift +++ b/Client/Frontend/Brave Today/OPML/OPML.swift @@ -34,7 +34,7 @@ class OPMLParser { static func parse(data: Data) -> OPML? { guard let document = try? XMLDocument(data: data), let _ = document.firstChild(xpath: "//opml") else { - log.info("Failed to parse XML document") + log.warning("Failed to parse XML document") return nil } let title = document.firstChild(xpath: "//head/title")?.stringValue diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift index b611e7a7348..22213bf7044 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceResultsViewController.swift @@ -126,7 +126,7 @@ class BraveTodayAddSourceResultsViewController: UITableViewController { } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return section == 0 ? secureLocations.count : insecureLocations.count + section == 0 ? secureLocations.count : insecureLocations.count } override func numberOfSections(in tableView: UITableView) -> Int { diff --git a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift index 4049ac65988..09eae8fdad6 100644 --- a/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift +++ b/Client/Frontend/Settings/Brave Today/BraveTodayAddSourceViewController.swift @@ -161,9 +161,6 @@ class BraveTodayAddSourceViewController: UITableViewController { private func downloadPageData(for url: URL, _ completion: @escaping (Result<[RSSFeedLocation], FindFeedsError>) -> Void) { pageTask = session.dataTask(with: url) { [weak self] (data, response, error) in guard let self = self else { return } - if let error = error as? URLError, error.code == .cancelled { - return - } if let error = error { completion(.failure(.dataTaskError(error))) return