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.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/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/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 397ac26a1..744865716 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -27,6 +27,8 @@ struct HomeFeedView: View { private let stackSpacing: CGFloat = 8 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() @@ -77,6 +79,7 @@ struct HomeFeedView: View { PagedNoteListView( refreshController: $refreshController, + scrollOffsetY: $scrollOffsetY, databaseFilter: homeFeedFetchRequest, relayFilter: homeFeedFilter, relay: feedController.selectedRelay, @@ -147,6 +150,7 @@ struct HomeFeedView: View { Button { withAnimation { showFeedSelector.toggle() + showFeedTip = false } } label: { Image(systemName: showFeedSelector ? "xmark.circle.fill" : "line.3.horizontal.decrease.circle") @@ -207,7 +211,7 @@ struct HomeFeedView: View { } return NavigationStack { - HomeFeedView(user: previewData.alice) + 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 7a0892268..1bd2c5cc6 100644 --- a/Nos/Views/Home/HomeTab.swift +++ b/Nos/Views/Home/HomeTab.swift @@ -1,16 +1,126 @@ import SwiftUI -import Dependencies -struct HomeTab: View { +/// 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. +fileprivate 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) + } + } + } +} + +struct HomeTab: View { @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 { - NosNavigationStack(path: $router.homeFeedPath) { - HomeFeedView(user: user) + ZStack { + NosNavigationStack(path: $router.homeFeedPath) { + HomeFeedView( + user: user, + showFeedTip: $showFeedTip, + scrollOffsetY: $scrollOffsetY + ) + } + + 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 !feedTip.hasShown { + timer = Timer.scheduledTimer(withTimeInterval: FeedSelectorTip.maximumDelay, repeats: false) { _ in + showTip() + } + } + } + .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 } } @@ -20,8 +130,12 @@ struct HomeTab_Previews: PreviewProvider { static var previews: some View { NavigationView { - HomeFeedView(user: previewData.currentUser.author!) - .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,