Skip to content

Commit

Permalink
Merge pull request #1715 from planetary-social/bdm/feed-tip
Browse files Browse the repository at this point in the history
added pop-up tip for feed customization #101
  • Loading branch information
bryanmontz authored Dec 30, 2024
2 parents 7c14d54 + c7ea025 commit 1f2a210
Show file tree
Hide file tree
Showing 8 changed files with 187 additions and 11 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions Nos.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -766,6 +767,7 @@
501728B32D16EFAC00CF2A07 /* FeedPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPicker.swift; sourceTree = "<group>"; };
5022F9452D2186300012FF4B /* follow_set_private.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = follow_set_private.json; sourceTree = "<group>"; };
5022F9472D2188650012FF4B /* Nos 23.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Nos 23.xcdatamodel"; sourceTree = "<group>"; };
5022FB342D2303F00012FF4B /* FeedSelectorTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSelectorTip.swift; sourceTree = "<group>"; };
502B6C3C2C9462A400446316 /* PushNotificationRegistrar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationRegistrar.swift; sourceTree = "<group>"; };
503CA9522D19ACC800805EF8 /* HorizontalLine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalLine.swift; sourceTree = "<group>"; };
503CA9782D19C39800805EF8 /* FeedCustomizerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCustomizerView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1718,6 +1720,7 @@
children = (
503CA9782D19C39800805EF8 /* FeedCustomizerView.swift */,
501728B32D16EFAC00CF2A07 /* FeedPicker.swift */,
5022FB342D2303F00012FF4B /* FeedSelectorTip.swift */,
503CAC602D1EF71700805EF8 /* FeedSourceToggleView.swift */,
503CAB6D2D1DA17100805EF8 /* FeedToggleRow.swift */,
C9DEBFD8298941000078B43A /* HomeFeedView.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
20 changes: 20 additions & 0 deletions Nos/Assets/Colors.xcassets/tip-shadow.colorset/Contents.json
Original file line number Diff line number Diff line change
@@ -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
}
}
19 changes: 15 additions & 4 deletions Nos/Views/Components/PagedNoteListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ struct PagedNoteListView<Header: View, EmptyPlaceholder: View>: 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<Event>

Expand All @@ -45,7 +48,7 @@ struct PagedNoteListView<Header: View, EmptyPlaceholder: View>: UIViewRepresenta
let emptyPlaceholder: () -> EmptyPlaceholder

func makeCoordinator() -> Coordinator<Header, EmptyPlaceholder> {
Coordinator(refreshController: refreshController)
Coordinator(refreshController: refreshController, scrollOffsetY: $scrollOffsetY)
}

func makeUIView(context: Context) -> UICollectionView {
Expand All @@ -65,6 +68,7 @@ struct PagedNoteListView<Header: View, EmptyPlaceholder: View>: UIViewRepresenta
emptyPlaceholder: emptyPlaceholder
)
collectionView.dataSource = dataSource
collectionView.delegate = context.coordinator
collectionView.prefetchDataSource = dataSource

let refreshControl = UIRefreshControl()
Expand Down Expand Up @@ -173,11 +177,12 @@ struct PagedNoteListView<Header: View, EmptyPlaceholder: View>: UIViewRepresenta

// swiftlint:disable generic_type_name
/// The coordinator mainly holds a strong reference to the `dataSource` and proxies pull-to-refresh events.
class Coordinator<CoordinatorHeader: View, CoordinatorEmptyPlaceholder: View> {
class Coordinator<CoordinatorHeader: View, CoordinatorEmptyPlaceholder: View>: 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<CoordinatorHeader, CoordinatorEmptyPlaceholder>?
var collectionView: UICollectionView?
Expand All @@ -186,8 +191,9 @@ struct PagedNoteListView<Header: View, EmptyPlaceholder: View>: 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<CGFloat>) {
self.refreshController = refreshController
self._scrollOffsetY = scrollOffsetY
}

func dataSource(
Expand Down Expand Up @@ -229,6 +235,10 @@ struct PagedNoteListView<Header: View, EmptyPlaceholder: View>: UIViewRepresenta
}
}
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollOffsetY = scrollView.contentOffset.y
}
}
}

Expand All @@ -243,6 +253,7 @@ extension Notification.Name {

return PagedNoteListView(
refreshController: $refreshController,
scrollOffsetY: .constant(0),
databaseFilter: previewData.alice.allPostsRequest(onlyRootPosts: false),
relayFilter: Filter(),
relay: nil,
Expand Down
20 changes: 20 additions & 0 deletions Nos/Views/Home/FeedSelectorTip.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
6 changes: 5 additions & 1 deletion Nos/Views/Home/HomeFeedView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -77,6 +79,7 @@ struct HomeFeedView: View {

PagedNoteListView(
refreshController: $refreshController,
scrollOffsetY: $scrollOffsetY,
databaseFilter: homeFeedFetchRequest,
relayFilter: homeFeedFilter,
relay: feedController.selectedRelay,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down
126 changes: 120 additions & 6 deletions Nos/Views/Home/HomeTab.swift
Original file line number Diff line number Diff line change
@@ -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
}
}

Expand All @@ -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)
}
}
}
2 changes: 2 additions & 0 deletions Nos/Views/Profile/ProfileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct ProfileView: View {
@State private var selectedTab: ProfileFeedType = .notes

@State private var alert: AlertState<Never>?
@State private var scrollOffsetY: CGFloat = 0

var isShowingLoggedInUser: Bool {
author.hexadecimalPublicKey == currentUser.publicKeyHex
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 1f2a210

Please sign in to comment.