From a6712eb2258460da1eb4cdd474d2687764b8928f Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Fri, 27 Dec 2024 09:22:06 -0600 Subject: [PATCH] added feed source customizer drop-down view #102 --- CHANGELOG.md | 1 + Nos.xcodeproj/project.pbxproj | 28 ++- Nos/Controller/FeedController.swift | 176 ++++++++++++++++++ Nos/Controller/FetchRequestPublisher.swift | 2 +- .../AuthorList+CoreDataProperties.swift | 3 + .../Generated/Relay+CoreDataProperties.swift | 2 + .../Nos.xcdatamodeld/.xccurrentversion | 2 +- .../Nos 22.xcdatamodel/.xccurrentversion | 8 + .../Nos.xcdatamodel/contents | 56 ++++++ .../Nos 22.xcdatamodel/contents | 114 ++++++++++++ .../Components/BeveledContainerView.swift | 20 ++ Nos/Views/Home/FeedCustomizerView.swift | 71 +++++++ Nos/Views/Home/FeedPicker.swift | 59 +----- Nos/Views/Home/FeedSourceToggleView.swift | 71 +++++++ Nos/Views/Home/FeedToggleRow.swift | 48 +++++ Nos/Views/Home/HomeFeedView.swift | 46 +++-- 16 files changed, 639 insertions(+), 68 deletions(-) create mode 100644 Nos/Controller/FeedController.swift create mode 100644 Nos/Models/CoreData/Nos.xcdatamodeld/Nos 22.xcdatamodel/.xccurrentversion create mode 100644 Nos/Models/CoreData/Nos.xcdatamodeld/Nos 22.xcdatamodel/Nos.xcdatamodel/contents create mode 100644 Nos/Models/CoreData/Nos.xcdatamodeld/Nos 22.xcdatamodel/contents create mode 100644 Nos/Views/Components/BeveledContainerView.swift create mode 100644 Nos/Views/Home/FeedCustomizerView.swift create mode 100644 Nos/Views/Home/FeedSourceToggleView.swift create mode 100644 Nos/Views/Home/FeedToggleRow.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index cd1c90e62..734dd933f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Nos now publishes the hashtags it finds in your note when you post. This means it works the way you’ve always expected it to work. [#44](https://github.com/verse-pbc/issues/issues/44) - Fixed crash related to tracking delete events. [#96](https://github.com/verse-pbc/issues/issues/96) - Added feed picker view (UI only). [#103](https://github.com/verse-pbc/issues/issues/103) +- Added feed source customizer drop-down view. [#102](https://github.com/verse-pbc/issues/issues/102) ### Internal Changes - Upgraded to Xcode 16. [#1570](https://github.com/planetary-social/nos/issues/1570) diff --git a/Nos.xcodeproj/project.pbxproj b/Nos.xcodeproj/project.pbxproj index 3d0ce5594..b286d76e1 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -200,6 +200,12 @@ 501728B42D16EFB000CF2A07 /* FeedPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501728B32D16EFAC00CF2A07 /* FeedPicker.swift */; }; 502B6C3D2C9462A400446316 /* PushNotificationRegistrar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 502B6C3C2C9462A400446316 /* PushNotificationRegistrar.swift */; }; 503CA9532D19ACCC00805EF8 /* HorizontalLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503CA9522D19ACC800805EF8 /* HorizontalLine.swift */; }; + 503CA9792D19C39F00805EF8 /* FeedCustomizerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503CA9782D19C39800805EF8 /* FeedCustomizerView.swift */; }; + 503CAAF12D1AFF8900805EF8 /* BeveledContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503CAAF02D1AFF8400805EF8 /* BeveledContainerView.swift */; }; + 503CAB4F2D1D8FB300805EF8 /* FeedController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503CAB4E2D1D8FAF00805EF8 /* FeedController.swift */; }; + 503CAB502D1D8FB300805EF8 /* FeedController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503CAB4E2D1D8FAF00805EF8 /* FeedController.swift */; }; + 503CAB6E2D1DA17400805EF8 /* FeedToggleRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503CAB6D2D1DA17100805EF8 /* FeedToggleRow.swift */; }; + 503CAC612D1EF71B00805EF8 /* FeedSourceToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503CAC602D1EF71700805EF8 /* FeedSourceToggleView.swift */; }; 5044546E2C90726A00251A7E /* Event+Fetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5044546D2C90726A00251A7E /* Event+Fetching.swift */; }; 504454702C90728500251A7E /* Event+Hydration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5044546F2C90728500251A7E /* Event+Hydration.swift */; }; 504454712C90728E00251A7E /* Event+Fetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5044546D2C90726A00251A7E /* Event+Fetching.swift */; }; @@ -616,8 +622,8 @@ 030024182CC00DF70073ED56 /* SplashScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenView.swift; sourceTree = ""; }; 030036842C5D39DD002C71F5 /* RefreshController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshController.swift; sourceTree = ""; }; 030036AA2C5D872B002C71F5 /* NewNotesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewNotesButton.swift; sourceTree = ""; }; - 0303B11E2D0257D400077929 /* Nos 21.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Nos 21.xcdatamodel"; sourceTree = ""; }; 0301495B2CFFA8B7000A0152 /* TabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarController.swift; sourceTree = ""; }; + 0303B11E2D0257D400077929 /* Nos 21.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Nos 21.xcdatamodel"; sourceTree = ""; }; 0303B13E2D025BDD00077929 /* AuthorList+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AuthorList+CoreDataProperties.swift"; sourceTree = ""; }; 0304D0A62C9B4BF2001D16C7 /* OpenGraphMetatdata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGraphMetatdata.swift; sourceTree = ""; }; 0304D0B12C9B731F001D16C7 /* MockOpenGraphService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOpenGraphService.swift; sourceTree = ""; }; @@ -760,6 +766,12 @@ 501728B32D16EFAC00CF2A07 /* FeedPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPicker.swift; sourceTree = ""; }; 502B6C3C2C9462A400446316 /* PushNotificationRegistrar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationRegistrar.swift; sourceTree = ""; }; 503CA9522D19ACC800805EF8 /* HorizontalLine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalLine.swift; sourceTree = ""; }; + 503CA9782D19C39800805EF8 /* FeedCustomizerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCustomizerView.swift; sourceTree = ""; }; + 503CAAF02D1AFF8400805EF8 /* BeveledContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeveledContainerView.swift; sourceTree = ""; }; + 503CAB4E2D1D8FAF00805EF8 /* FeedController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedController.swift; sourceTree = ""; }; + 503CAB6D2D1DA17100805EF8 /* FeedToggleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedToggleRow.swift; sourceTree = ""; }; + 503CAB7B2D1DA6DB00805EF8 /* Nos 22.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Nos 22.xcdatamodel"; sourceTree = ""; }; + 503CAC602D1EF71700805EF8 /* FeedSourceToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSourceToggleView.swift; sourceTree = ""; }; 5044546D2C90726A00251A7E /* Event+Fetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Event+Fetching.swift"; sourceTree = ""; }; 5044546F2C90728500251A7E /* Event+Hydration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Event+Hydration.swift"; sourceTree = ""; }; 5045540C2C81E10C0044ECAE /* EditableAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableAvatarView.swift; sourceTree = ""; }; @@ -1596,6 +1608,7 @@ C98DC9BA2A795CAD004E5F0F /* ActionBanner.swift */, C9A0DAE929C6A34200466635 /* ActivityView.swift */, 3FFB1D88299FF37C002A755D /* AvatarView.swift */, + 503CAAF02D1AFF8400805EF8 /* BeveledContainerView.swift */, C95D68A0299E6D3E00429F86 /* BioView.swift */, C9DFA968299BEC33006929C1 /* CardStyle.swift */, 0496D6302C975E6900D29375 /* FlagOptionPicker.swift */, @@ -1700,7 +1713,10 @@ C96877B32B4EDCCF0051ED2F /* Home */ = { isa = PBXGroup; children = ( + 503CA9782D19C39800805EF8 /* FeedCustomizerView.swift */, 501728B32D16EFAC00CF2A07 /* FeedPicker.swift */, + 503CAC602D1EF71700805EF8 /* FeedSourceToggleView.swift */, + 503CAB6D2D1DA17100805EF8 /* FeedToggleRow.swift */, C9DEBFD8298941000078B43A /* HomeFeedView.swift */, 5BE281C92AE2CCEB00880466 /* HomeTab.swift */, 03C7E7912CB9C0AF0054624C /* WelcomeToFeedTip.swift */, @@ -2002,6 +2018,7 @@ isa = PBXGroup; children = ( 0357299A2BE415E5005FEE85 /* ContentWarningController.swift */, + 503CAB4E2D1D8FAF00805EF8 /* FeedController.swift */, C913DA0B2AEB2EBF003BDD6D /* FetchRequestPublisher.swift */, C993148C2C5BD8FC00224BA6 /* NoteEditorController.swift */, C913DA092AEAF52B003BDD6D /* NoteWarningController.swift */, @@ -2381,9 +2398,11 @@ C9E8C1152B081EBE002D46B0 /* NIP05View.swift in Sources */, 50E2EB722C86175900D4B360 /* NSRegularExpression+Replacement.swift in Sources */, C92E7F6A2C4EFF7200B80638 /* WebSocketConnection.swift in Sources */, + 503CAB6E2D1DA17400805EF8 /* FeedToggleRow.swift in Sources */, 5BC0D9CC2B867B9D005D6980 /* NamesAPI.swift in Sources */, C987F81D29BA6D9A00B44E7A /* ProfileTab.swift in Sources */, C9ADB14129951CB10075E7F8 /* NSManagedObject+Nos.swift in Sources */, + 503CA9792D19C39F00805EF8 /* FeedCustomizerView.swift in Sources */, C9F84C21298DC36800C6714D /* AppView.swift in Sources */, C9CE5B142A0172CF008E198C /* WebView.swift in Sources */, CD4908D429B92941007443DB /* ReportABugMailView.swift in Sources */, @@ -2395,6 +2414,7 @@ A34E439929A522F20057AFCB /* CurrentUser.swift in Sources */, 045EDCF32CAAF47600B67964 /* FlagSuccessView.swift in Sources */, 03E1812F2C753C9B00886CC6 /* ImageButton.swift in Sources */, + 503CAC612D1EF71B00805EF8 /* FeedSourceToggleView.swift in Sources */, C9A0DADD29C689C900466635 /* NosNavigationBar.swift in Sources */, 3F30020529C1FDD9003D4F8B /* OnboardingStartView.swift in Sources */, C936B4592A4C7B7C00DF1EB9 /* Nos.xcdatamodeld in Sources */, @@ -2554,6 +2574,7 @@ C97465312A3B89140031226F /* AuthorLabel.swift in Sources */, C9C547592A4F1D8C006B0741 /* NosNotification+CoreDataClass.swift in Sources */, 030AE4292BE3D63C004DEE02 /* FeaturedAuthor.swift in Sources */, + 503CAAF12D1AFF8900805EF8 /* BeveledContainerView.swift in Sources */, C9B678E729F01A8500303F33 /* FullscreenProgressView.swift in Sources */, C9F0BB6929A5039D000547FC /* Int+Bool.swift in Sources */, 03E181472C754BA300886CC6 /* LinkView.swift in Sources */, @@ -2583,6 +2604,7 @@ C9BAB09B2996FBA10003A84E /* EventProcessor.swift in Sources */, C9B5C78E2C24AF650070445B /* MockRelaySubscriptionManager.swift in Sources */, C960C57129F3236200929990 /* LikeButton.swift in Sources */, + 503CAB502D1D8FB300805EF8 /* FeedController.swift in Sources */, C97797B9298AA19A0046BD25 /* RelayService.swift in Sources */, 04368D2B2C99A2C400DEAA2E /* FlagOption.swift in Sources */, C99721CB2AEBED26004EBEAB /* String+Empty.swift in Sources */, @@ -2658,6 +2680,7 @@ buildActionMask = 2147483647; files = ( 03F7C4F32C10DF79006FF613 /* URLSessionProtocol.swift in Sources */, + 503CAB4F2D1D8FB300805EF8 /* FeedController.swift in Sources */, 0320C1152BFE63DC00C4C080 /* MockRelaySubscriptionManager.swift in Sources */, C993148E2C5BD8FC00224BA6 /* NoteEditorController.swift in Sources */, 035729CB2BE41770005FEE85 /* ContentWarningController.swift in Sources */, @@ -3896,6 +3919,7 @@ C936B4572A4C7B7C00DF1EB9 /* Nos.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + 503CAB7B2D1DA6DB00805EF8 /* Nos 22.xcdatamodel */, 0303B11E2D0257D400077929 /* Nos 21.xcdatamodel */, 2D3C71A52CEE6F7100625BCB /* Nos 20.xcdatamodel */, C95057C62CC69FD70024EC9C /* Nos 19.xcdatamodel */, @@ -3910,7 +3934,7 @@ C9C547562A4F1D1A006B0741 /* Nos 9.xcdatamodel */, 5BFF66AF2A4B55FC00AA79DD /* Nos 10.xcdatamodel */, ); - currentVersion = 0303B11E2D0257D400077929 /* Nos 21.xcdatamodel */; + currentVersion = 503CAB7B2D1DA6DB00805EF8 /* Nos 22.xcdatamodel */; path = Nos.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/Nos/Controller/FeedController.swift b/Nos/Controller/FeedController.swift new file mode 100644 index 000000000..deb4092a2 --- /dev/null +++ b/Nos/Controller/FeedController.swift @@ -0,0 +1,176 @@ +import Combine +import CoreData +import Dependencies +import SwiftUI + +/// The source to be used for a feed of notes. +enum FeedSource: Hashable, Equatable { + case following + case relay(String, String?) + case list(String, String?) + + var displayName: String { + switch self { + case .following: String(localized: "following") + case .relay(let name, _), .list(let name, _): name + } + } + + var description: String? { + switch self { + case .following: nil + case .relay(_, let description), .list(_, let description): description + } + } + + static func == (lhs: FeedSource, rhs: FeedSource) -> Bool { + switch (lhs, rhs) { + case (.following, .following): true + case (.relay(let name1, _), .relay(let name2, _)): name1 == name2 + case (.list(let name1, _), .list(let name2, _)): name1 == name2 + default: false + } + } +} + +@Observable @MainActor final class FeedController { + + @ObservationIgnored @Dependency(\.persistenceController) private var persistenceController + @ObservationIgnored @Dependency(\.currentUser) private var currentUser + + var enabledSources: [FeedSource] = [.following] + var selectedSource: FeedSource = .following + + private(set) var listRowItems: [FeedToggleRow.Item] = [] + private(set) var relayRowItems: [FeedToggleRow.Item] = [] + + private var lists: [AuthorList] = [] { + didSet { + updateEnabledSources() + } + } + private var relays: [Relay] = [] { + didSet { + updateEnabledSources() + } + } + + private var cancellables = Set() + + init() { + observeLists() + observeRelays() + } + + private func observeLists() { + guard let author = currentUser.author else { + return + } + + let request = NSFetchRequest(entityName: "AuthorList") + request.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] + request.predicate = NSPredicate( + format: "kind = %i AND author = %@ AND title != nil", + EventKind.followSet.rawValue, + author + ) + + let listWatcher = NSFetchedResultsController( + fetchRequest: request, + managedObjectContext: persistenceController.viewContext, + sectionNameKeyPath: nil, + cacheName: "FeedController.listWatcher" + ) + + FetchedResultsControllerPublisher(fetchedResultsController: listWatcher) + .publisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] lists in + self?.lists = lists + }) + .store(in: &cancellables) + } + + private func observeRelays() { + guard let author = currentUser.author else { + return + } + + let request = Relay.relays(for: author) + + let relayWatcher = NSFetchedResultsController( + fetchRequest: request, + managedObjectContext: persistenceController.viewContext, + sectionNameKeyPath: nil, + cacheName: "FeedController.relayWatcher" + ) + + FetchedResultsControllerPublisher(fetchedResultsController: relayWatcher) + .publisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] relays in + self?.relays = relays + }) + .store(in: &cancellables) + } + + private func updateEnabledSources() { + var enabledSources = [FeedSource]() + enabledSources.append(.following) + + var listItems = [FeedToggleRow.Item]() + var relayItems = [FeedToggleRow.Item]() + + for list in lists { + let source = FeedSource.list(list.title ?? "??", nil) + + if list.isFeedEnabled { + enabledSources.append(source) + } + + listItems.append(FeedToggleRow.Item(source: source, isOn: list.isFeedEnabled)) + } + + for relay in relays { + let source = FeedSource.relay(relay.host ?? "", relay.relayDescription) + + if relay.isFeedEnabled { + enabledSources.append(source) + } + + relayItems.append(FeedToggleRow.Item(source: source, isOn: relay.isFeedEnabled)) + } + + self.enabledSources = enabledSources + self.listRowItems = listItems + self.relayRowItems = relayItems + } + + func toggleSourceEnabled(_ source: FeedSource) { + do { + switch source { + case .relay(let address, _): + if let relay = relays.first(where: { $0.host == address }) { + relay.isFeedEnabled.toggle() + try relay.managedObjectContext?.save() + updateEnabledSources() + } + case .list(let title, _): + // TODO: Needs to use replaceableID instead of title + if let list = lists.first(where: { $0.title == title }) { + list.isFeedEnabled.toggle() + try list.managedObjectContext?.save() + updateEnabledSources() + } + default: + break + } + } catch { + print("FeedController: error updating source: \(source), error: \(error)") + } + } + + func isSourceEnabled(_ source: FeedSource) -> Bool { + enabledSources.contains(source) + } +} diff --git a/Nos/Controller/FetchRequestPublisher.swift b/Nos/Controller/FetchRequestPublisher.swift index e847d0dda..aa90b32dd 100644 --- a/Nos/Controller/FetchRequestPublisher.swift +++ b/Nos/Controller/FetchRequestPublisher.swift @@ -3,7 +3,7 @@ import Combine import CoreData /// Create by passing in a FetchedResultsController -/// This will perform the fetch request on the correct queue and publish the resutls on the +/// This will perform the fetch request on the correct queue and publish the results on the /// publishers. /// source: https://gist.github.com/josephlord/0d6a9d0871bd2e1b3a3bdbf20c184f88 /// diff --git a/Nos/Models/CoreData/Generated/AuthorList+CoreDataProperties.swift b/Nos/Models/CoreData/Generated/AuthorList+CoreDataProperties.swift index 71c58f3e7..1cd75f9de 100644 --- a/Nos/Models/CoreData/Generated/AuthorList+CoreDataProperties.swift +++ b/Nos/Models/CoreData/Generated/AuthorList+CoreDataProperties.swift @@ -22,6 +22,9 @@ extension AuthorList { /// The set of unique authors in this list. @NSManaged public var authors: Set + + /// Whether or not this list should be visible in the ``FeedPicker``. + @NSManaged public var isFeedEnabled: Bool } // MARK: Generated accessors for authors diff --git a/Nos/Models/CoreData/Generated/Relay+CoreDataProperties.swift b/Nos/Models/CoreData/Generated/Relay+CoreDataProperties.swift index 5e3036e9d..0782bd387 100644 --- a/Nos/Models/CoreData/Generated/Relay+CoreDataProperties.swift +++ b/Nos/Models/CoreData/Generated/Relay+CoreDataProperties.swift @@ -14,6 +14,8 @@ extension Relay { @NSManaged public var events: Set @NSManaged public var publishedEvents: Set @NSManaged public var shouldBePublishedEvents: Set + /// Whether or not this relay should be visible in the ``FeedPicker``. + @NSManaged public var isFeedEnabled: Bool // Metadata @NSManaged public var name: String? diff --git a/Nos/Models/CoreData/Nos.xcdatamodeld/.xccurrentversion b/Nos/Models/CoreData/Nos.xcdatamodeld/.xccurrentversion index eb66b2858..5b7d17353 100644 --- a/Nos/Models/CoreData/Nos.xcdatamodeld/.xccurrentversion +++ b/Nos/Models/CoreData/Nos.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Nos 21.xcdatamodel + Nos 22.xcdatamodel diff --git a/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 22.xcdatamodel/.xccurrentversion b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 22.xcdatamodel/.xccurrentversion new file mode 100644 index 000000000..6c8a1eef9 --- /dev/null +++ b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 22.xcdatamodel/.xccurrentversion @@ -0,0 +1,8 @@ + + + + + _XCCurrentVersionName + Nos.xcdatamodel + + diff --git a/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 22.xcdatamodel/Nos.xcdatamodel/contents b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 22.xcdatamodel/Nos.xcdatamodel/contents new file mode 100644 index 000000000..1a418ef2c --- /dev/null +++ b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 22.xcdatamodel/Nos.xcdatamodel/contents @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 22.xcdatamodel/contents b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 22.xcdatamodel/contents new file mode 100644 index 000000000..3193d833e --- /dev/null +++ b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 22.xcdatamodel/contents @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Nos/Views/Components/BeveledContainerView.swift b/Nos/Views/Components/BeveledContainerView.swift new file mode 100644 index 000000000..746f0b7e8 --- /dev/null +++ b/Nos/Views/Components/BeveledContainerView.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct BeveledContainerView: View { + let content: () -> Content + + var topColor: Color = .buttonBevelBottom + var bottomColor: Color = .panelBevelBottom + + var body: some View { + VStack(spacing: 0) { + HorizontalLine(color: topColor) + + content() + + HorizontalLine(color: bottomColor, height: 1 / UIScreen.main.scale) + + HorizontalLine(color: .black, height: 1 / UIScreen.main.scale) + } + } +} diff --git a/Nos/Views/Home/FeedCustomizerView.swift b/Nos/Views/Home/FeedCustomizerView.swift new file mode 100644 index 000000000..bafc21e88 --- /dev/null +++ b/Nos/Views/Home/FeedCustomizerView.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct FeedCustomizerView: View { + + @Environment(FeedController.self) var feedController + let author: Author + @Binding var shouldNavigateToRelays: Bool + + @AppStorage("selectedFeedTogglesTab") private var selectedTab = "Lists" + + var body: some View { + VStack(spacing: 0) { + BeveledContainerView { + Picker("", selection: $selectedTab) { + Text("Lists").tag("Lists") + Text("Relays").tag("Relays") + } + .pickerStyle(.segmented) + .padding(.vertical, 10) + .padding(.horizontal, 16) + } + + if selectedTab == "Lists" { + FeedSourceToggleView( + author: author, + headerText: Text("Add lists to your feed to filter by topic."), + items: feedController.listRowItems, + footer: { + Group { + Text("Create your own lists on ") + + Text("Listr 🔗") + .foregroundStyle(Color.accent) + } + .padding() + .onTapGesture { + if let url = URL(string: "https://listr.lol/feed") { + UIApplication.shared.open(url) + } + } + } + ) + } else { + FeedSourceToggleView( + author: author, + headerText: Text("Select relays to show on your feed."), + items: feedController.relayRowItems, + footer: { + Group { + Text("Manage these on the ") + + Text("Relays") + .foregroundStyle(Color.accent) + + Text(" screen") + } + .padding() + .onTapGesture { + shouldNavigateToRelays = true + } + } + ) + } + } + .background( + Rectangle() + .foregroundStyle(LinearGradient.cardBackground) + .cornerRadius(20, corners: [.bottomLeft, .bottomRight]) + .shadow(radius: 15, y: 10) + ) + .readabilityPadding() + .frame(height: 400) + } +} diff --git a/Nos/Views/Home/FeedPicker.swift b/Nos/Views/Home/FeedPicker.swift index 0f1da5f31..127f963d9 100644 --- a/Nos/Views/Home/FeedPicker.swift +++ b/Nos/Views/Home/FeedPicker.swift @@ -1,58 +1,27 @@ import CoreData import SwiftUI -/// The source to be used for a feed of notes. -enum FeedSource: Hashable, Equatable { - case following - case relay(String) - case list(String) - - var displayName: String { - switch self { - case .following: String(localized: "following") - case .relay(let name), .list(let name): name - } - } - - static func == (lhs: FeedSource, rhs: FeedSource) -> Bool { - switch (lhs, rhs) { - case (.following, .following): true - case (.relay(let name1), .relay(let name2)): name1 == name2 - case (.list(let name1), .list(let name2)): name1 == name2 - default: false - } - } -} - /// A picker view used to pick which source a feed should show notes from. struct FeedPicker: View { - @Binding var selectedSource: FeedSource - - @FetchRequest var relays: FetchedResults - - init(author: Author, selectedSource: Binding) { - _selectedSource = selectedSource - _relays = FetchRequest(fetchRequest: Relay.relays(for: author)) - } + @Environment(FeedController.self) var feedController var body: some View { - VStack(spacing: 0) { - HorizontalLine(color: .buttonBevelBottom) - + BeveledContainerView { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 0) { - ForEach(allSources, id: \.self) { source in + ForEach(feedController.enabledSources, id: \.self) { source in Button(action: { withAnimation(nil) { - selectedSource = source + feedController.selectedSource = source } }, label: { + let isSelected = feedController.selectedSource == source Text(source.displayName) - .font(.system(size: 16, weight: selectedSource == source ? .medium : .regular)) + .font(.system(size: 16, weight: isSelected ? .medium : .regular)) .padding(.horizontal, 10) .padding(.vertical, 4) - .background(selectedSource == source ? Color.pickerBackgroundSelected : Color.clear) - .foregroundStyle(selectedSource == source ? Color.white : Color.secondaryTxt) + .background(isSelected ? Color.pickerBackgroundSelected : Color.clear) + .foregroundStyle(isSelected ? Color.white : Color.secondaryTxt) .clipShape(Capsule()) }) } @@ -60,19 +29,7 @@ struct FeedPicker: View { .padding(.horizontal, 8) } .frame(height: 40) - - HorizontalLine(color: .panelBevelBottom, height: 1 / UIScreen.main.scale) - - HorizontalLine(color: .black, height: 1 / UIScreen.main.scale) } .background(Color.cardBgTop) } - - private var allSources: [FeedSource] { - var sources = [FeedSource]() - sources.append(.following) - sources.append(contentsOf: relays.map { FeedSource.relay($0.host!) }) - // TODO: Add lists - return sources - } } diff --git a/Nos/Views/Home/FeedSourceToggleView.swift b/Nos/Views/Home/FeedSourceToggleView.swift new file mode 100644 index 000000000..87d86cf0a --- /dev/null +++ b/Nos/Views/Home/FeedSourceToggleView.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct FeedSourceToggleView: View { + @Environment(FeedController.self) var feedController + + let author: Author + let headerText: Text + let items: [FeedToggleRow.Item] + let footer: () -> Content + + init( + author: Author, + headerText: Text, + items: [FeedToggleRow.Item], + @ViewBuilder footer: @escaping () -> Content + ) { + self.author = author + self.headerText = headerText + self.items = items + self.footer = footer + } + + var body: some View { + VStack(spacing: 0) { + HorizontalLine(color: .buttonBevelBottom) + + HStack { + Image(systemName: "lightbulb.max.fill") + + headerText + .font(.clarity(.medium)) + + Spacer() + } + .foregroundStyle(Color.primaryTxt) + .padding() + + let rows = Group { + ForEach(items) { item in + VStack(spacing: 0) { + BeveledSeparator() + + FeedToggleRow(item: item) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 4) + .onChange(of: item.isOn) { _, _ in + feedController.toggleSourceEnabled(item.source) + } + } + } + + BeveledSeparator() + } + .padding(.horizontal, 16) + + ViewThatFits(in: .vertical) { + VStack { + rows + Spacer() + } + + ScrollView { + rows + } + } + .geometryGroup() + + footer() + } + } +} diff --git a/Nos/Views/Home/FeedToggleRow.swift b/Nos/Views/Home/FeedToggleRow.swift new file mode 100644 index 000000000..45145104b --- /dev/null +++ b/Nos/Views/Home/FeedToggleRow.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct FeedToggleRow: View { + + @Observable final class Item: Identifiable { + let id = UUID() + let source: FeedSource + var isOn: Bool + + init(source: FeedSource, isOn: Bool) { + self.source = source + self.isOn = isOn + } + } + + let item: Item + + var body: some View { + HStack { + VStack(spacing: 2) { + HStack { + Text(item.source.displayName) + .foregroundColor(.primaryTxt) + .font(.clarity(.bold)) + .lineLimit(1) + .shadow(radius: 4, y: 4) + Spacer() + } + + if let description = item.source.description { + HStack { + Text(description) + .font(.clarity(.medium, textStyle: .callout)) + .multilineTextAlignment(.leading) + .foregroundColor(.secondaryTxt) + .lineLimit(1) + .truncationMode(.tail) + Spacer() + } + } + } + + Toggle("", isOn: Binding(get: { item.isOn }, set: { item.isOn = $0 })) + .labelsHidden() + .tint(.green) + } + } +} diff --git a/Nos/Views/Home/HomeFeedView.swift b/Nos/Views/Home/HomeFeedView.swift index dfebb453d..1e4a759bf 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -12,11 +12,14 @@ struct HomeFeedView: View { @State private var refreshController = RefreshController(lastRefreshDate: Date.now + Self.staticLoadTime) @State private var isVisible = false + @State private var feedController = FeedController() /// When set to true this will display a fullscreen progress wheel for a set amount of time to give us a chance /// to get some data from relay. The amount of time is defined in `staticLoadTime`. @State private var showTimedLoadingIndicator = true + @State private var shouldNavigateToRelaysOnAppear = false + /// The amount of time (in seconds) the loading indicator will be shown when showTimedLoadingIndicator is set to /// true. static let staticLoadTime: TimeInterval = 2 @@ -78,7 +81,7 @@ struct HomeFeedView: View { .tipBackground(LinearGradient.horizontalAccentReversed) .tipViewStyle(.inline) - FeedPicker(author: user, selectedSource: $pickerSelected) + FeedPicker() .padding(.bottom, -stackSpacing) // remove the padding below the picker PagedNoteListView( @@ -114,21 +117,22 @@ struct HomeFeedView: View { } if showRelayPicker { - RelayPicker( - selectedRelay: $selectedRelay, - defaultSelection: String(localized: "accountsIFollow"), - author: user, - isPresented: $showRelayPicker - ) - .onChange(of: selectedRelay) { _, _ in - showTimedLoadingIndicator = true - refreshController.lastRefreshDate = .now + Self.staticLoadTime - Task { - withAnimation { + Color.black.opacity(0.5) + .ignoresSafeArea() + .onTapGesture { + // Close on tap + withAnimation(.easeInOut(duration: 0.3)) { showRelayPicker = false } } + .transition(.opacity) + + VStack { + FeedCustomizerView(author: user, shouldNavigateToRelays: $shouldNavigateToRelaysOnAppear) + Spacer() } + .transition(.move(edge: .top)) + .zIndex(99) // Fixes dismissal animation } } .doubleTapToPop(tab: .home) { _ in @@ -155,17 +159,19 @@ struct HomeFeedView: View { showRelayPicker.toggle() } } label: { - Image(systemName: "line.3.horizontal.decrease.circle") + Image(systemName: showRelayPicker ? "xmark.circle.fill" : "line.3.horizontal.decrease.circle") .foregroundStyle(Color.secondaryTxt) .accessibilityLabel("filter") } .frame(minWidth: 40, minHeight: 40) } } + .animation(.easeOut, value: showRelayPicker) .toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(Color.cardBgBottom, for: .navigationBar) .navigationBarTitle("", displayMode: .inline) .padding(.top, 1) + .environment(feedController) .onAppear { if router.selectedTab == .home { isVisible = true @@ -178,6 +184,20 @@ struct HomeFeedView: View { GoToFeedTip.viewedFeed.sendDonation() } } + .onChange(of: shouldNavigateToRelaysOnAppear) { + if shouldNavigateToRelaysOnAppear { + showRelayPicker = false + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) { + router.push(RelaysDestination(author: user, relays: [])) + } + + shouldNavigateToRelaysOnAppear = false + } + } + .navigationDestination(for: RelaysDestination.self) { destination in + RelayView(author: destination.author) + } } }