From 1747c6bc61ae9a6a308b7fe664ac010b53b36e3e Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Mon, 23 Dec 2024 08:51:19 -0600 Subject: [PATCH 01/26] add feed picker view (UI only) #103 --- CHANGELOG.md | 1 + Nos.xcodeproj/project.pbxproj | 8 ++ .../panel-bevel-bottom.colorset/Contents.json | 20 +++++ Nos/Views/Components/HorizontalLine.swift | 17 ++++ Nos/Views/Home/FeedPicker.swift | 78 +++++++++++++++++++ Nos/Views/Home/HomeFeedView.swift | 15 +++- 6 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 Nos/Assets/Colors.xcassets/panel-bevel-bottom.colorset/Contents.json create mode 100644 Nos/Views/Components/HorizontalLine.swift create mode 100644 Nos/Views/Home/FeedPicker.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f3e88a3..cd1c90e62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed display of mastodon usernames so it shows @username@server.instance rather than username@instance-name.mostr.pub - 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) ### 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 b1cf16735..3d0ce5594 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -197,7 +197,9 @@ 50089A0C2C97182200834588 /* CurrentUser+PublishEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50089A0B2C97182200834588 /* CurrentUser+PublishEvents.swift */; }; 50089A0D2C97182200834588 /* CurrentUser+PublishEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50089A0B2C97182200834588 /* CurrentUser+PublishEvents.swift */; }; 50089A172C98678600834588 /* View+ListRowGradientBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50089A162C98678600834588 /* View+ListRowGradientBackground.swift */; }; + 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 */; }; 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 */; }; @@ -755,7 +757,9 @@ 50089A002C9712EF00834588 /* JSONEvent+Kinds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSONEvent+Kinds.swift"; sourceTree = ""; }; 50089A0B2C97182200834588 /* CurrentUser+PublishEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CurrentUser+PublishEvents.swift"; sourceTree = ""; }; 50089A162C98678600834588 /* View+ListRowGradientBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ListRowGradientBackground.swift"; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; @@ -1599,6 +1603,7 @@ C9CDBBA329A8FA2900C555C7 /* GoldenPostView.swift */, C930E0562BA49DAD002B5776 /* GridPattern.swift */, C9A0DAE329C69F0C00466635 /* HighlightedText.swift */, + 503CA9522D19ACC800805EF8 /* HorizontalLine.swift */, 2D4010A12AD87DF300F93AD4 /* KnownFollowersView.swift */, C9A0DADC29C689C900466635 /* NosNavigationBar.swift */, C97B28892C10B07100DC1FC0 /* NosNavigationStack.swift */, @@ -1695,6 +1700,7 @@ C96877B32B4EDCCF0051ED2F /* Home */ = { isa = PBXGroup; children = ( + 501728B32D16EFAC00CF2A07 /* FeedPicker.swift */, C9DEBFD8298941000078B43A /* HomeFeedView.swift */, 5BE281C92AE2CCEB00880466 /* HomeTab.swift */, 03C7E7912CB9C0AF0054624C /* WelcomeToFeedTip.swift */, @@ -2560,6 +2566,7 @@ C9CDBBA429A8FA2900C555C7 /* GoldenPostView.swift in Sources */, C92F01582AC4D6F700972489 /* NosTextField.swift in Sources */, C9C2B77C29E072E400548B4A /* WebSocket+Nos.swift in Sources */, + 503CA9532D19ACCC00805EF8 /* HorizontalLine.swift in Sources */, C9DEC003298945150078B43A /* String+Lorem.swift in Sources */, 04C9D7912CC29D5000EAAD4D /* FeaturedAuthor+Cohort1.swift in Sources */, 038EF09D2CC16D640031F7F2 /* PrivateKeyView.swift in Sources */, @@ -2616,6 +2623,7 @@ C92F01552AC4D6CF00972489 /* BeveledSeparator.swift in Sources */, 50DE6B1B2C6B88FE0065665D /* View+StyledBorder.swift in Sources */, C93EC2FD29C3785C0012EE2A /* View+RoundedCorner.swift in Sources */, + 501728B42D16EFB000CF2A07 /* FeedPicker.swift in Sources */, 5B503F622A291A1A0098805A /* JSONRelayMetadata.swift in Sources */, C98298332ADD7F9A0096C5B5 /* DeepLinkService.swift in Sources */, 03F7C4F42C10E05B006FF613 /* URLSessionProtocol.swift in Sources */, diff --git a/Nos/Assets/Colors.xcassets/panel-bevel-bottom.colorset/Contents.json b/Nos/Assets/Colors.xcassets/panel-bevel-bottom.colorset/Contents.json new file mode 100644 index 000000000..47202903b --- /dev/null +++ b/Nos/Assets/Colors.xcassets/panel-bevel-bottom.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x18", + "green" : "0x18", + "red" : "0x18" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Nos/Views/Components/HorizontalLine.swift b/Nos/Views/Components/HorizontalLine.swift new file mode 100644 index 000000000..0f49b2ce5 --- /dev/null +++ b/Nos/Views/Components/HorizontalLine.swift @@ -0,0 +1,17 @@ +import SwiftUI + +/// A thin horizontal line with the provided color. +/// +/// The default thickness of the line is 1 point, but it can be overridden, +/// such as to make it 1 pixel (e.g. `1 / UIScreen.main.scale`). +struct HorizontalLine: View { + let color: Color + var height: CGFloat = 1 + + var body: some View { + Rectangle() + .frame(height: height) + .frame(maxWidth: .infinity) + .foregroundStyle(color) + } +} diff --git a/Nos/Views/Home/FeedPicker.swift b/Nos/Views/Home/FeedPicker.swift new file mode 100644 index 000000000..0f1da5f31 --- /dev/null +++ b/Nos/Views/Home/FeedPicker.swift @@ -0,0 +1,78 @@ +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)) + } + + var body: some View { + VStack(spacing: 0) { + HorizontalLine(color: .buttonBevelBottom) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(allSources, id: \.self) { source in + Button(action: { + withAnimation(nil) { + selectedSource = source + } + }, label: { + Text(source.displayName) + .font(.system(size: 16, weight: selectedSource == source ? .medium : .regular)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(selectedSource == source ? Color.pickerBackgroundSelected : Color.clear) + .foregroundStyle(selectedSource == source ? Color.white : Color.secondaryTxt) + .clipShape(Capsule()) + }) + } + } + .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/HomeFeedView.swift b/Nos/Views/Home/HomeFeedView.swift index d36e73fc1..ca0b7df90 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -27,7 +27,8 @@ struct HomeFeedView: View { let welcomeTip = WelcomeToFeedTip() @State private var showRelayPicker = false - @State private var selectedRelay: Relay? + @State private var selectedRelay: Relay? + @State private var pickerSelected = FeedSource.following init(user: Author) { self.user = user @@ -75,6 +76,8 @@ struct HomeFeedView: View { .tipBackground(LinearGradient.horizontalAccentReversed) .tipViewStyle(.inline) + FeedPicker(author: user, selectedSource: $pickerSelected) + PagedNoteListView( refreshController: $refreshController, databaseFilter: homeFeedFetchRequest, @@ -137,6 +140,12 @@ struct HomeFeedView: View { ToolbarItem(placement: .navigationBarLeading) { SideMenuButton() } + ToolbarItem(placement: .principal) { + Image.nosLogo + .resizable() + .scaledToFit() + .frame(height: 20) + } ToolbarItem(placement: .navigationBarTrailing) { Button { withAnimation { @@ -150,8 +159,10 @@ struct HomeFeedView: View { .frame(minWidth: 40, minHeight: 40) } } + .toolbarBackground(.visible, for: .navigationBar) + .toolbarBackground(Color.cardBgBottom, for: .navigationBar) + .navigationBarTitle("", displayMode: .inline) .padding(.top, 1) - .nosNavigationBar(navigationBarTitle) .onAppear { if router.selectedTab == .home { isVisible = true From 52b3c37a867843b57a1ede0f6a32ebcd65d81234 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Mon, 23 Dec 2024 09:04:41 -0600 Subject: [PATCH 02/26] remove padding below the picker --- Nos/Views/Home/HomeFeedView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Nos/Views/Home/HomeFeedView.swift b/Nos/Views/Home/HomeFeedView.swift index ca0b7df90..dfebb453d 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -20,6 +20,8 @@ struct HomeFeedView: View { /// The amount of time (in seconds) the loading indicator will be shown when showTimedLoadingIndicator is set to /// true. static let staticLoadTime: TimeInterval = 2 + + private let stackSpacing: CGFloat = 8 let user: Author @@ -68,7 +70,7 @@ struct HomeFeedView: View { var body: some View { ZStack { - VStack(spacing: 8) { + VStack(spacing: stackSpacing) { TipView(welcomeTip) .padding(.top, 20) .padding(.horizontal, 16) @@ -77,6 +79,7 @@ struct HomeFeedView: View { .tipViewStyle(.inline) FeedPicker(author: user, selectedSource: $pickerSelected) + .padding(.bottom, -stackSpacing) // remove the padding below the picker PagedNoteListView( refreshController: $refreshController, From a1da24b1ecc7e30e09d725b162696e897159bf90 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Mon, 23 Dec 2024 09:47:59 -0600 Subject: [PATCH 03/26] enable automatic TestFlight staging builds for feature/lists-ui branch --- .github/workflows/testflight-staging-deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/testflight-staging-deploy.yml b/.github/workflows/testflight-staging-deploy.yml index b5fa4b6a7..54fd478f0 100644 --- a/.github/workflows/testflight-staging-deploy.yml +++ b/.github/workflows/testflight-staging-deploy.yml @@ -3,6 +3,7 @@ on: push: branches: - main + - feature/lists-ui # Enable manual run workflow_dispatch: From 77d5ceacc855458ea43598e99bf9777bd974922a Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 23 Dec 2024 16:55:18 -0500 Subject: [PATCH 04/26] Allow Github actions to update provisioning profiles --- fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 9e6b9cec2..e3fa68852 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -50,7 +50,7 @@ platform :ios do ) match( type: "appstore", - readonly: true, + readonly: false, app_identifier: options[:appidentifier], keychain_name: "keychain", keychain_password: keychain_pass From a6712eb2258460da1eb4cdd474d2687764b8928f Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Fri, 27 Dec 2024 09:22:06 -0600 Subject: [PATCH 05/26] 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) + } } } From 69479ffb3e5349bc644a1f0d7f96409cf94026b0 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Fri, 27 Dec 2024 09:35:38 -0600 Subject: [PATCH 06/26] fix test target membership --- Nos.xcodeproj/project.pbxproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/Nos.xcodeproj/project.pbxproj b/Nos.xcodeproj/project.pbxproj index b286d76e1..7f36dd904 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -202,7 +202,6 @@ 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 */; }; @@ -2680,7 +2679,6 @@ 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 */, From a52cdd03faad4ac4540fb591cc9ea2a113b640f5 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Sat, 28 Dec 2024 07:52:40 -0600 Subject: [PATCH 07/26] make feed source selector work --- CHANGELOG.md | 1 + Nos/Controller/FeedController.swift | 24 +++++++++- Nos/Models/CoreData/Event+Fetching.swift | 14 ++++-- Nos/Views/Home/HomeFeedView.swift | 56 ++++++++++-------------- 4 files changed, 58 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 734dd933f..67f0e3b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) +- Make feed source selector work. ### Internal Changes - Upgraded to Xcode 16. [#1570](https://github.com/planetary-social/nos/issues/1570) diff --git a/Nos/Controller/FeedController.swift b/Nos/Controller/FeedController.swift index deb4092a2..5d4464f67 100644 --- a/Nos/Controller/FeedController.swift +++ b/Nos/Controller/FeedController.swift @@ -39,7 +39,29 @@ enum FeedSource: Hashable, Equatable { @ObservationIgnored @Dependency(\.currentUser) private var currentUser var enabledSources: [FeedSource] = [.following] - var selectedSource: FeedSource = .following + + private(set) var selectedList: AuthorList? + private(set) var selectedRelay: Relay? + var selectedSource: FeedSource = .following { + didSet { + switch selectedSource { + case .relay(let address, _): + if let relay = relays.first(where: { $0.host == address }) { + selectedRelay = relay + selectedList = nil + } + case .list(let title, _): + // TODO: Needs to use replaceableID instead of title + if let list = lists.first(where: { $0.title == title }) { + selectedList = list + selectedRelay = nil + } + default: + selectedList = nil + selectedRelay = nil + } + } + } private(set) var listRowItems: [FeedToggleRow.Item] = [] private(set) var relayRowItems: [FeedToggleRow.Item] = [] diff --git a/Nos/Models/CoreData/Event+Fetching.swift b/Nos/Models/CoreData/Event+Fetching.swift index ea5ea56fd..6bd48be25 100644 --- a/Nos/Models/CoreData/Event+Fetching.swift +++ b/Nos/Models/CoreData/Event+Fetching.swift @@ -321,7 +321,8 @@ extension Event { for user: Author, before: Date? = nil, after: Date? = nil, - seenOn relay: Relay? = nil + seenOn relay: Relay? = nil, + from authors: Set? = nil ) -> NSPredicate { let kind1Predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ NSPredicate(format: "kind = 1"), @@ -366,6 +367,12 @@ extension Event { NSPredicate(format: "(ANY author.followers.source = %@ OR author = %@)", user, user) ) } + + if let authors { + andPredicates.append( + NSPredicate(format: "author IN %@", authors) + ) + } return NSCompoundPredicate(andPredicateWithSubpredicates: andPredicates) } @@ -373,11 +380,12 @@ extension Event { @nonobjc public class func homeFeed( for user: Author, before: Date, - seenOn relay: Relay? = nil + seenOn relay: Relay? = nil, + from authors: Set? = nil ) -> NSFetchRequest { let fetchRequest = NSFetchRequest(entityName: "Event") fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] - fetchRequest.predicate = homeFeedPredicate(for: user, before: before, seenOn: relay) + fetchRequest.predicate = homeFeedPredicate(for: user, before: before, seenOn: relay, from: authors) return fetchRequest } diff --git a/Nos/Views/Home/HomeFeedView.swift b/Nos/Views/Home/HomeFeedView.swift index 1e4a759bf..1af04f8e1 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -22,53 +22,44 @@ struct HomeFeedView: View { /// The amount of time (in seconds) the loading indicator will be shown when showTimedLoadingIndicator is set to /// true. - static let staticLoadTime: TimeInterval = 2 + private static let staticLoadTime: TimeInterval = 2 private let stackSpacing: CGFloat = 8 let user: Author /// A tip to display at the top of the feed. - let welcomeTip = WelcomeToFeedTip() + private let welcomeTip = WelcomeToFeedTip() - @State private var showRelayPicker = false - @State private var selectedRelay: Relay? - @State private var pickerSelected = FeedSource.following - - init(user: Author) { - self.user = user - } + @State private var showFeedSelector = false - var homeFeedFetchRequest: NSFetchRequest { + private var homeFeedFetchRequest: NSFetchRequest { Event.homeFeed( for: user, before: refreshController.lastRefreshDate, - seenOn: selectedRelay + seenOn: feedController.selectedRelay, + from: feedController.selectedList?.authors ) } - var newNotesRequest: NSFetchRequest { + private var newNotesRequest: NSFetchRequest { Event.homeFeed( for: user, after: refreshController.lastRefreshDate, - seenOn: selectedRelay + seenOn: feedController.selectedRelay ) } - var homeFeedFilter: Filter { + private var homeFeedFilter: Filter { var filter = Filter(kinds: [.text, .delete, .repost, .longFormContent, .report]) - if selectedRelay == nil { - filter.authorKeys = user.followedKeys.sorted() - } - return filter - } - - var navigationBarTitle: LocalizedStringKey { - if let relayName = selectedRelay?.host { - LocalizedStringKey(stringLiteral: relayName) - } else { - "accountsIFollow" + if feedController.selectedRelay == nil { + if let list = feedController.selectedList { + filter.authorKeys = list.authors.compactMap { $0.hexadecimalPublicKey }.filter { $0.isValid } + } else { + filter.authorKeys = user.followedKeys.sorted() + } } + return filter } var body: some View { @@ -88,7 +79,7 @@ struct HomeFeedView: View { refreshController: $refreshController, databaseFilter: homeFeedFetchRequest, relayFilter: homeFeedFilter, - relay: selectedRelay, + relay: feedController.selectedRelay, managedObjectContext: viewContext, tab: .home, header: { @@ -116,13 +107,12 @@ struct HomeFeedView: View { ) } - if showRelayPicker { + if showFeedSelector { Color.black.opacity(0.5) .ignoresSafeArea() .onTapGesture { - // Close on tap withAnimation(.easeInOut(duration: 0.3)) { - showRelayPicker = false + showFeedSelector = false } } .transition(.opacity) @@ -156,17 +146,17 @@ struct HomeFeedView: View { ToolbarItem(placement: .navigationBarTrailing) { Button { withAnimation { - showRelayPicker.toggle() + showFeedSelector.toggle() } } label: { - Image(systemName: showRelayPicker ? "xmark.circle.fill" : "line.3.horizontal.decrease.circle") + Image(systemName: showFeedSelector ? "xmark.circle.fill" : "line.3.horizontal.decrease.circle") .foregroundStyle(Color.secondaryTxt) .accessibilityLabel("filter") } .frame(minWidth: 40, minHeight: 40) } } - .animation(.easeOut, value: showRelayPicker) + .animation(.easeOut, value: showFeedSelector) .toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(Color.cardBgBottom, for: .navigationBar) .navigationBarTitle("", displayMode: .inline) @@ -186,7 +176,7 @@ struct HomeFeedView: View { } .onChange(of: shouldNavigateToRelaysOnAppear) { if shouldNavigateToRelaysOnAppear { - showRelayPicker = false + showFeedSelector = false DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) { router.push(RelaysDestination(author: user, relays: [])) From e14b85ba5ef8a69750424eff185a8ad282172cf5 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Sat, 28 Dec 2024 08:54:15 -0600 Subject: [PATCH 08/26] add empty state for lists/relays drop-down --- CHANGELOG.md | 1 + Nos/Views/Home/FeedCustomizerView.swift | 16 ++++-- Nos/Views/Home/FeedSourceToggleView.swift | 61 +++++++++++++---------- Nos/Views/Home/HomeFeedView.swift | 6 +-- 4 files changed, 51 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f0e3b7c..36b4f99b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) - Make feed source selector work. +- Add empty state for lists/relays drop-down. ### Internal Changes - Upgraded to Xcode 16. [#1570](https://github.com/planetary-social/nos/issues/1570) diff --git a/Nos/Views/Home/FeedCustomizerView.swift b/Nos/Views/Home/FeedCustomizerView.swift index bafc21e88..841ad8311 100644 --- a/Nos/Views/Home/FeedCustomizerView.swift +++ b/Nos/Views/Home/FeedCustomizerView.swift @@ -28,7 +28,9 @@ struct FeedCustomizerView: View { footer: { Group { Text("Create your own lists on ") + - Text("Listr 🔗") + Text("Listr ") + .foregroundStyle(Color.accent) + + Text(Image(systemName: "link")) .foregroundStyle(Color.accent) } .padding() @@ -37,24 +39,30 @@ struct FeedCustomizerView: View { UIApplication.shared.open(url) } } + }, + noContent: { + Text("It doesn’t look like you have created any lists.") // TODO: localize } ) } else { FeedSourceToggleView( author: author, - headerText: Text("Select relays to show on your feed."), + headerText: Text("Select relays to show on your feed."), // TODO: localize items: feedController.relayRowItems, footer: { Group { Text("Manage these on the ") + - Text("Relays") - .foregroundStyle(Color.accent) + + (Text("Relays") + .foregroundStyle(Color.accent)) + Text(" screen") } .padding() .onTapGesture { shouldNavigateToRelays = true } + }, + noContent: { + Text("It doesn’t look like you have any relays.") // TODO: localize } ) } diff --git a/Nos/Views/Home/FeedSourceToggleView.swift b/Nos/Views/Home/FeedSourceToggleView.swift index 87d86cf0a..a5605f92a 100644 --- a/Nos/Views/Home/FeedSourceToggleView.swift +++ b/Nos/Views/Home/FeedSourceToggleView.swift @@ -1,23 +1,26 @@ import SwiftUI -struct FeedSourceToggleView: View { +struct FeedSourceToggleView: View { @Environment(FeedController.self) var feedController let author: Author let headerText: Text let items: [FeedToggleRow.Item] let footer: () -> Content + let noContent: () -> EmptyPlaceholder init( author: Author, headerText: Text, items: [FeedToggleRow.Item], - @ViewBuilder footer: @escaping () -> Content + @ViewBuilder footer: @escaping () -> Content, + @ViewBuilder noContent: @escaping () -> EmptyPlaceholder ) { self.author = author self.headerText = headerText self.items = items self.footer = footer + self.noContent = noContent } var body: some View { @@ -35,35 +38,41 @@ struct FeedSourceToggleView: View { .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) - } + if !items.isEmpty { + 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) - BeveledSeparator() - } - .padding(.horizontal, 16) - - ViewThatFits(in: .vertical) { - VStack { - rows - Spacer() - } - - ScrollView { - rows + ViewThatFits(in: .vertical) { + VStack { + rows + Spacer() + } + + ScrollView { + rows + } } + .geometryGroup() + } else { + Spacer() + noContent() + Spacer() } - .geometryGroup() footer() } diff --git a/Nos/Views/Home/HomeFeedView.swift b/Nos/Views/Home/HomeFeedView.swift index 1af04f8e1..135a3ecf7 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -65,6 +65,9 @@ struct HomeFeedView: View { var body: some View { ZStack { VStack(spacing: stackSpacing) { + FeedPicker() + .padding(.bottom, -stackSpacing) // remove the padding below the picker + TipView(welcomeTip) .padding(.top, 20) .padding(.horizontal, 16) @@ -72,9 +75,6 @@ struct HomeFeedView: View { .tipBackground(LinearGradient.horizontalAccentReversed) .tipViewStyle(.inline) - FeedPicker() - .padding(.bottom, -stackSpacing) // remove the padding below the picker - PagedNoteListView( refreshController: $refreshController, databaseFilter: homeFeedFetchRequest, From 39bef4c528cfbbe51cf5681fdca8b6b65770a96e Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Sun, 29 Dec 2024 08:57:20 -0600 Subject: [PATCH 09/26] added support for decrypting private tags in kind 30000 lists --- CHANGELOG.md | 1 + Nos.xcodeproj/project.pbxproj | 8 +- .../CoreData/AuthorList+CoreDataClass.swift | 29 ++++- .../AuthorList+CoreDataProperties.swift | 15 +++ .../Nos.xcdatamodeld/.xccurrentversion | 2 +- .../Nos 23.xcdatamodel/.xccurrentversion | 8 ++ .../Nos.xcdatamodel/contents | 56 +++++++++ .../Nos 23.xcdatamodel/contents | 116 ++++++++++++++++++ Nos/Service/EventProcessor.swift | 20 ++- Nos/Service/Relay/RelayService.swift | 3 +- ...tProcessorIntegrationTests+FollowSet.swift | 37 ++++++ .../Fixtures/follow_set_private.json | 22 ++++ 12 files changed, 309 insertions(+), 8 deletions(-) create mode 100644 Nos/Models/CoreData/Nos.xcdatamodeld/Nos 23.xcdatamodel/.xccurrentversion create mode 100644 Nos/Models/CoreData/Nos.xcdatamodeld/Nos 23.xcdatamodel/Nos.xcdatamodel/contents create mode 100644 Nos/Models/CoreData/Nos.xcdatamodeld/Nos 23.xcdatamodel/contents create mode 100644 NosTests/IntegrationTests/Fixtures/follow_set_private.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 36b4f99b2..6450ef61f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added feed source customizer drop-down view. [#102](https://github.com/verse-pbc/issues/issues/102) - Make feed source selector work. - Add empty state for lists/relays drop-down. +- Added support for decrypting private tags in kind 30000 lists. ### 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 7f36dd904..67b910e27 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -198,6 +198,7 @@ 50089A0D2C97182200834588 /* CurrentUser+PublishEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50089A0B2C97182200834588 /* CurrentUser+PublishEvents.swift */; }; 50089A172C98678600834588 /* View+ListRowGradientBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50089A162C98678600834588 /* View+ListRowGradientBackground.swift */; }; 501728B42D16EFB000CF2A07 /* FeedPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501728B32D16EFAC00CF2A07 /* FeedPicker.swift */; }; + 5022F9462D2186380012FF4B /* follow_set_private.json in Resources */ = {isa = PBXBuildFile; fileRef = 5022F9452D2186300012FF4B /* follow_set_private.json */; }; 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 */; }; @@ -763,6 +764,8 @@ 50089A0B2C97182200834588 /* CurrentUser+PublishEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CurrentUser+PublishEvents.swift"; sourceTree = ""; }; 50089A162C98678600834588 /* View+ListRowGradientBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ListRowGradientBackground.swift"; sourceTree = ""; }; 501728B32D16EFAC00CF2A07 /* FeedPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPicker.swift; sourceTree = ""; }; + 5022F9452D2186300012FF4B /* follow_set_private.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = follow_set_private.json; sourceTree = ""; }; + 5022F9472D2188650012FF4B /* Nos 23.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Nos 23.xcdatamodel"; 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 = ""; }; @@ -1271,6 +1274,7 @@ C9BD91882B61BBEF00FDA083 /* bad_contact_list.json */, 0350F1162C0A47B20024CC15 /* contact_list.json */, 03C853C52D03A50900164D6C /* follow_set.json */, + 5022F9452D2186300012FF4B /* follow_set_private.json */, 03FFCA582D075E2800D6F0F1 /* follow_set_updated.json */, 039C96282C48321E00A8EB39 /* long_form_data.json */, C95057B02CC6986E0024EC9C /* mute_list_2.json */, @@ -2286,6 +2290,7 @@ 3AEABEFE2B2BF850001BC933 /* ImagePicker.xcstrings in Resources */, 0350F1172C0A47B20024CC15 /* contact_list.json in Resources */, C944024D2C5BE6A600834568 /* Assets.xcassets in Resources */, + 5022F9462D2186380012FF4B /* follow_set_private.json in Resources */, 03C853C62D03A50900164D6C /* follow_set.json in Resources */, C987F83729BA951E00B44E7A /* ClarityCity-ExtraBold.otf in Resources */, C987F83329BA951E00B44E7A /* ClarityCity-ExtraLight.otf in Resources */, @@ -3917,6 +3922,7 @@ C936B4572A4C7B7C00DF1EB9 /* Nos.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + 5022F9472D2188650012FF4B /* Nos 23.xcdatamodel */, 503CAB7B2D1DA6DB00805EF8 /* Nos 22.xcdatamodel */, 0303B11E2D0257D400077929 /* Nos 21.xcdatamodel */, 2D3C71A52CEE6F7100625BCB /* Nos 20.xcdatamodel */, @@ -3932,7 +3938,7 @@ C9C547562A4F1D1A006B0741 /* Nos 9.xcdatamodel */, 5BFF66AF2A4B55FC00AA79DD /* Nos 10.xcdatamodel */, ); - currentVersion = 503CAB7B2D1DA6DB00805EF8 /* Nos 22.xcdatamodel */; + currentVersion = 5022F9472D2188650012FF4B /* Nos 23.xcdatamodel */; path = Nos.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/Nos/Models/CoreData/AuthorList+CoreDataClass.swift b/Nos/Models/CoreData/AuthorList+CoreDataClass.swift index 9957cf1d0..d9e4570d0 100644 --- a/Nos/Models/CoreData/AuthorList+CoreDataClass.swift +++ b/Nos/Models/CoreData/AuthorList+CoreDataClass.swift @@ -1,10 +1,23 @@ import Foundation import CoreData +import NostrSDK + +/// This class is needed only as a utility. The protocol functions only work on instances, +/// (as opposed to classes in static functions). +fileprivate final class TagInterpreter: PrivateTagInterpreting, DirectMessageEncrypting { +} + +extension Keypair { + static func withNosKeyPair(_ keyPair: KeyPair) -> Keypair? { + Keypair(nsec: keyPair.nsec) + } +} @objc(AuthorList) public class AuthorList: Event { static func createOrUpdate( from jsonEvent: JSONEvent, + keyPair: KeyPair? = nil, in context: NSManagedObjectContext ) throws -> AuthorList { guard jsonEvent.kind == EventKind.followSet.rawValue else { throw AuthorListError.invalidKind } @@ -45,7 +58,21 @@ public class AuthorList: Event { authorList.listDescription = tag[safe: 1] } } - + + if !jsonEvent.content.isEmpty, + let keyPair, + let nostrSDKKeypair = Keypair.withNosKeyPair(keyPair) { + let authorIDs = TagInterpreter().valuesForPrivateTags( + from: jsonEvent.content, + withName: .pubkey, + using: nostrSDKKeypair + ) + for authorID in authorIDs { + let author = try Author.findOrCreate(by: authorID, context: context) + authorList.addToPrivateAuthors(author) + } + } + return authorList } diff --git a/Nos/Models/CoreData/Generated/AuthorList+CoreDataProperties.swift b/Nos/Models/CoreData/Generated/AuthorList+CoreDataProperties.swift index 1cd75f9de..c4826674a 100644 --- a/Nos/Models/CoreData/Generated/AuthorList+CoreDataProperties.swift +++ b/Nos/Models/CoreData/Generated/AuthorList+CoreDataProperties.swift @@ -23,6 +23,9 @@ extension AuthorList { /// The set of unique authors in this list. @NSManaged public var authors: Set + /// The set of privately listed unique authors. + @NSManaged public var privateAuthors: Set + /// Whether or not this list should be visible in the ``FeedPicker``. @NSManaged public var isFeedEnabled: Bool } @@ -41,4 +44,16 @@ extension AuthorList { @objc(removeAuthors:) @NSManaged public func removeFromAuthors(_ values: NSSet) + + @objc(addPrivateAuthorsObject:) + @NSManaged public func addToPrivateAuthors(_ value: Author) + + @objc(removePrivateAuthorsObject:) + @NSManaged public func removeFromPrivateAuthors(_ value: Author) + + @objc(addPrivateAuthors:) + @NSManaged public func addToPrivateAuthors(_ values: NSSet) + + @objc(removePrivateAuthors:) + @NSManaged public func removeFromPrivateAuthors(_ values: NSSet) } diff --git a/Nos/Models/CoreData/Nos.xcdatamodeld/.xccurrentversion b/Nos/Models/CoreData/Nos.xcdatamodeld/.xccurrentversion index 5b7d17353..559c03b9e 100644 --- a/Nos/Models/CoreData/Nos.xcdatamodeld/.xccurrentversion +++ b/Nos/Models/CoreData/Nos.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Nos 22.xcdatamodel + Nos 23.xcdatamodel diff --git a/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 23.xcdatamodel/.xccurrentversion b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 23.xcdatamodel/.xccurrentversion new file mode 100644 index 000000000..6c8a1eef9 --- /dev/null +++ b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 23.xcdatamodel/.xccurrentversion @@ -0,0 +1,8 @@ + + + + + _XCCurrentVersionName + Nos.xcdatamodel + + diff --git a/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 23.xcdatamodel/Nos.xcdatamodel/contents b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 23.xcdatamodel/Nos.xcdatamodel/contents new file mode 100644 index 000000000..1a418ef2c --- /dev/null +++ b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 23.xcdatamodel/Nos.xcdatamodel/contents @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 23.xcdatamodel/contents b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 23.xcdatamodel/contents new file mode 100644 index 000000000..4361b0fe3 --- /dev/null +++ b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 23.xcdatamodel/contents @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Nos/Service/EventProcessor.swift b/Nos/Service/EventProcessor.swift index e5ff4fc06..ad024fc5a 100644 --- a/Nos/Service/EventProcessor.swift +++ b/Nos/Service/EventProcessor.swift @@ -9,14 +9,16 @@ enum EventProcessor { jsonEvent: JSONEvent, from relay: Relay?, in parseContext: NSManagedObjectContext, - skipVerification: Bool = false + skipVerification: Bool = false, + keyPair: KeyPair? = nil ) throws -> Event? { if jsonEvent.kind == EventKind.followSet.rawValue { return try saveFollowSet( jsonEvent: jsonEvent, relay: relay, parseContext: parseContext, - skipVerification: skipVerification + skipVerification: skipVerification, + keyPair: keyPair ) } else if let event = try Event.createIfNecessary(jsonEvent: jsonEvent, relay: relay, context: parseContext) { try updateEvent( @@ -67,13 +69,23 @@ enum EventProcessor { } extension EventProcessor { + + /// Creates or updates a kind 30000 Follow Set event into an ``AuthorList``. + /// - Parameters: + /// - jsonEvent: The event to parse. + /// - relay: The relay the event came from, if needed. + /// - parseContext: The context to create or update the list in. + /// - skipVerification: If true, skips verifying the signature on the event. + /// - keyPair: The keypair to use for decrypting privately listed pubkeys. + /// - Returns: The list. private static func saveFollowSet( jsonEvent: JSONEvent, relay: Relay?, parseContext: NSManagedObjectContext, - skipVerification: Bool + skipVerification: Bool, + keyPair: KeyPair? ) throws -> AuthorList { - let authorList = try AuthorList.createOrUpdate(from: jsonEvent, in: parseContext) + let authorList = try AuthorList.createOrUpdate(from: jsonEvent, keyPair: keyPair, in: parseContext) if !skipVerification { guard try authorList.verifySignature() else { parseContext.delete(authorList) diff --git a/Nos/Service/Relay/RelayService.swift b/Nos/Service/Relay/RelayService.swift index 93537f642..b9a89e35a 100644 --- a/Nos/Service/Relay/RelayService.swift +++ b/Nos/Service/Relay/RelayService.swift @@ -391,13 +391,14 @@ extension RelayService { return false } else { let remainingEventCount = await parseQueue.count + let keyPair = await currentUser.keyPair try await persistenceController.parseContext.perform { var savedEvents = 0 for (event, socket) in eventData { let relay = self.relay(from: socket, in: self.persistenceController.parseContext) do { let context = self.persistenceController.parseContext - if try EventProcessor.parse(jsonEvent: event, from: relay, in: context) != nil { + if try EventProcessor.parse(jsonEvent: event, from: relay, in: context, keyPair: keyPair) != nil { savedEvents += 1 } } catch { diff --git a/NosTests/IntegrationTests/EventProcessorIntegrationTests+FollowSet.swift b/NosTests/IntegrationTests/EventProcessorIntegrationTests+FollowSet.swift index 4a40a8454..e59093dea 100644 --- a/NosTests/IntegrationTests/EventProcessorIntegrationTests+FollowSet.swift +++ b/NosTests/IntegrationTests/EventProcessorIntegrationTests+FollowSet.swift @@ -73,4 +73,41 @@ extension EventProcessorIntegrationTests { let authorPubKey = try XCTUnwrap(followSet.authors.first?.hexadecimalPublicKey) XCTAssertEqual(authorPubKey, "27cf2c68535ae1fc06510e827670053f5dcd39e6bd7e05f1ffb487ef2ac13549") } + + @MainActor func test_parse_kind_30000_with_private_tags() throws { + // Arrange + let replaceableID = "PrivateOnly" + let ownerPubKey = "ffd99f9e545b53e3291dab4b8cd6d25d12b9973c40f02e1938c0891d62e38e57" + let data = try jsonData(filename: "follow_set_private") + let jsonEvent = try JSONDecoder().decode(JSONEvent.self, from: data) + + // Act + _ = try EventProcessor.parse( + jsonEvent: jsonEvent, + from: nil, + in: testContext, + skipVerification: true, + keyPair: KeyPair(nsec: "nsec17vdaesh5tp6u5dy74vdy7a7e5x5ww4wfdnrn6ewgnsfxav8pcurqnlmj88") + ) + + // Assert + let fetchResults = try testContext.fetch( + AuthorList.authorList( + by: replaceableID, + owner: try Author.findOrCreate(by: ownerPubKey, context: testContext), + kind: EventKind.followSet.rawValue + ) + ) + XCTAssertEqual(fetchResults.count, 1) + + let followSet = try XCTUnwrap(fetchResults.first) + XCTAssertTrue(followSet.authors.isEmpty) + + XCTAssertEqual(followSet.privateAuthors.count, 3) + + let publicKeys = followSet.privateAuthors.map { $0.hexadecimalPublicKey } + XCTAssertTrue(publicKeys.contains("3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681")) + XCTAssertTrue(publicKeys.contains("3743244390be53473a7e3b3b8d04dce83f6c9514b81a997fb3b123c072ef9f78")) + XCTAssertTrue(publicKeys.contains("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52")) + } } diff --git a/NosTests/IntegrationTests/Fixtures/follow_set_private.json b/NosTests/IntegrationTests/Fixtures/follow_set_private.json new file mode 100644 index 000000000..d7e39f3c1 --- /dev/null +++ b/NosTests/IntegrationTests/Fixtures/follow_set_private.json @@ -0,0 +1,22 @@ +{ + "id": "642c786d6435f39833d5c045d76058b1607ed17e2ca44170da0a6d363e2b1797", + "pubkey": "ffd99f9e545b53e3291dab4b8cd6d25d12b9973c40f02e1938c0891d62e38e57", + "tags": [ + [ + "title", + "Private Only" + ], + [ + "d", + "PrivateOnly" + ], + [ + "description", + "This is a list for testing lists." + ] + ], + "content": "bKKE7/Xi/uBxRfCoAb0eQLUiwelbJrGVEkmL6JGHE2maGn6rp3QGQeU+pecz54B6aCUYKoyimzB0wSTXXuhirgeBtS4pTSH6QO4KHPSZJeRh8GDcr3e9bexATBb9X5R/tpuVb6Fgv0vYFAso3t9cmO5YCAM0O2H6fd/Fj/MyYp+3VVz0rOXi6MMuBmswmD2MbSq5OwmZqY7sC77F0BOkZceWQzUHmVXcmBSMz0t0DeVFq4DW395s9E0VKMTJPzejGYlF/+Eg/E9mBRVd7mLMEBZtf/3Rmq+hKGiladFFpE8=?iv=dL08cLM4R/gNzpoLlWWxdA==", + "sig": "7b95fce1ce83f4f116d74a105afc07984f39342fbbb483b1b99e0f757106538cf6c42e2d8b0837e4e6b40fc6fc37e82cc6c56d16ecab1ab109a11fd3640d7fb8", + "created_at": 1735477908, + "kind": 30000 +} From 3a4142190fa62fdac43ea4a986569987d547e223 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Sun, 29 Dec 2024 09:00:38 -0600 Subject: [PATCH 10/26] added privately listed authors to Home feed when list is selected --- Nos/Views/Home/HomeFeedView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Nos/Views/Home/HomeFeedView.swift b/Nos/Views/Home/HomeFeedView.swift index 135a3ecf7..819e5fd85 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -54,7 +54,8 @@ struct HomeFeedView: View { var filter = Filter(kinds: [.text, .delete, .repost, .longFormContent, .report]) if feedController.selectedRelay == nil { if let list = feedController.selectedList { - filter.authorKeys = list.authors.compactMap { $0.hexadecimalPublicKey }.filter { $0.isValid } + let authors = list.authors.union(list.privateAuthors) + filter.authorKeys = authors.compactMap { $0.hexadecimalPublicKey }.filter { $0.isValid } } else { filter.authorKeys = user.followedKeys.sorted() } From d3490d3701c9f0928fceee4c0c3e75b4dd2b5645 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Sun, 29 Dec 2024 09:08:26 -0600 Subject: [PATCH 11/26] improve access to lists' public and private authors --- Nos/Models/CoreData/AuthorList+CoreDataClass.swift | 4 ++++ Nos/Views/Home/HomeFeedView.swift | 5 ++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Nos/Models/CoreData/AuthorList+CoreDataClass.swift b/Nos/Models/CoreData/AuthorList+CoreDataClass.swift index d9e4570d0..debf4d2d3 100644 --- a/Nos/Models/CoreData/AuthorList+CoreDataClass.swift +++ b/Nos/Models/CoreData/AuthorList+CoreDataClass.swift @@ -92,4 +92,8 @@ public class AuthorList: Event { fetchRequest.fetchLimit = 1 return fetchRequest } + + var allAuthors: Set { + authors.union(privateAuthors) + } } diff --git a/Nos/Views/Home/HomeFeedView.swift b/Nos/Views/Home/HomeFeedView.swift index 819e5fd85..397ac26a1 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -38,7 +38,7 @@ struct HomeFeedView: View { for: user, before: refreshController.lastRefreshDate, seenOn: feedController.selectedRelay, - from: feedController.selectedList?.authors + from: feedController.selectedList?.allAuthors ) } @@ -54,8 +54,7 @@ struct HomeFeedView: View { var filter = Filter(kinds: [.text, .delete, .repost, .longFormContent, .report]) if feedController.selectedRelay == nil { if let list = feedController.selectedList { - let authors = list.authors.union(list.privateAuthors) - filter.authorKeys = authors.compactMap { $0.hexadecimalPublicKey }.filter { $0.isValid } + filter.authorKeys = list.allAuthors.compactMap { $0.hexadecimalPublicKey }.filter { $0.isValid } } else { filter.authorKeys = user.followedKeys.sorted() } From 8e8a30913f19513efa268166e237197326a06d62 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Sun, 29 Dec 2024 09:10:04 -0600 Subject: [PATCH 12/26] lint fix --- Nos/Service/Relay/RelayService.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Nos/Service/Relay/RelayService.swift b/Nos/Service/Relay/RelayService.swift index b9a89e35a..d258270ff 100644 --- a/Nos/Service/Relay/RelayService.swift +++ b/Nos/Service/Relay/RelayService.swift @@ -398,7 +398,12 @@ extension RelayService { let relay = self.relay(from: socket, in: self.persistenceController.parseContext) do { let context = self.persistenceController.parseContext - if try EventProcessor.parse(jsonEvent: event, from: relay, in: context, keyPair: keyPair) != nil { + if try EventProcessor.parse( + jsonEvent: event, + from: relay, + in: context, + keyPair: keyPair + ) != nil { savedEvents += 1 } } catch { From 84e18c98b566dea6f1df65eec53c848b9efa4ce4 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Mon, 30 Dec 2024 08:14:46 -0600 Subject: [PATCH 13/26] added pop-up tip for feed customization #101 --- CHANGELOG.md | 1 + .../tip-shadow.colorset/Contents.json | 20 ++++ Nos/Views/Home/HomeFeedView.swift | 4 +- Nos/Views/Home/HomeTab.swift | 100 +++++++++++++++++- 4 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 Nos/Assets/Colors.xcassets/tip-shadow.colorset/Contents.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 6450ef61f..c613c67d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Make feed source selector work. - Add empty state for lists/relays drop-down. - Added support for decrypting private tags in kind 30000 lists. +- Added pop-up tip for feed customization. [#101](https://github.com/verse-pbc/issues/issues/101) ### Internal Changes - Upgraded to Xcode 16. [#1570](https://github.com/planetary-social/nos/issues/1570) diff --git a/Nos/Assets/Colors.xcassets/tip-shadow.colorset/Contents.json b/Nos/Assets/Colors.xcassets/tip-shadow.colorset/Contents.json new file mode 100644 index 000000000..0c3514d47 --- /dev/null +++ b/Nos/Assets/Colors.xcassets/tip-shadow.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2D", + "green" : "0x39", + "red" : "0xAA" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Nos/Views/Home/HomeFeedView.swift b/Nos/Views/Home/HomeFeedView.swift index 397ac26a1..6cc262a10 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -27,6 +27,7 @@ struct HomeFeedView: View { private let stackSpacing: CGFloat = 8 let user: Author + @Binding var showFeedTip: Bool /// A tip to display at the top of the feed. private let welcomeTip = WelcomeToFeedTip() @@ -147,6 +148,7 @@ struct HomeFeedView: View { Button { withAnimation { showFeedSelector.toggle() + showFeedTip = false } } label: { Image(systemName: showFeedSelector ? "xmark.circle.fill" : "line.3.horizontal.decrease.circle") @@ -207,7 +209,7 @@ struct HomeFeedView: View { } return NavigationStack { - HomeFeedView(user: previewData.alice) + HomeFeedView(user: previewData.alice, showFeedTip: .constant(false)) } .inject(previewData: previewData) .onAppear { diff --git a/Nos/Views/Home/HomeTab.swift b/Nos/Views/Home/HomeTab.swift index 7a0892268..6c2b07152 100644 --- a/Nos/Views/Home/HomeTab.swift +++ b/Nos/Views/Home/HomeTab.swift @@ -1,15 +1,107 @@ -import SwiftUI import Dependencies +import SwiftUI + +/// A styled tip view that contains the text provided. +/// +/// Caution: As of iOS 18, TipKit does not allow styling of popover-style tips, so this +/// is a custom replication of TipKit's popover with custom styling. This is a bespoke +/// solution for the specific view it is in and will need to be modified to suit other views. +struct PopoverTipView: View { + let text: String + + var body: some View { + VStack(spacing: 0) { + HStack { + Spacer() + + Image(systemName: "triangle.fill") + .resizable() + .foregroundStyle(Color.actionPrimaryGradientTop) + .frame(width: 20, height: 10) + .padding(.trailing, 23) + .offset(y: 4) + } + + HStack { + Spacer() + + HStack(alignment: .top) { + Text(text) + .font(.clarityBold(.headline)) + .padding(.horizontal, 2) + + Image(systemName: "xmark") + .padding(.trailing, 6) + } + .font(.headline) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 20) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(LinearGradient.horizontalAccentReversed) + ) + .padding(.bottom, 4) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.tipShadow) + ) + .frame(idealWidth: 320) + + Spacer() + .frame(width: 8) + } + } + } +} + +let hasShownFeedTip = "com.verse.nos.Home.hasShownFeedTip" struct HomeTab: View { + @Dependency(\.userDefaults) private var userDefaults @ObservedObject var user: Author @EnvironmentObject private var router: Router + @State private var showFeedTip = false + var body: some View { - NosNavigationStack(path: $router.homeFeedPath) { - HomeFeedView(user: user) + ZStack { + NosNavigationStack(path: $router.homeFeedPath) { + HomeFeedView(user: user, showFeedTip: $showFeedTip) + } + + if showFeedTip { + VStack { + Spacer() + .frame(height: 24) + + HStack { + Spacer() + + PopoverTipView(text: "Curate your feed with lists, custom feeds, and relays.") + .onTapGesture { + withAnimation { + showFeedTip.toggle() + } + } + } + Spacer() + } + .transition(.opacity) + } + } + .onAppear { + if !userDefaults.bool(forKey: hasShownFeedTip) { + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { + withAnimation { + showFeedTip = true + } + } + + userDefaults.set(true, forKey: hasShownFeedTip) + } } } } @@ -20,7 +112,7 @@ struct HomeTab_Previews: PreviewProvider { static var previews: some View { NavigationView { - HomeFeedView(user: previewData.currentUser.author!) + HomeFeedView(user: previewData.currentUser.author!, showFeedTip: .constant(false)) .inject(previewData: previewData) } } From c7ea0256120f34e08bafe8d4d00cdae107102181 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Mon, 30 Dec 2024 10:46:44 -0600 Subject: [PATCH 14/26] add rules for when to show feed selector tooltip --- Nos.xcodeproj/project.pbxproj | 4 ++ Nos/Views/Components/PagedNoteListView.swift | 19 +++++-- Nos/Views/Home/FeedSelectorTip.swift | 20 ++++++++ Nos/Views/Home/HomeFeedView.swift | 4 +- Nos/Views/Home/HomeTab.swift | 54 ++++++++++++++------ Nos/Views/Profile/ProfileView.swift | 2 + 6 files changed, 82 insertions(+), 21 deletions(-) create mode 100644 Nos/Views/Home/FeedSelectorTip.swift diff --git a/Nos.xcodeproj/project.pbxproj b/Nos.xcodeproj/project.pbxproj index 67b910e27..b89427620 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -199,6 +199,7 @@ 50089A172C98678600834588 /* View+ListRowGradientBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50089A162C98678600834588 /* View+ListRowGradientBackground.swift */; }; 501728B42D16EFB000CF2A07 /* FeedPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501728B32D16EFAC00CF2A07 /* FeedPicker.swift */; }; 5022F9462D2186380012FF4B /* follow_set_private.json in Resources */ = {isa = PBXBuildFile; fileRef = 5022F9452D2186300012FF4B /* follow_set_private.json */; }; + 5022FB352D2303F30012FF4B /* FeedSelectorTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5022FB342D2303F00012FF4B /* FeedSelectorTip.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 */; }; @@ -766,6 +767,7 @@ 501728B32D16EFAC00CF2A07 /* FeedPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPicker.swift; sourceTree = ""; }; 5022F9452D2186300012FF4B /* follow_set_private.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = follow_set_private.json; sourceTree = ""; }; 5022F9472D2188650012FF4B /* Nos 23.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Nos 23.xcdatamodel"; sourceTree = ""; }; + 5022FB342D2303F00012FF4B /* FeedSelectorTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSelectorTip.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 = ""; }; @@ -1718,6 +1720,7 @@ children = ( 503CA9782D19C39800805EF8 /* FeedCustomizerView.swift */, 501728B32D16EFAC00CF2A07 /* FeedPicker.swift */, + 5022FB342D2303F00012FF4B /* FeedSelectorTip.swift */, 503CAC602D1EF71700805EF8 /* FeedSourceToggleView.swift */, 503CAB6D2D1DA17100805EF8 /* FeedToggleRow.swift */, C9DEBFD8298941000078B43A /* HomeFeedView.swift */, @@ -2656,6 +2659,7 @@ CD09A74829A51EFC0063464F /* Router.swift in Sources */, 2D4010A22AD87DF300F93AD4 /* KnownFollowersView.swift in Sources */, CD2CF38E299E67F900332116 /* CardButtonStyle.swift in Sources */, + 5022FB352D2303F30012FF4B /* FeedSelectorTip.swift in Sources */, 03E181392C75467C00886CC6 /* GalleryView.swift in Sources */, A336DD3C299FD78000A0CBA0 /* Filter.swift in Sources */, 0315B5EF2C7E451C0020E707 /* MockMediaService.swift in Sources */, diff --git a/Nos/Views/Components/PagedNoteListView.swift b/Nos/Views/Components/PagedNoteListView.swift index 1eb085b88..732e73084 100644 --- a/Nos/Views/Components/PagedNoteListView.swift +++ b/Nos/Views/Components/PagedNoteListView.swift @@ -19,7 +19,10 @@ struct PagedNoteListView: UIViewRepresenta /// Allows us to refresh the PagedNoteListView from outside this view itself, such as with a separate button. @Binding var refreshController: RefreshController - /// A fetch request that specifies the events that should be shown. The events should be sorted in + /// Allows parent views to act when the offset reaches a certain point. + @Binding var scrollOffsetY: CGFloat + + /// A fetch request that specifies the events that should be shown. The events should be sorted in /// reverse-chronological order and should match the events returned by `relayFilter`. let databaseFilter: NSFetchRequest @@ -45,7 +48,7 @@ struct PagedNoteListView: UIViewRepresenta let emptyPlaceholder: () -> EmptyPlaceholder func makeCoordinator() -> Coordinator { - Coordinator(refreshController: refreshController) + Coordinator(refreshController: refreshController, scrollOffsetY: $scrollOffsetY) } func makeUIView(context: Context) -> UICollectionView { @@ -65,6 +68,7 @@ struct PagedNoteListView: UIViewRepresenta emptyPlaceholder: emptyPlaceholder ) collectionView.dataSource = dataSource + collectionView.delegate = context.coordinator collectionView.prefetchDataSource = dataSource let refreshControl = UIRefreshControl() @@ -173,11 +177,12 @@ struct PagedNoteListView: UIViewRepresenta // swiftlint:disable generic_type_name /// The coordinator mainly holds a strong reference to the `dataSource` and proxies pull-to-refresh events. - class Coordinator { + class Coordinator: NSObject, UICollectionViewDelegate { // swiftlint:enable generic_type_name /// Controls refresh actions. Used for setting the `lastRefreshDate` whenever the data is refreshed. let refreshController: RefreshController + @Binding var scrollOffsetY: CGFloat var dataSource: PagedNoteDataSource? var collectionView: UICollectionView? @@ -186,8 +191,9 @@ struct PagedNoteListView: UIViewRepresenta /// Initializes a coordinator with the given refresh controller. /// - Parameter refreshController: Controls refresh actions. Used for setting the `lastRefreshDate` /// whenever the data is refreshed. - init(refreshController: RefreshController) { + init(refreshController: RefreshController, scrollOffsetY: Binding) { self.refreshController = refreshController + self._scrollOffsetY = scrollOffsetY } func dataSource( @@ -229,6 +235,10 @@ struct PagedNoteListView: UIViewRepresenta } } } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + scrollOffsetY = scrollView.contentOffset.y + } } } @@ -243,6 +253,7 @@ extension Notification.Name { return PagedNoteListView( refreshController: $refreshController, + scrollOffsetY: .constant(0), databaseFilter: previewData.alice.allPostsRequest(onlyRootPosts: false), relayFilter: Filter(), relay: nil, diff --git a/Nos/Views/Home/FeedSelectorTip.swift b/Nos/Views/Home/FeedSelectorTip.swift new file mode 100644 index 000000000..f4c41a263 --- /dev/null +++ b/Nos/Views/Home/FeedSelectorTip.swift @@ -0,0 +1,20 @@ +import Dependencies +import SwiftUI + +struct FeedSelectorTip { + @Dependency(\.userDefaults) private var userDefaults + + static let hasShownFeedTipKey = "com.verse.nos.Home.hasShownFeedTip" + + static var minimumScrollOffset: CGFloat = 1500 + static var maximumDelay: TimeInterval = 30 + + var hasShown: Bool { + get { + userDefaults.bool(forKey: Self.hasShownFeedTipKey) + } + set { + userDefaults.set(newValue, forKey: Self.hasShownFeedTipKey) + } + } +} diff --git a/Nos/Views/Home/HomeFeedView.swift b/Nos/Views/Home/HomeFeedView.swift index 6cc262a10..744865716 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -28,6 +28,7 @@ struct HomeFeedView: View { let user: Author @Binding var showFeedTip: Bool + @Binding var scrollOffsetY: CGFloat /// A tip to display at the top of the feed. private let welcomeTip = WelcomeToFeedTip() @@ -78,6 +79,7 @@ struct HomeFeedView: View { PagedNoteListView( refreshController: $refreshController, + scrollOffsetY: $scrollOffsetY, databaseFilter: homeFeedFetchRequest, relayFilter: homeFeedFilter, relay: feedController.selectedRelay, @@ -209,7 +211,7 @@ struct HomeFeedView: View { } return NavigationStack { - HomeFeedView(user: previewData.alice, showFeedTip: .constant(false)) + HomeFeedView(user: previewData.alice, showFeedTip: .constant(false), scrollOffsetY: .constant(0)) } .inject(previewData: previewData) .onAppear { diff --git a/Nos/Views/Home/HomeTab.swift b/Nos/Views/Home/HomeTab.swift index 6c2b07152..1bd2c5cc6 100644 --- a/Nos/Views/Home/HomeTab.swift +++ b/Nos/Views/Home/HomeTab.swift @@ -1,4 +1,3 @@ -import Dependencies import SwiftUI /// A styled tip view that contains the text provided. @@ -6,7 +5,7 @@ import SwiftUI /// Caution: As of iOS 18, TipKit does not allow styling of popover-style tips, so this /// is a custom replication of TipKit's popover with custom styling. This is a bespoke /// solution for the specific view it is in and will need to be modified to suit other views. -struct PopoverTipView: View { +fileprivate struct PopoverTipView: View { let text: String var body: some View { @@ -55,21 +54,24 @@ struct PopoverTipView: View { } } -let hasShownFeedTip = "com.verse.nos.Home.hasShownFeedTip" - struct HomeTab: View { - @Dependency(\.userDefaults) private var userDefaults - @ObservedObject var user: Author @EnvironmentObject private var router: Router + @State private var feedTip = FeedSelectorTip() @State private var showFeedTip = false + @State private var timer: Timer? + @State private var scrollOffsetY: CGFloat = 0 var body: some View { ZStack { NosNavigationStack(path: $router.homeFeedPath) { - HomeFeedView(user: user, showFeedTip: $showFeedTip) + HomeFeedView( + user: user, + showFeedTip: $showFeedTip, + scrollOffsetY: $scrollOffsetY + ) } if showFeedTip { @@ -93,16 +95,32 @@ struct HomeTab: View { } } .onAppear { - if !userDefaults.bool(forKey: hasShownFeedTip) { - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { - withAnimation { - showFeedTip = true - } + if !feedTip.hasShown { + timer = Timer.scheduledTimer(withTimeInterval: FeedSelectorTip.maximumDelay, repeats: false) { _ in + showTip() } - - userDefaults.set(true, forKey: hasShownFeedTip) } } + .onDisappear { + timer?.invalidate() + timer = nil + } + .onChange(of: scrollOffsetY) { + if scrollOffsetY > FeedSelectorTip.minimumScrollOffset { + showTip() + } + } + } + + private func showTip() { + guard !feedTip.hasShown else { + return + } + + withAnimation { + showFeedTip = true + } + feedTip.hasShown = true } } @@ -112,8 +130,12 @@ struct HomeTab_Previews: PreviewProvider { static var previews: some View { NavigationView { - HomeFeedView(user: previewData.currentUser.author!, showFeedTip: .constant(false)) - .inject(previewData: previewData) + HomeFeedView( + user: previewData.currentUser.author!, + showFeedTip: .constant(false), + scrollOffsetY: .constant(0) + ) + .inject(previewData: previewData) } } } diff --git a/Nos/Views/Profile/ProfileView.swift b/Nos/Views/Profile/ProfileView.swift index 5a6075375..ae9fc2941 100644 --- a/Nos/Views/Profile/ProfileView.swift +++ b/Nos/Views/Profile/ProfileView.swift @@ -23,6 +23,7 @@ struct ProfileView: View { @State private var selectedTab: ProfileFeedType = .notes @State private var alert: AlertState? + @State private var scrollOffsetY: CGFloat = 0 var isShowingLoggedInUser: Bool { author.hexadecimalPublicKey == currentUser.publicKeyHex @@ -202,6 +203,7 @@ struct ProfileView: View { var noteListView: some View { PagedNoteListView( refreshController: $refreshController, + scrollOffsetY: .constant(0), databaseFilter: databaseFilter, relayFilter: selectedTab.relayFilter(author: author), relay: nil, From b90ea45b8dd538b4149a5ead2542531caf065e57 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Tue, 31 Dec 2024 07:32:02 -0600 Subject: [PATCH 15/26] added remembering which feed source is selected --- CHANGELOG.md | 1 + Nos/Controller/FeedController.swift | 88 +++++++++++++++++++----- Nos/Models/CoreData/Event+Fetching.swift | 5 +- Nos/Views/Home/FeedPicker.swift | 45 +++++++----- Nos/Views/Home/HomeFeedView.swift | 3 +- 5 files changed, 102 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c613c67d4..31dce629e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add empty state for lists/relays drop-down. - Added support for decrypting private tags in kind 30000 lists. - Added pop-up tip for feed customization. [#101](https://github.com/verse-pbc/issues/issues/101) +- Added remembering which feed source is selected. ### Internal Changes - Upgraded to Xcode 16. [#1570](https://github.com/planetary-social/nos/issues/1570) diff --git a/Nos/Controller/FeedController.swift b/Nos/Controller/FeedController.swift index 5d4464f67..e07316273 100644 --- a/Nos/Controller/FeedController.swift +++ b/Nos/Controller/FeedController.swift @@ -4,7 +4,7 @@ import Dependencies import SwiftUI /// The source to be used for a feed of notes. -enum FeedSource: Hashable, Equatable { +enum FeedSource: RawRepresentable, Hashable, Equatable { case following case relay(String, String?) case list(String, String?) @@ -31,6 +31,44 @@ enum FeedSource: Hashable, Equatable { default: false } } + + // Note: RawRepresentable conformance is required for use of @AppStorage for persistence. + var rawValue: String { + switch self { + case .following: + "following" + case .relay(let host, let description): + "relay:|\(host):|\(description ?? "")" + case .list(let name, let description): + "list:|\(name):|\(description ?? "")" + } + } + + init?(rawValue: String) { + let components = rawValue.split(separator: ":|").map { String($0) } + guard let caseName = components.first else { + return nil + } + + switch caseName { + case "following": + self = .following + case "relay": + guard components.count >= 2 else { + return nil + } + let description = components.count >= 3 ? components[2] : "" + self = .relay(components[1], description) + case "list": + guard components.count >= 2 else { + return nil + } + let description = components.count >= 3 ? components[2] : "" + self = .list(components[1], description) + default: + return nil + } + } } @Observable @MainActor final class FeedController { @@ -42,24 +80,13 @@ enum FeedSource: Hashable, Equatable { private(set) var selectedList: AuthorList? private(set) var selectedRelay: Relay? - var selectedSource: FeedSource = .following { + + @ObservationIgnored @AppStorage("selectedFeedSource") private var persistedSelectedSource = FeedSource.following + + var selectedSource = FeedSource.following { didSet { - switch selectedSource { - case .relay(let address, _): - if let relay = relays.first(where: { $0.host == address }) { - selectedRelay = relay - selectedList = nil - } - case .list(let title, _): - // TODO: Needs to use replaceableID instead of title - if let list = lists.first(where: { $0.title == title }) { - selectedList = list - selectedRelay = nil - } - default: - selectedList = nil - selectedRelay = nil - } + updateSelectedListOrRelay() + persistedSelectedSource = selectedSource } } @@ -82,6 +109,12 @@ enum FeedSource: Hashable, Equatable { init() { observeLists() observeRelays() + + // The delay here is an unfortunate workaround. Without it, the feed always resumes to + // the default value of FeedSource.following. + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { + self.selectedSource = self.persistedSelectedSource + } } private func observeLists() { @@ -136,6 +169,25 @@ enum FeedSource: Hashable, Equatable { .store(in: &cancellables) } + private func updateSelectedListOrRelay() { + switch selectedSource { + case .relay(let address, _): + if let relay = relays.first(where: { $0.host == address }) { + selectedRelay = relay + selectedList = nil + } + case .list(let title, _): + // TODO: Needs to use replaceableID instead of title + if let list = lists.first(where: { $0.title == title }) { + selectedList = list + selectedRelay = nil + } + default: + selectedList = nil + selectedRelay = nil + } + } + private func updateEnabledSources() { var enabledSources = [FeedSource]() enabledSources.append(.following) diff --git a/Nos/Models/CoreData/Event+Fetching.swift b/Nos/Models/CoreData/Event+Fetching.swift index 6bd48be25..82d751400 100644 --- a/Nos/Models/CoreData/Event+Fetching.swift +++ b/Nos/Models/CoreData/Event+Fetching.swift @@ -392,11 +392,12 @@ extension Event { @nonobjc public class func homeFeed( for user: Author, after: Date, - seenOn relay: Relay? = nil + seenOn relay: Relay? = nil, + from authors: Set? = nil ) -> NSFetchRequest { let fetchRequest = NSFetchRequest(entityName: "Event") fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] - fetchRequest.predicate = homeFeedPredicate(for: user, after: after, seenOn: relay) + fetchRequest.predicate = homeFeedPredicate(for: user, after: after, seenOn: relay, from: authors) return fetchRequest } diff --git a/Nos/Views/Home/FeedPicker.swift b/Nos/Views/Home/FeedPicker.swift index 127f963d9..c0530142e 100644 --- a/Nos/Views/Home/FeedPicker.swift +++ b/Nos/Views/Home/FeedPicker.swift @@ -7,28 +7,35 @@ struct FeedPicker: View { var body: some View { BeveledContainerView { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 0) { - ForEach(feedController.enabledSources, id: \.self) { source in - Button(action: { - withAnimation(nil) { - feedController.selectedSource = source - } - }, label: { - let isSelected = feedController.selectedSource == source - Text(source.displayName) - .font(.system(size: 16, weight: isSelected ? .medium : .regular)) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(isSelected ? Color.pickerBackgroundSelected : Color.clear) - .foregroundStyle(isSelected ? Color.white : Color.secondaryTxt) - .clipShape(Capsule()) - }) + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(feedController.enabledSources, id: \.self) { source in + Button(action: { + withAnimation(nil) { + feedController.selectedSource = source + } + }, label: { + let isSelected = feedController.selectedSource == source + Text(source.displayName) + .font(.system(size: 16, weight: isSelected ? .medium : .regular)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(isSelected ? Color.pickerBackgroundSelected : Color.clear) + .foregroundStyle(isSelected ? Color.white : Color.secondaryTxt) + .clipShape(Capsule()) + }) + } + } + .padding(.horizontal, 8) + .onChange(of: feedController.selectedSource) { + withAnimation { + proxy.scrollTo(feedController.selectedSource) + } } } - .padding(.horizontal, 8) + .frame(height: 40) } - .frame(height: 40) } .background(Color.cardBgTop) } diff --git a/Nos/Views/Home/HomeFeedView.swift b/Nos/Views/Home/HomeFeedView.swift index 744865716..5e8007a35 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -48,7 +48,8 @@ struct HomeFeedView: View { Event.homeFeed( for: user, after: refreshController.lastRefreshDate, - seenOn: feedController.selectedRelay + seenOn: feedController.selectedRelay, + from: feedController.selectedList?.allAuthors ) } From a6f70841dc0c85e97b852ceb2bfc93dfe36e5bb1 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Tue, 31 Dec 2024 08:29:59 -0600 Subject: [PATCH 16/26] factored out the segmented picker on the ProfileHeader for reusability --- CHANGELOG.md | 1 + Nos.xcodeproj/project.pbxproj | 4 ++ Nos/Views/Components/NosSegmentedPicker.swift | 42 ++++++++++++++++ Nos/Views/Profile/ProfileFeedType.swift | 30 ++++++++++++ Nos/Views/Profile/ProfileHeader.swift | 49 +++---------------- 5 files changed, 83 insertions(+), 43 deletions(-) create mode 100644 Nos/Views/Components/NosSegmentedPicker.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 31dce629e..35847084d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added support for decrypting private tags in kind 30000 lists. - Added pop-up tip for feed customization. [#101](https://github.com/verse-pbc/issues/issues/101) - Added remembering which feed source is selected. +- Factored out the segmented picker on the ProfileHeader for reusability. ### 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 b89427620..369a9fad8 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -200,6 +200,7 @@ 501728B42D16EFB000CF2A07 /* FeedPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501728B32D16EFAC00CF2A07 /* FeedPicker.swift */; }; 5022F9462D2186380012FF4B /* follow_set_private.json in Resources */ = {isa = PBXBuildFile; fileRef = 5022F9452D2186300012FF4B /* follow_set_private.json */; }; 5022FB352D2303F30012FF4B /* FeedSelectorTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5022FB342D2303F00012FF4B /* FeedSelectorTip.swift */; }; + 5022FBCF2D242C850012FF4B /* NosSegmentedPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5022FBCE2D242C810012FF4B /* NosSegmentedPicker.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 */; }; @@ -768,6 +769,7 @@ 5022F9452D2186300012FF4B /* follow_set_private.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = follow_set_private.json; sourceTree = ""; }; 5022F9472D2188650012FF4B /* Nos 23.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Nos 23.xcdatamodel"; sourceTree = ""; }; 5022FB342D2303F00012FF4B /* FeedSelectorTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSelectorTip.swift; sourceTree = ""; }; + 5022FBCE2D242C810012FF4B /* NosSegmentedPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NosSegmentedPicker.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 = ""; }; @@ -1625,6 +1627,7 @@ 2D4010A12AD87DF300F93AD4 /* KnownFollowersView.swift */, C9A0DADC29C689C900466635 /* NosNavigationBar.swift */, C97B28892C10B07100DC1FC0 /* NosNavigationStack.swift */, + 5022FBCE2D242C810012FF4B /* NosSegmentedPicker.swift */, 042406F22C907A15008F2A21 /* NosToggle.swift */, C9B708BA2A13BE41006C613A /* NoteTextEditor.swift */, C986510F2B0BD49200597B68 /* PagedNoteListView.swift */, @@ -2414,6 +2417,7 @@ C9CE5B142A0172CF008E198C /* WebView.swift in Sources */, CD4908D429B92941007443DB /* ReportABugMailView.swift in Sources */, 0314D5AC2C7D31060002E7F4 /* MediaService.swift in Sources */, + 5022FBCF2D242C850012FF4B /* NosSegmentedPicker.swift in Sources */, 5B7C93B02B6AD52400410ABE /* CreateUsernameWizard.swift in Sources */, 030036852C5D39DD002C71F5 /* RefreshController.swift in Sources */, C9C2B78229E0735400548B4A /* RelaySubscriptionManager.swift in Sources */, diff --git a/Nos/Views/Components/NosSegmentedPicker.swift b/Nos/Views/Components/NosSegmentedPicker.swift new file mode 100644 index 000000000..83cd94526 --- /dev/null +++ b/Nos/Views/Components/NosSegmentedPicker.swift @@ -0,0 +1,42 @@ +import SwiftUI + +protocol NosSegmentedPickerItem: Equatable, Identifiable { + var titleKey: LocalizedStringKey { get } + var image: Image { get } +} + +/// A custom styled segmented picker. +/// +/// Pass in an array of items that conform to ``NosSegmentedPickerItem`` as +/// well as a binding to a selected item of the same type. +struct NosSegmentedPicker: View { + + let items: [Item] + @Binding var selectedItem: Item + + var body: some View { + HStack { + ForEach(items) { item in + Button { + selectedItem = item + } label: { + HStack { + Spacer() + let color = selectedItem == item ? Color.primaryTxt : .secondaryTxt + item.image + .renderingMode(.template) + .foregroundColor(color) + Text(item.titleKey) + .font(.subheadline.weight(.medium)) + .foregroundColor(color) + Spacer() + } + } + .frame(maxWidth: .infinity) + } + } + .padding(.top, 12) + .padding(.bottom, 15) + .border(.green) + } +} diff --git a/Nos/Views/Profile/ProfileFeedType.swift b/Nos/Views/Profile/ProfileFeedType.swift index c19e9d503..872af2d86 100644 --- a/Nos/Views/Profile/ProfileFeedType.swift +++ b/Nos/Views/Profile/ProfileFeedType.swift @@ -1,4 +1,5 @@ import CoreData +import SwiftUI /// An enumeration of the different feed algorithms the user can choose to view on the Profile screen. enum ProfileFeedType { @@ -21,3 +22,32 @@ enum ProfileFeedType { return Filter(authorKeys: [author.hexadecimalPublicKey ?? "error"], kinds: kinds) } } + +extension ProfileFeedType: NosSegmentedPickerItem { + var id: String { + switch self { + case .activity: + "activity" + case .notes: + "notes" + } + } + + var titleKey: LocalizedStringKey { + switch self { + case .activity: + "activity" + case .notes: + "notes" + } + } + + var image: Image { + switch self { + case .activity: + Image.profileFeed + case .notes: + Image.profilePosts + } + } +} diff --git a/Nos/Views/Profile/ProfileHeader.swift b/Nos/Views/Profile/ProfileHeader.swift index 92bc28ff7..b878409da 100644 --- a/Nos/Views/Profile/ProfileHeader.swift +++ b/Nos/Views/Profile/ProfileHeader.swift @@ -70,7 +70,7 @@ struct ProfileHeader: View { y: 1 ) } - + var body: some View { ZStack { VStack(alignment: .leading) { @@ -204,8 +204,11 @@ struct ProfileHeader: View { .frame(maxWidth: .infinity) divider - - profileHeaderTab + + NosSegmentedPicker( + items: [ProfileFeedType.notes, ProfileFeedType.activity], + selectedItem: $selectedTab + ) } .frame(maxWidth: 500) } @@ -235,46 +238,6 @@ struct ProfileHeader: View { .presentationDetents([.medium, .large]) } } - - private var profileHeaderTab: some View { - HStack { - Button { - selectedTab = .notes - } label: { - HStack { - Spacer() - let color = selectedTab == .notes ? Color.primaryTxt : .secondaryTxt - Image.profilePosts - .renderingMode(.template) - .foregroundColor(color) - Text("notes") - .font(.subheadline.weight(.medium)) - .foregroundColor(color) - Spacer() - } - } - .frame(maxWidth: .infinity) - - Button { - selectedTab = .activity - } label: { - HStack { - Spacer() - let color = selectedTab == .activity ? Color.primaryTxt : .secondaryTxt - Image.profileFeed - .renderingMode(.template) - .foregroundColor(color) - Text("activity") - .foregroundColor(color) - .font(.subheadline.weight(.medium)) - Spacer() - } - } - .frame(maxWidth: .infinity) - } - .padding(.top, 12) - .padding(.bottom, 15) - } } #Preview { From 335d8db1e71200a1b3fb13c70a22eab0769fe2f5 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Tue, 31 Dec 2024 08:51:59 -0600 Subject: [PATCH 17/26] remove debug code --- Nos/Views/Components/NosSegmentedPicker.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Nos/Views/Components/NosSegmentedPicker.swift b/Nos/Views/Components/NosSegmentedPicker.swift index 83cd94526..b0eb6efee 100644 --- a/Nos/Views/Components/NosSegmentedPicker.swift +++ b/Nos/Views/Components/NosSegmentedPicker.swift @@ -37,6 +37,5 @@ struct NosSegmentedPicker: View { } .padding(.top, 12) .padding(.bottom, 15) - .border(.green) } } From 9f9d9a33d63fe26b45a6885c2958265f3f6720d2 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Tue, 31 Dec 2024 09:00:24 -0600 Subject: [PATCH 18/26] use new segmented picker in feed customizer --- Nos/Views/Home/FeedCustomizerView.swift | 44 ++++++++++++++++++++----- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/Nos/Views/Home/FeedCustomizerView.swift b/Nos/Views/Home/FeedCustomizerView.swift index 841ad8311..7e362d5e6 100644 --- a/Nos/Views/Home/FeedCustomizerView.swift +++ b/Nos/Views/Home/FeedCustomizerView.swift @@ -1,26 +1,52 @@ import SwiftUI +enum FeedTab: String { + case lists + case relays +} + +extension FeedTab: NosSegmentedPickerItem { + var id: String { + rawValue + } + + var titleKey: LocalizedStringKey { + switch self { + case .lists: + "Lists" + case .relays: + "Relays" + } + } + + var image: Image { + switch self { + case .lists: + Image(systemName: "person.2") + case .relays: + Image(systemName: "dot.radiowaves.left.and.right") + } + } +} + struct FeedCustomizerView: View { @Environment(FeedController.self) var feedController let author: Author @Binding var shouldNavigateToRelays: Bool - @AppStorage("selectedFeedTogglesTab") private var selectedTab = "Lists" + @AppStorage("selectedFeedTogglesTab") private var selectedTab = FeedTab.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) + NosSegmentedPicker( + items: [FeedTab.lists, FeedTab.relays], + selectedItem: $selectedTab + ) } - if selectedTab == "Lists" { + if selectedTab == .lists { FeedSourceToggleView( author: author, headerText: Text("Add lists to your feed to filter by topic."), From 8a02fee34318e1770f047ac8a7efec2edd115a41 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Thu, 2 Jan 2025 07:08:27 -0600 Subject: [PATCH 19/26] fixed a case where lists don't show up immediately after signing in --- CHANGELOG.md | 1 + Nos/Controller/FeedController.swift | 14 ++++---------- Nos/Service/CurrentUser.swift | 7 ++++++- Nos/Views/Home/HomeFeedView.swift | 9 ++++++++- NosTests/Service/Relay/RelayServiceTests.swift | 2 +- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35847084d..bdda51cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added pop-up tip for feed customization. [#101](https://github.com/verse-pbc/issues/issues/101) - Added remembering which feed source is selected. - Factored out the segmented picker on the ProfileHeader for reusability. +- Fixed a case where lists don't show up immediately after signing in. ### Internal Changes - Upgraded to Xcode 16. [#1570](https://github.com/planetary-social/nos/issues/1570) diff --git a/Nos/Controller/FeedController.swift b/Nos/Controller/FeedController.swift index e07316273..d3ea272ee 100644 --- a/Nos/Controller/FeedController.swift +++ b/Nos/Controller/FeedController.swift @@ -74,7 +74,8 @@ enum FeedSource: RawRepresentable, Hashable, Equatable { @Observable @MainActor final class FeedController { @ObservationIgnored @Dependency(\.persistenceController) private var persistenceController - @ObservationIgnored @Dependency(\.currentUser) private var currentUser + + let author: Author var enabledSources: [FeedSource] = [.following] @@ -106,7 +107,8 @@ enum FeedSource: RawRepresentable, Hashable, Equatable { private var cancellables = Set() - init() { + init(author: Author) { + self.author = author observeLists() observeRelays() @@ -118,10 +120,6 @@ enum FeedSource: RawRepresentable, Hashable, Equatable { } 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( @@ -147,10 +145,6 @@ enum FeedSource: RawRepresentable, Hashable, Equatable { } private func observeRelays() { - guard let author = currentUser.author else { - return - } - let request = Relay.relays(for: author) let relayWatcher = NSFetchedResultsController( diff --git a/Nos/Service/CurrentUser.swift b/Nos/Service/CurrentUser.swift index d3e37f7e8..aa20654c4 100644 --- a/Nos/Service/CurrentUser.swift +++ b/Nos/Service/CurrentUser.swift @@ -177,7 +177,7 @@ import Dependencies } /// Subscribes to relays for important events concerning the current user like their latest contact list, - /// notifications, reports, mutes, zaps, etc. + /// notifications, reports, mutes, zaps, lists etc. @MainActor func subscribe() async { guard let key = publicKeyHex, let author else { return @@ -191,6 +191,11 @@ import Dependencies await relayService.requestContactList(for: key, since: author.lastUpdatedContactList) ) + // Always request the user's lists + subscriptions.append( + await relayService.requestAuthorLists(for: key, since: nil) + ) + // Subscribe to important events we may not get incidentally while browsing the feed let latestReceivedEvent = try? viewContext.fetch(Event.lastReceived(for: author)).first let importantEventsFilter = Filter( diff --git a/Nos/Views/Home/HomeFeedView.swift b/Nos/Views/Home/HomeFeedView.swift index 5e8007a35..a79e151ca 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -12,7 +12,7 @@ struct HomeFeedView: View { @State private var refreshController = RefreshController(lastRefreshDate: Date.now + Self.staticLoadTime) @State private var isVisible = false - @State private var feedController = FeedController() + @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`. @@ -30,6 +30,13 @@ struct HomeFeedView: View { @Binding var showFeedTip: Bool @Binding var scrollOffsetY: CGFloat + init(user: Author, showFeedTip: Binding, scrollOffsetY: Binding) { + self.user = user + self._showFeedTip = showFeedTip + self._scrollOffsetY = scrollOffsetY + _feedController = State(initialValue: FeedController(author: user)) + } + /// A tip to display at the top of the feed. private let welcomeTip = WelcomeToFeedTip() diff --git a/NosTests/Service/Relay/RelayServiceTests.swift b/NosTests/Service/Relay/RelayServiceTests.swift index 4a0977a84..c5f75edc4 100644 --- a/NosTests/Service/Relay/RelayServiceTests.swift +++ b/NosTests/Service/Relay/RelayServiceTests.swift @@ -17,7 +17,7 @@ class RelayServiceTests: XCTestCase { XCTAssertEqual(resultFilter, expectedFilter) } - func test_requestFollowSets_uses_correct_filter() async throws { + func test_requestAuthorLists_uses_correct_filter() async throws { let since = Date() let expectedFilter = Filter( authorKeys: ["test"], From 0f21e59d31f7e623b4ff88dd7a9fea0e5b58dbb1 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Thu, 2 Jan 2025 07:37:31 -0600 Subject: [PATCH 20/26] fixed a minor cell layout issue on feed customizer drop-down view --- CHANGELOG.md | 1 + Nos/Views/Home/FeedSourceToggleView.swift | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdda51cd4..679b1818f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added remembering which feed source is selected. - Factored out the segmented picker on the ProfileHeader for reusability. - Fixed a case where lists don't show up immediately after signing in. +- Fixed a minor cell layout issue on feed customizer drop-down view. ### Internal Changes - Upgraded to Xcode 16. [#1570](https://github.com/planetary-social/nos/issues/1570) diff --git a/Nos/Views/Home/FeedSourceToggleView.swift b/Nos/Views/Home/FeedSourceToggleView.swift index a5605f92a..142d53a9d 100644 --- a/Nos/Views/Home/FeedSourceToggleView.swift +++ b/Nos/Views/Home/FeedSourceToggleView.swift @@ -46,7 +46,7 @@ struct FeedSourceToggleView: View { FeedToggleRow(item: item) .fixedSize(horizontal: false, vertical: true) - .padding(.vertical, 4) + .padding(.vertical, 10) .onChange(of: item.isOn) { _, _ in feedController.toggleSourceEnabled(item.source) } @@ -58,13 +58,15 @@ struct FeedSourceToggleView: View { .padding(.horizontal, 16) ViewThatFits(in: .vertical) { - VStack { + VStack(spacing: 0) { rows Spacer() } ScrollView { - rows + VStack(spacing: 0) { + rows + } } } .geometryGroup() From f69d4aa2e13ef286de6014d5f8915b2b64067089 Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Fri, 3 Jan 2025 07:09:10 -0600 Subject: [PATCH 21/26] PR feedback --- Nos.xcodeproj/project.pbxproj | 6 +++ Nos/Controller/FeedController.swift | 72 +---------------------------- Nos/Models/FeedSource.swift | 67 +++++++++++++++++++++++++++ Nos/Service/CurrentUser.swift | 2 +- Nos/Views/Home/HomeFeedView.swift | 1 + 5 files changed, 77 insertions(+), 71 deletions(-) create mode 100644 Nos/Models/FeedSource.swift diff --git a/Nos.xcodeproj/project.pbxproj b/Nos.xcodeproj/project.pbxproj index 369a9fad8..3258f155e 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -226,6 +226,8 @@ 50DE6B1B2C6B88FE0065665D /* View+StyledBorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50DE6B1A2C6B88FE0065665D /* View+StyledBorder.swift */; }; 50E2EB722C86175900D4B360 /* NSRegularExpression+Replacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2EB712C86175900D4B360 /* NSRegularExpression+Replacement.swift */; }; 50E2EB7B2C8617C800D4B360 /* NSRegularExpression+Replacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2EB712C86175900D4B360 /* NSRegularExpression+Replacement.swift */; }; + 50EA86D42D28150F001E62CC /* FeedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EA86D32D28150D001E62CC /* FeedSource.swift */; }; + 50EA86D52D28150F001E62CC /* FeedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EA86D32D28150D001E62CC /* FeedSource.swift */; }; 50F695072C6392C4000E4C74 /* zap_receipt.json in Resources */ = {isa = PBXBuildFile; fileRef = 50F695062C6392C4000E4C74 /* zap_receipt.json */; }; 5B098DBC2BDAF6CB00500A1B /* NoteParserTests+NIP08.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B098DBB2BDAF6CB00500A1B /* NoteParserTests+NIP08.swift */; }; 5B098DC62BDAF73500500A1B /* AttributedString+Links.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B098DC52BDAF73500500A1B /* AttributedString+Links.swift */; }; @@ -791,6 +793,7 @@ 5095330A2C625B5D00E0BACA /* zap_request_no_amount.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = zap_request_no_amount.json; sourceTree = ""; }; 50DE6B1A2C6B88FE0065665D /* View+StyledBorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+StyledBorder.swift"; sourceTree = ""; }; 50E2EB712C86175900D4B360 /* NSRegularExpression+Replacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+Replacement.swift"; sourceTree = ""; }; + 50EA86D32D28150D001E62CC /* FeedSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSource.swift; sourceTree = ""; }; 50F695062C6392C4000E4C74 /* zap_receipt.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = zap_receipt.json; sourceTree = ""; }; 5B098DBB2BDAF6CB00500A1B /* NoteParserTests+NIP08.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NoteParserTests+NIP08.swift"; sourceTree = ""; }; 5B098DC52BDAF73500500A1B /* AttributedString+Links.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Links.swift"; sourceTree = ""; }; @@ -1966,6 +1969,7 @@ 03FE3F782C87A9D900D25810 /* EventError.swift */, 0365CD862C4016A200622A1A /* EventKind.swift */, C9EE3E622A053910008A7491 /* ExpirationTimeOption.swift */, + 50EA86D32D28150D001E62CC /* FeedSource.swift */, C93CA0C229AE3A1E00921183 /* JSONEvent.swift */, 50089A002C9712EF00834588 /* JSONEvent+Kinds.swift */, 5B503F612A291A1A0098805A /* JSONRelayMetadata.swift */, @@ -2417,6 +2421,7 @@ C9CE5B142A0172CF008E198C /* WebView.swift in Sources */, CD4908D429B92941007443DB /* ReportABugMailView.swift in Sources */, 0314D5AC2C7D31060002E7F4 /* MediaService.swift in Sources */, + 50EA86D52D28150F001E62CC /* FeedSource.swift in Sources */, 5022FBCF2D242C850012FF4B /* NosSegmentedPicker.swift in Sources */, 5B7C93B02B6AD52400410ABE /* CreateUsernameWizard.swift in Sources */, 030036852C5D39DD002C71F5 /* RefreshController.swift in Sources */, @@ -2722,6 +2727,7 @@ C9EE3E642A053910008A7491 /* ExpirationTimeOption.swift in Sources */, 504454712C90728E00251A7E /* Event+Fetching.swift in Sources */, 03FFCA7C2D07721100D6F0F1 /* AuthorListError.swift in Sources */, + 50EA86D42D28150F001E62CC /* FeedSource.swift in Sources */, 65D066AA2BD55E160011C5CD /* DirectMessageWrapper.swift in Sources */, C973AB5E2A323167002AED16 /* Event+CoreDataProperties.swift in Sources */, C9F64D8D29ED840700563F2B /* Zipper.swift in Sources */, diff --git a/Nos/Controller/FeedController.swift b/Nos/Controller/FeedController.swift index d3ea272ee..7e4c3b284 100644 --- a/Nos/Controller/FeedController.swift +++ b/Nos/Controller/FeedController.swift @@ -3,74 +3,6 @@ import CoreData import Dependencies import SwiftUI -/// The source to be used for a feed of notes. -enum FeedSource: RawRepresentable, 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 - } - } - - // Note: RawRepresentable conformance is required for use of @AppStorage for persistence. - var rawValue: String { - switch self { - case .following: - "following" - case .relay(let host, let description): - "relay:|\(host):|\(description ?? "")" - case .list(let name, let description): - "list:|\(name):|\(description ?? "")" - } - } - - init?(rawValue: String) { - let components = rawValue.split(separator: ":|").map { String($0) } - guard let caseName = components.first else { - return nil - } - - switch caseName { - case "following": - self = .following - case "relay": - guard components.count >= 2 else { - return nil - } - let description = components.count >= 3 ? components[2] : "" - self = .relay(components[1], description) - case "list": - guard components.count >= 2 else { - return nil - } - let description = components.count >= 3 ? components[2] : "" - self = .list(components[1], description) - default: - return nil - } - } -} - @Observable @MainActor final class FeedController { @ObservationIgnored @Dependency(\.persistenceController) private var persistenceController @@ -190,7 +122,7 @@ enum FeedSource: RawRepresentable, Hashable, Equatable { var relayItems = [FeedToggleRow.Item]() for list in lists { - let source = FeedSource.list(list.title ?? "??", nil) + let source = FeedSource.list(name: list.title ?? "??", description: nil) if list.isFeedEnabled { enabledSources.append(source) @@ -200,7 +132,7 @@ enum FeedSource: RawRepresentable, Hashable, Equatable { } for relay in relays { - let source = FeedSource.relay(relay.host ?? "", relay.relayDescription) + let source = FeedSource.relay(host: relay.host ?? "", description: relay.relayDescription) if relay.isFeedEnabled { enabledSources.append(source) diff --git a/Nos/Models/FeedSource.swift b/Nos/Models/FeedSource.swift new file mode 100644 index 000000000..99cf74f50 --- /dev/null +++ b/Nos/Models/FeedSource.swift @@ -0,0 +1,67 @@ +/// The source to be used for a feed of notes. +enum FeedSource: RawRepresentable, Hashable, Equatable { + case following + case relay(host: String, description: String?) + case list(name: String, description: 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 + } + } + + // Note: RawRepresentable conformance is required for use of @AppStorage for persistence. + var rawValue: String { + switch self { + case .following: + "following" + case .relay(let host, let description): + "relay:|\(host):|\(description ?? "")" + case .list(let name, let description): + "list:|\(name):|\(description ?? "")" + } + } + + init?(rawValue: String) { + let components = rawValue.split(separator: ":|").map { String($0) } + guard let caseName = components.first else { + return nil + } + + switch caseName { + case "following": + self = .following + case "relay": + guard components.count >= 2 else { + return nil + } + let description = components.count >= 3 ? components[2] : "" + self = .relay(host: components[1], description: description) + case "list": + guard components.count >= 2 else { + return nil + } + let description = components.count >= 3 ? components[2] : "" + self = .list(name: components[1], description: description) + default: + return nil + } + } +} diff --git a/Nos/Service/CurrentUser.swift b/Nos/Service/CurrentUser.swift index aa20654c4..ceca6c364 100644 --- a/Nos/Service/CurrentUser.swift +++ b/Nos/Service/CurrentUser.swift @@ -200,7 +200,7 @@ import Dependencies let latestReceivedEvent = try? viewContext.fetch(Event.lastReceived(for: author)).first let importantEventsFilter = Filter( authorKeys: [key], - kinds: [.mute, .delete, .report, .contactList, .zapRequest], + kinds: [.mute, .delete, .report, .contactList, .zapRequest, .followSet], limit: 100, since: latestReceivedEvent?.receivedAt, keepSubscriptionOpen: true diff --git a/Nos/Views/Home/HomeFeedView.swift b/Nos/Views/Home/HomeFeedView.swift index a79e151ca..0facd3223 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -110,6 +110,7 @@ struct HomeFeedView: View { NewNotesButton(fetchRequest: FetchRequest(fetchRequest: newNotesRequest)) { refreshController.startRefresh = true } + .padding(.top, 44) if showTimedLoadingIndicator { FullscreenProgressView( From 041ce6dbeb0e89618b5380f7155cdf9716a42cdc Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Fri, 3 Jan 2025 08:09:10 -0600 Subject: [PATCH 22/26] fixed issue where feed shows following content rather than selected list after app restart #114 --- CHANGELOG.md | 1 + Nos/Controller/FeedController.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f25f97b..64d81dbde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Factored out the segmented picker on the ProfileHeader for reusability. - Fixed a case where lists don't show up immediately after signing in. - Fixed a minor cell layout issue on feed customizer drop-down view. +- Fixed issue where feed shows following content rather than selected list after app restart. [#114](https://github.com/verse-pbc/issues/issues/114) ### Internal Changes - Upgraded to Xcode 16. [#1570](https://github.com/planetary-social/nos/issues/1570) diff --git a/Nos/Controller/FeedController.swift b/Nos/Controller/FeedController.swift index 7e4c3b284..8572e45a3 100644 --- a/Nos/Controller/FeedController.swift +++ b/Nos/Controller/FeedController.swift @@ -46,7 +46,7 @@ import SwiftUI // The delay here is an unfortunate workaround. Without it, the feed always resumes to // the default value of FeedSource.following. - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) { self.selectedSource = self.persistedSelectedSource } } From 0b2d8e32f2bc19e3bf337ba8927441470b166e6e Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Fri, 3 Jan 2025 08:16:01 -0600 Subject: [PATCH 23/26] update relays icon on the feed customizer view to match the one in side menu --- CHANGELOG.md | 1 + Nos/Views/Home/FeedCustomizerView.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d81dbde..f0a79aabd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed a case where lists don't show up immediately after signing in. - Fixed a minor cell layout issue on feed customizer drop-down view. - Fixed issue where feed shows following content rather than selected list after app restart. [#114](https://github.com/verse-pbc/issues/issues/114) +- Update relays icon on the feed customizer view to match the one in side menu. ### Internal Changes - Upgraded to Xcode 16. [#1570](https://github.com/planetary-social/nos/issues/1570) diff --git a/Nos/Views/Home/FeedCustomizerView.swift b/Nos/Views/Home/FeedCustomizerView.swift index 7e362d5e6..e3c498ac9 100644 --- a/Nos/Views/Home/FeedCustomizerView.swift +++ b/Nos/Views/Home/FeedCustomizerView.swift @@ -24,7 +24,7 @@ extension FeedTab: NosSegmentedPickerItem { case .lists: Image(systemName: "person.2") case .relays: - Image(systemName: "dot.radiowaves.left.and.right") + Image(systemName: "antenna.radiowaves.left.and.right") } } } From f467e13f4359a2827111dd855cfbed6758806012 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 3 Jan 2025 11:50:53 -0500 Subject: [PATCH 24/26] Stop deploying Nos Staging from feature/lists-ui --- .github/workflows/testflight-staging-deploy.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/testflight-staging-deploy.yml b/.github/workflows/testflight-staging-deploy.yml index 54fd478f0..b5fa4b6a7 100644 --- a/.github/workflows/testflight-staging-deploy.yml +++ b/.github/workflows/testflight-staging-deploy.yml @@ -3,7 +3,6 @@ on: push: branches: - main - - feature/lists-ui # Enable manual run workflow_dispatch: From b7dc28059445f6dd83b3dd7aef24cf4a193dc769 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 3 Jan 2025 11:53:16 -0500 Subject: [PATCH 25/26] Disable loading selected feed from disk on launch --- Nos/Controller/FeedController.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Nos/Controller/FeedController.swift b/Nos/Controller/FeedController.swift index 8572e45a3..24afa5f32 100644 --- a/Nos/Controller/FeedController.swift +++ b/Nos/Controller/FeedController.swift @@ -44,11 +44,13 @@ import SwiftUI observeLists() observeRelays() + // TODO: I commented this code out because it wasn't fixing the bug it was intended to yet. Let's come back to + // it. https://github.com/planetary-social/nos/pull/1720#issuecomment-2569529771 // The delay here is an unfortunate workaround. Without it, the feed always resumes to // the default value of FeedSource.following. - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) { - self.selectedSource = self.persistedSelectedSource - } + // DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) { + // self.selectedSource = self.persistedSelectedSource + //} } private func observeLists() { From 4e50522c16ceb3b4fa80ac50cb298b5ef9c7bab9 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 3 Jan 2025 12:33:56 -0500 Subject: [PATCH 26/26] Fix swiftlint warning --- Nos/Controller/FeedController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Nos/Controller/FeedController.swift b/Nos/Controller/FeedController.swift index 24afa5f32..ba5cbdcda 100644 --- a/Nos/Controller/FeedController.swift +++ b/Nos/Controller/FeedController.swift @@ -50,7 +50,7 @@ import SwiftUI // the default value of FeedSource.following. // DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) { // self.selectedSource = self.persistedSelectedSource - //} + // } } private func observeLists() {