From 4e12191893aef482b8ad8e986531bb9681406057 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Sat, 26 Feb 2022 20:01:38 -0800 Subject: [PATCH 1/9] drop from three panels to two on ipad/mac nav --- .../OmnivoreKit/Sources/App/Views/PrimaryContentView.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift index 422e770cb..69277f47c 100644 --- a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift @@ -26,17 +26,13 @@ public struct PrimaryContentView: View { return NavigationView { // The first column is the sidebar. PrimaryContentSidebar(categories: categories) - .navigationTitle("Categories") - // Initial Content of second column + // Second column is the Primary Nav Stack if let destinationView = categories.first?.destinationView { destinationView } else { Text("Select a Category") } - - // Initial content of detail view - Text("No Selection") } .accentColor(.appGrayTextContrast) } From c27eafebdedeb156a76d7529a01f667be3ca8905 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Sat, 26 Feb 2022 20:18:59 -0800 Subject: [PATCH 2/9] add navigation title back --- apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift index 69277f47c..9cd0c833e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift @@ -26,6 +26,7 @@ public struct PrimaryContentView: View { return NavigationView { // The first column is the sidebar. PrimaryContentSidebar(categories: categories) + .navigationBarTitle("Categories") // Second column is the Primary Nav Stack if let destinationView = categories.first?.destinationView { From bedd5d878453515bac87715b265b695734219b68 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Sat, 26 Feb 2022 20:29:53 -0800 Subject: [PATCH 3/9] add detail column for mac app only --- .../Sources/App/Views/PrimaryContentView.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift index 9cd0c833e..422a2c5e6 100644 --- a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift @@ -26,14 +26,15 @@ public struct PrimaryContentView: View { return NavigationView { // The first column is the sidebar. PrimaryContentSidebar(categories: categories) - .navigationBarTitle("Categories") + .navigationTitle("Categories") // Second column is the Primary Nav Stack - if let destinationView = categories.first?.destinationView { - destinationView - } else { - Text("Select a Category") - } + PrimaryContentCategory.feed.destinationView + + // Add a third column for macOS only + #if os(macOS) + Text("Select a link from the feed") + #endif } .accentColor(.appGrayTextContrast) } From 00fcd9946697fcb3518f4e367ec06d69b4072351 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Mon, 28 Feb 2022 08:53:58 -0800 Subject: [PATCH 4/9] destructure home feed components into platform specific parent components --- .../Sources/App/PrimaryContentCategory.swift | 2 +- .../Views/Home/HomeFeedViewComponents.swift | 80 ++++ .../App/Views/Home/HomeFeedViewIOS.swift | 239 ++++++++++ .../App/Views/Home/HomeFeedViewMac.swift | 69 +++ .../App/Views/Home/HomeFeedViewModel.swift | 188 ++++++++ .../Sources/App/Views/Home/HomeView.swift | 17 + .../Sources/App/Views/HomeFeedView.swift | 428 ------------------ .../App/Views/PrimaryContentView.swift | 2 +- 8 files changed, 595 insertions(+), 430 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewComponents.swift create mode 100644 apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift create mode 100644 apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift create mode 100644 apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift create mode 100644 apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift delete mode 100644 apple/OmnivoreKit/Sources/App/Views/HomeFeedView.swift diff --git a/apple/OmnivoreKit/Sources/App/PrimaryContentCategory.swift b/apple/OmnivoreKit/Sources/App/PrimaryContentCategory.swift index 699c63cb9..682555142 100644 --- a/apple/OmnivoreKit/Sources/App/PrimaryContentCategory.swift +++ b/apple/OmnivoreKit/Sources/App/PrimaryContentCategory.swift @@ -38,7 +38,7 @@ enum PrimaryContentCategory: Identifiable, Hashable, Equatable { @ViewBuilder var destinationView: some View { switch self { case .feed: - HomeFeedView() + HomeView() case .profile: ProfileView() } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewComponents.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewComponents.swift new file mode 100644 index 000000000..3468b9968 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewComponents.swift @@ -0,0 +1,80 @@ +import Models +import Services +import SwiftUI +import Views + +struct FeedItemContextMenuView: View { + @EnvironmentObject var dataService: DataService + + let item: FeedItem + + @Binding var selectedLinkItem: FeedItem? + @Binding var snoozePresented: Bool + @Binding var itemToSnooze: FeedItem? + + @ObservedObject var viewModel: HomeFeedViewModel + + var body: some View { + if !item.isArchived { + Button(action: { + withAnimation(.linear(duration: 0.4)) { + viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true) + if item == selectedLinkItem { + selectedLinkItem = nil + } + } + }, label: { Label("Archive", systemImage: "archivebox") }) + } else { + Button(action: { + withAnimation(.linear(duration: 0.4)) { + viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false) + } + }, label: { Label("Unarchive", systemImage: "tray.and.arrow.down.fill") }) + } + Button { + itemToSnooze = item + snoozePresented = true + } label: { + Label { Text("Snooze") } icon: { Image.moon } + } + } +} + +struct FeedCardNavigationLink: View { + @EnvironmentObject var dataService: DataService + + let item: FeedItem + let searchQuery: String + + @Binding var selectedLinkItem: FeedItem? + + @ObservedObject var viewModel: HomeFeedViewModel + var body: some View { + NavigationLink( + destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item)), + tag: item, + selection: $selectedLinkItem + ) { + EmptyView() + } + .opacity(0) + .buttonStyle(PlainButtonStyle()) + .onAppear { + viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService) + } + FeedCard(item: item) + } +} + +struct LoadingSection: View { + var body: some View { + Section { + HStack(alignment: .center) { + Spacer() + Text("Loading...") + Spacer() + } + .frame(maxWidth: .infinity) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift new file mode 100644 index 000000000..db7dee27b --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -0,0 +1,239 @@ +import Combine +import Models +import Services +import SwiftUI +import UserNotifications +import Utils +import Views + +#if os(iOS) + struct CompactHomeView: View { + @ObservedObject var viewModel: HomeFeedViewModel + + var body: some View { + NavigationView { + HomeFeedContainerView(viewModel: viewModel) + .toolbar { + ToolbarItem { + NavigationLink( + destination: { ProfileView() }, + label: { + Image.profile + .resizable() + .frame(width: 26, height: 26) + .padding() + } + ) + } + } + } + .accentColor(.appGrayTextContrast) + } + } + + struct HomeFeedContainerView: View { + @EnvironmentObject var dataService: DataService + @State private var searchQuery = "" + @ObservedObject var viewModel: HomeFeedViewModel + + var body: some View { + if #available(iOS 15.0, *) { + HomeFeedView(searchQuery: $searchQuery, viewModel: viewModel) + .refreshable { + viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + } + .searchable( + text: $searchQuery, + placement: .sidebar + ) { + if searchQuery.isEmpty { + Text("Inbox").searchCompletion("in:inbox ") + Text("All").searchCompletion("in:all ") + Text("Archived").searchCompletion("in:archive ") + Text("Files").searchCompletion("type:file ") + } + } + .onChange(of: searchQuery) { _ in + // Maybe we should debounce this, but + // it feels like it works ok without + viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + } + .onSubmit(of: .search) { + viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + } + } else { + HomeFeedView(searchQuery: $searchQuery, viewModel: viewModel).toolbar { + ToolbarItem { + Button( + action: { viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) }, + label: { Label("Refresh Feed", systemImage: "arrow.clockwise") } + ) + } + } + } + } + } + + struct HomeFeedView: View { + @EnvironmentObject var dataService: DataService + @Binding var searchQuery: String + + @State private var selectedLinkItem: FeedItem? + @State private var itemToRemove: FeedItem? + @State private var confirmationShown = false + @State private var snoozePresented = false + @State private var itemToSnooze: FeedItem? + + @ObservedObject var viewModel: HomeFeedViewModel + + let columns: [GridItem] = { + [GridItem(.adaptive(minimum: 300))] + }() + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 20) { + ForEach(viewModel.items) { item in + let link = ZStack { + NavigationLink( + destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item)), + tag: item, + selection: $selectedLinkItem + ) { + EmptyView() + } + .opacity(0) + .buttonStyle(PlainButtonStyle()) + .onAppear { + viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService) + } + FeedCard(item: item) + }.contextMenu { + if !item.isArchived { + Button(action: { + withAnimation(.linear(duration: 0.4)) { + viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true) + if item == selectedLinkItem { + selectedLinkItem = nil + } + } + }, label: { Label("Archive", systemImage: "archivebox") }) + } else { + Button(action: { + withAnimation(.linear(duration: 0.4)) { + viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false) + } + }, label: { Label("Unarchive", systemImage: "tray.and.arrow.down.fill") }) + } + Button { + itemToSnooze = item + snoozePresented = true + } label: { + Label { Text("Snooze") } icon: { Image.moon } + } + } + if #available(iOS 15.0, *) { + link + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + if !item.isArchived { + Button { + withAnimation(.linear(duration: 0.4)) { + viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true) + } + } label: { + Label("Archive", systemImage: "archivebox") + }.tint(.green) + } else { + Button { + withAnimation(.linear(duration: 0.4)) { + viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false) + } + } label: { + Label("Unarchive", systemImage: "tray.and.arrow.down.fill") + }.tint(.indigo) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button( + role: .destructive, + action: { + itemToRemove = item + confirmationShown = true + }, + label: { + Image(systemName: "trash") + } + ) + }.alert("Are you sure?", isPresented: $confirmationShown) { + Button("Remove Link", role: .destructive) { + if let itemToRemove = itemToRemove { + withAnimation { + viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id) + } + } + self.itemToRemove = nil + } + Button("Cancel", role: .cancel) { self.itemToRemove = nil } + } + // .swipeActions(edge: .leading, allowsFullSwipe: true) { + // Button { + // itemToSnooze = item + // snoozePresented = true + // } label: { + // Label { Text("Snooze") } icon: { Image.moon } + // }.tint(.appYellow48) + // } + } else { + link + } + } + } + + if viewModel.isLoading { + Section { + HStack(alignment: .center) { + Spacer() + Text("Loading...") + Spacer() + } + .frame(maxWidth: .infinity) + } + } + } + .listStyle(PlainListStyle()) + .navigationTitle("Home") + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in + // Don't refresh the list if the user is currently reading an article + if selectedLinkItem == nil { + refresh() + } + } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushFeedItem"))) { notification in + if let feedItem = notification.userInfo?["feedItem"] as? FeedItem { + viewModel.pushFeedItem(item: feedItem) + self.selectedLinkItem = feedItem + } + } + .formSheet(isPresented: $snoozePresented) { + SnoozeView(snoozePresented: $snoozePresented, itemToSnooze: $itemToSnooze) { + viewModel.snoozeUntil( + dataService: dataService, + linkId: $0.feedItemId, + until: $0.snoozeUntilDate, + successMessage: $0.successMessage + ) + } + } + .onAppear { + if viewModel.items.isEmpty { + refresh() + } + } + } + + private func refresh() { + viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + } + } + +#endif diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift new file mode 100644 index 000000000..3190812b3 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift @@ -0,0 +1,69 @@ +import Combine +import Models +import Services +import SwiftUI +import UserNotifications +import Utils +import Views + +#if os(macOS) + struct HomeFeedView: View { + @EnvironmentObject var dataService: DataService + @State var searchQuery = "" + @State private var selectedLinkItem: FeedItem? + @State private var itemToRemove: FeedItem? + @State private var confirmationShown = false + @State private var snoozePresented = false + @State private var itemToSnooze: FeedItem? + + @ObservedObject var viewModel: HomeFeedViewModel + + var body: some View { + List { + Section { + ForEach(viewModel.items) { item in + ZStack { + FeedCardNavigationLink( + item: item, + searchQuery: searchQuery, + selectedLinkItem: $selectedLinkItem, + viewModel: viewModel + ) + }.contextMenu { + FeedItemContextMenuView( + item: item, + selectedLinkItem: $selectedLinkItem, + snoozePresented: $snoozePresented, + itemToSnooze: $itemToSnooze, + viewModel: viewModel + ) + } + } + } + + if viewModel.isLoading { + LoadingSection() + } + } + .listStyle(PlainListStyle()) + .navigationTitle("Home") + .toolbar { + ToolbarItem { + Button( + action: { + viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + }, + label: { Label("Refresh Feed", systemImage: "arrow.clockwise") } + ) + } + } + .onAppear { + if viewModel.items.isEmpty { + viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + } + } + } + } + +// TODO: handle $snoozePresented == true +#endif diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift new file mode 100644 index 000000000..20204280e --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -0,0 +1,188 @@ +import Combine +import Models +import Services +import SwiftUI +import Utils +import Views + +final class HomeFeedViewModel: ObservableObject { + var currentDetailViewModel: LinkItemDetailViewModel? + + @Published var items = [FeedItem]() + @Published var isLoading = false + @Published var showPushNotificationPrimer = false + var cursor: String? + + // These are used to make sure we handle search result + // responses in the right order + var searchIdx = 0 + var receivedIdx = 0 + + var subscriptions = Set() + + init() {} + + func itemAppeared(item: FeedItem, searchQuery: String, dataService: DataService) { + if isLoading { return } + let itemIndex = items.firstIndex(where: { $0.id == item.id }) + let thresholdIndex = items.index(items.endIndex, offsetBy: -5) + + // Check if user has scrolled to the last five items in the list + if let itemIndex = itemIndex, itemIndex > thresholdIndex, items.count < thresholdIndex + 10 { + loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: false) + } + } + + func pushFeedItem(item: FeedItem) { + items.insert(item, at: 0) + } + + func loadItems(dataService: DataService, searchQuery: String?, isRefresh: Bool) { + // Clear offline highlights since we'll be populating new FeedItems with the correct highlights set + dataService.clearHighlights() + + let thisSearchIdx = searchIdx + searchIdx += 1 + + isLoading = true + startNetworkActivityIndicator() + + // Cache the viewer + if dataService.currentViewer == nil { + dataService.viewerPublisher().sink( + receiveCompletion: { _ in }, + receiveValue: { _ in } + ) + .store(in: &subscriptions) + } + + dataService.libraryItemsPublisher( + limit: 10, + sortDescending: true, + searchQuery: searchQuery, + cursor: isRefresh ? nil : cursor + ) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(error) = completion else { return } + self?.isLoading = false + stopNetworkActivityIndicator() + print(error) + }, + receiveValue: { [weak self] result in + // Search results aren't guaranteed to return in order so this + // will discard old results that are returned while a user is typing. + // For example if a user types 'Canucks', often the search results + // for 'C' are returned after 'Canucks' because it takes the backend + // much longer to compute. + if thisSearchIdx > 0, thisSearchIdx <= self?.receivedIdx ?? 0 { + return + } + self?.items = isRefresh ? result.items : (self?.items ?? []) + result.items + self?.isLoading = false + self?.receivedIdx = thisSearchIdx + self?.cursor = result.cursor + stopNetworkActivityIndicator() + } + ) + .store(in: &subscriptions) + } + + func setLinkArchived(dataService: DataService, linkId: String, archived: Bool) { + isLoading = true + startNetworkActivityIndicator() + + // First remove the link from the internal list, + // then make a call to remove it. The isLoading block should + // prevent our local change from being overwritten, but we + // might need to cache a local list of archived links + if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { + items.remove(at: itemIndex) + } + + dataService.archiveLinkPublisher(itemID: linkId, archived: archived) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(error) = completion else { return } + self?.isLoading = false + stopNetworkActivityIndicator() + print(error) + NSNotification.operationFailed(message: archived ? "Failed to archive link" : "Failed to unarchive link") + }, + receiveValue: { [weak self] _ in + self?.isLoading = false + stopNetworkActivityIndicator() + Snackbar.show(message: archived ? "Link archived" : "Link moved to Inbox") + } + ) + .store(in: &subscriptions) + } + + func removeLink(dataService: DataService, linkId: String) { + isLoading = true + startNetworkActivityIndicator() + + if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { + items.remove(at: itemIndex) + } + + dataService.removeLinkPublisher(itemID: linkId) + .sink( + receiveCompletion: { [weak self] completion in + guard case .failure = completion else { return } + self?.isLoading = false + stopNetworkActivityIndicator() + Snackbar.show(message: "Failed to remove link") + }, + receiveValue: { [weak self] _ in + self?.isLoading = false + stopNetworkActivityIndicator() + Snackbar.show(message: "Link removed") + } + ) + .store(in: &subscriptions) + } + + func snoozeUntil(dataService: DataService, linkId: String, until: Date, successMessage: String?) { + isLoading = true + startNetworkActivityIndicator() + + if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { + items.remove(at: itemIndex) + } + + dataService.createReminderPublisher( + reminderItemId: .link(id: linkId), + remindAt: until + ) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(error) = completion else { return } + self?.isLoading = false + stopNetworkActivityIndicator() + print(error) + NSNotification.operationFailed(message: "Failed to snooze") + }, + receiveValue: { [weak self] _ in + self?.isLoading = false + stopNetworkActivityIndicator() + if let message = successMessage { + Snackbar.show(message: message) + } + } + ) + .store(in: &subscriptions) + } +} + +private func startNetworkActivityIndicator() { + #if os(iOS) + UIApplication.shared.isNetworkActivityIndicatorVisible = true + #endif +} + +private func stopNetworkActivityIndicator() { + #if os(iOS) + UIApplication.shared.isNetworkActivityIndicatorVisible = false + #endif +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift new file mode 100644 index 000000000..cc074c995 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct HomeView: View { + @StateObject private var viewModel = HomeFeedViewModel() + + var body: some View { + #if os(iOS) + if UIDevice.isIPhone { + CompactHomeView(viewModel: viewModel) + } else { + HomeFeedContainerView(viewModel: viewModel) + } + #elseif os(macOS) + HomeFeedView(viewModel: viewModel) + #endif + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/HomeFeedView.swift b/apple/OmnivoreKit/Sources/App/Views/HomeFeedView.swift deleted file mode 100644 index 759dbbd63..000000000 --- a/apple/OmnivoreKit/Sources/App/Views/HomeFeedView.swift +++ /dev/null @@ -1,428 +0,0 @@ -import Combine -import Models -import Services -import SwiftUI -import UserNotifications -import Utils -import Views - -final class HomeFeedViewModel: ObservableObject { - var currentDetailViewModel: LinkItemDetailViewModel? - - @Published var items = [FeedItem]() - @Published var isLoading = false - @Published var showPushNotificationPrimer = false - var cursor: String? - - // These are used to make sure we handle search result - // responses in the right order - var searchIdx = 0 - var receivedIdx = 0 - - var subscriptions = Set() - - init() {} - - func itemAppeared(item: FeedItem, searchQuery: String, dataService: DataService) { - if isLoading { return } - let itemIndex = items.firstIndex(where: { $0.id == item.id }) - let thresholdIndex = items.index(items.endIndex, offsetBy: -5) - - // Check if user has scrolled to the last five items in the list - if let itemIndex = itemIndex, itemIndex > thresholdIndex, items.count < thresholdIndex + 10 { - loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: false) - } - } - - func pushFeedItem(item: FeedItem) { - items.insert(item, at: 0) - } - - func loadItems(dataService: DataService, searchQuery: String?, isRefresh: Bool) { - // Clear offline highlights since we'll be populating new FeedItems with the correct highlights set - dataService.clearHighlights() - - let thisSearchIdx = searchIdx - searchIdx += 1 - - isLoading = true - startNetworkActivityIndicator() - - // Cache the viewer - if dataService.currentViewer == nil { - dataService.viewerPublisher().sink( - receiveCompletion: { _ in }, - receiveValue: { _ in } - ) - .store(in: &subscriptions) - } - - dataService.libraryItemsPublisher( - limit: 10, - sortDescending: true, - searchQuery: searchQuery, - cursor: isRefresh ? nil : cursor - ) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(error) = completion else { return } - self?.isLoading = false - stopNetworkActivityIndicator() - print(error) - }, - receiveValue: { [weak self] result in - // Search results aren't guaranteed to return in order so this - // will discard old results that are returned while a user is typing. - // For example if a user types 'Canucks', often the search results - // for 'C' are returned after 'Canucks' because it takes the backend - // much longer to compute. - if thisSearchIdx > 0, thisSearchIdx <= self?.receivedIdx ?? 0 { - return - } - self?.items = isRefresh ? result.items : (self?.items ?? []) + result.items - self?.isLoading = false - self?.receivedIdx = thisSearchIdx - self?.cursor = result.cursor - stopNetworkActivityIndicator() - } - ) - .store(in: &subscriptions) - } - - func setLinkArchived(dataService: DataService, linkId: String, archived: Bool) { - isLoading = true - startNetworkActivityIndicator() - - // First remove the link from the internal list, - // then make a call to remove it. The isLoading block should - // prevent our local change from being overwritten, but we - // might need to cache a local list of archived links - if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { - items.remove(at: itemIndex) - } - - dataService.archiveLinkPublisher(itemID: linkId, archived: archived) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(error) = completion else { return } - self?.isLoading = false - stopNetworkActivityIndicator() - print(error) - NSNotification.operationFailed(message: archived ? "Failed to archive link" : "Failed to unarchive link") - }, - receiveValue: { [weak self] _ in - self?.isLoading = false - stopNetworkActivityIndicator() - Snackbar.show(message: archived ? "Link archived" : "Link moved to Inbox") - } - ) - .store(in: &subscriptions) - } - - func removeLink(dataService: DataService, linkId: String) { - isLoading = true - startNetworkActivityIndicator() - - if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { - items.remove(at: itemIndex) - } - - dataService.removeLinkPublisher(itemID: linkId) - .sink( - receiveCompletion: { [weak self] completion in - guard case .failure = completion else { return } - self?.isLoading = false - stopNetworkActivityIndicator() - Snackbar.show(message: "Failed to remove link") - }, - receiveValue: { [weak self] _ in - self?.isLoading = false - stopNetworkActivityIndicator() - Snackbar.show(message: "Link removed") - } - ) - .store(in: &subscriptions) - } - - func snoozeUntil(dataService: DataService, linkId: String, until: Date, successMessage: String?) { - isLoading = true - startNetworkActivityIndicator() - - if let itemIndex = items.firstIndex(where: { $0.id == linkId }) { - items.remove(at: itemIndex) - } - - dataService.createReminderPublisher( - reminderItemId: .link(id: linkId), - remindAt: until - ) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(error) = completion else { return } - self?.isLoading = false - stopNetworkActivityIndicator() - print(error) - NSNotification.operationFailed(message: "Failed to snooze") - }, - receiveValue: { [weak self] _ in - self?.isLoading = false - stopNetworkActivityIndicator() - if let message = successMessage { - Snackbar.show(message: message) - } - } - ) - .store(in: &subscriptions) - } -} - -struct HomeFeedView: View { - @EnvironmentObject var dataService: DataService - - @StateObject private var viewModel = HomeFeedViewModel() - @State private var selectedLinkItem: FeedItem? - @State private var searchQuery = "" - @State private var itemToRemove: FeedItem? - @State private var confirmationShown = false - @State private var snoozePresented = false - @State private var itemToSnooze: FeedItem? - - @ViewBuilder var conditionalInnerBody: some View { - #if os(iOS) - if #available(iOS 15.0, *) { - innerBody - .refreshable { - refresh() - } - .searchable( - text: $searchQuery, - placement: .sidebar - ) { - if searchQuery.isEmpty { - Text("Inbox").searchCompletion("in:inbox ") - Text("All").searchCompletion("in:all ") - Text("Archived").searchCompletion("in:archive ") - Text("Files").searchCompletion("type:file ") - } - } - .onChange(of: searchQuery) { _ in - // Maybe we should debounce this, but - // it feels like it works ok without - refresh() - } - .onSubmit(of: .search) { - refresh() - } - } else { - innerBody.toolbar { - ToolbarItem { - Button( - action: { refresh() }, - label: { Label("Refresh Feed", systemImage: "arrow.clockwise") } - ) - } - } - } - #elseif os(macOS) - innerBody.toolbar { - ToolbarItem { - Button( - action: { refresh() }, - label: { Label("Refresh Feed", systemImage: "arrow.clockwise") } - ) - } - } - #endif - } - - var innerBody: some View { - List { - Section { - ForEach(viewModel.items) { item in - let link = ZStack { - NavigationLink( - destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item)), - tag: item, - selection: $selectedLinkItem - ) { - EmptyView() - } - .opacity(0) - .buttonStyle(PlainButtonStyle()) - .onAppear { - viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService) - } - FeedCard(item: item) - }.contextMenu { - if !item.isArchived { - Button(action: { - withAnimation(.linear(duration: 0.4)) { - viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true) - if item == selectedLinkItem { - selectedLinkItem = nil - } - } - }, label: { Label("Archive", systemImage: "archivebox") }) - } else { - Button(action: { - withAnimation(.linear(duration: 0.4)) { - viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false) - } - }, label: { Label("Unarchive", systemImage: "tray.and.arrow.down.fill") }) - } - Button { - itemToSnooze = item - snoozePresented = true - } label: { - Label { Text("Snooze") } icon: { Image.moon } - } - } - #if os(iOS) - if #available(iOS 15.0, *) { - link - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - if !item.isArchived { - Button { - withAnimation(.linear(duration: 0.4)) { - viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true) - } - } label: { - Label("Archive", systemImage: "archivebox") - }.tint(.green) - } else { - Button { - withAnimation(.linear(duration: 0.4)) { - viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false) - } - } label: { - Label("Unarchive", systemImage: "tray.and.arrow.down.fill") - }.tint(.indigo) - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button( - role: .destructive, - action: { - itemToRemove = item - confirmationShown = true - }, - label: { - Image(systemName: "trash") - } - ) - }.alert("Are you sure?", isPresented: $confirmationShown) { - Button("Remove Link", role: .destructive) { - if let itemToRemove = itemToRemove { - withAnimation { - viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id) - } - } - self.itemToRemove = nil - } - Button("Cancel", role: .cancel) { self.itemToRemove = nil } - } -// .swipeActions(edge: .leading, allowsFullSwipe: true) { -// Button { -// itemToSnooze = item -// snoozePresented = true -// } label: { -// Label { Text("Snooze") } icon: { Image.moon } -// }.tint(.appYellow48) -// } - } else { - link - } - #elseif os(macOS) - link - #endif - } - } - - if viewModel.isLoading { - Section { - HStack(alignment: .center) { - Spacer() - Text("Loading...") - Spacer() - } - .frame(maxWidth: .infinity) - } - } - } - .listStyle(PlainListStyle()) - .navigationTitle("Home") - #if os(iOS) - .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in - // Don't refresh the list if the user is currently reading an article - if selectedLinkItem == nil { - refresh() - } - } - .onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushFeedItem"))) { notification in - if let feedItem = notification.userInfo?["feedItem"] as? FeedItem { - viewModel.pushFeedItem(item: feedItem) - self.selectedLinkItem = feedItem - } - } - .formSheet(isPresented: $snoozePresented) { - SnoozeView(snoozePresented: $snoozePresented, itemToSnooze: $itemToSnooze) { - viewModel.snoozeUntil( - dataService: dataService, - linkId: $0.feedItemId, - until: $0.snoozeUntilDate, - successMessage: $0.successMessage - ) - } - } - #endif - .onAppear { - if viewModel.items.isEmpty { - refresh() - } - } - } - - var body: some View { - #if os(iOS) - if UIDevice.isIPhone { - NavigationView { - conditionalInnerBody - .toolbar { - ToolbarItem { - NavigationLink( - destination: { ProfileView() }, - label: { - Image.profile - .resizable() - .frame(width: 26, height: 26) - .padding() - } - ) - } - } - } - .accentColor(.appGrayTextContrast) - } else { - conditionalInnerBody - } - #elseif os(macOS) - conditionalInnerBody - #endif - } - - private func refresh() { - viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) - } -} - -private func startNetworkActivityIndicator() { - #if os(iOS) - UIApplication.shared.isNetworkActivityIndicatorVisible = true - #endif -} - -private func stopNetworkActivityIndicator() { - #if os(iOS) - UIApplication.shared.isNetworkActivityIndicatorVisible = false - #endif -} diff --git a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift index 422a2c5e6..f8ca2f10d 100644 --- a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift @@ -9,7 +9,7 @@ public struct PrimaryContentView: View { if UIDevice.isIPad { regularView } else { - HomeFeedView() + HomeView() } #elseif os(macOS) regularView From 714e7bb18418f2bd218e49e6f28e2a0b86f7ad5e Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Mon, 28 Feb 2022 10:05:46 -0800 Subject: [PATCH 5/9] use home feed child components in ios views --- .../Views/Home/HomeFeedViewComponents.swift | 13 +-- .../App/Views/Home/HomeFeedViewIOS.swift | 83 ++++++------------- .../App/Views/Home/HomeFeedViewMac.swift | 1 - .../Sources/Utils/FeatureFlags.swift | 1 + 4 files changed, 35 insertions(+), 63 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewComponents.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewComponents.swift index 3468b9968..62b756050 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewComponents.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewComponents.swift @@ -1,6 +1,7 @@ import Models import Services import SwiftUI +import Utils import Views struct FeedItemContextMenuView: View { @@ -31,11 +32,13 @@ struct FeedItemContextMenuView: View { } }, label: { Label("Unarchive", systemImage: "tray.and.arrow.down.fill") }) } - Button { - itemToSnooze = item - snoozePresented = true - } label: { - Label { Text("Snooze") } icon: { Image.moon } + if FeatureFlag.enableSnooze { + Button { + itemToSnooze = item + snoozePresented = true + } label: { + Label { Text("Snooze") } icon: { Image.moon } + } } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index db7dee27b..69d3f436f 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -86,51 +86,25 @@ import Views @ObservedObject var viewModel: HomeFeedViewModel - let columns: [GridItem] = { - [GridItem(.adaptive(minimum: 300))] - }() - var body: some View { - ScrollView { - LazyVGrid(columns: columns, spacing: 20) { + List { + Section { ForEach(viewModel.items) { item in let link = ZStack { - NavigationLink( - destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item)), - tag: item, - selection: $selectedLinkItem - ) { - EmptyView() - } - .opacity(0) - .buttonStyle(PlainButtonStyle()) - .onAppear { - viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService) - } - FeedCard(item: item) + FeedCardNavigationLink( + item: item, + searchQuery: searchQuery, + selectedLinkItem: $selectedLinkItem, + viewModel: viewModel + ) }.contextMenu { - if !item.isArchived { - Button(action: { - withAnimation(.linear(duration: 0.4)) { - viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true) - if item == selectedLinkItem { - selectedLinkItem = nil - } - } - }, label: { Label("Archive", systemImage: "archivebox") }) - } else { - Button(action: { - withAnimation(.linear(duration: 0.4)) { - viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false) - } - }, label: { Label("Unarchive", systemImage: "tray.and.arrow.down.fill") }) - } - Button { - itemToSnooze = item - snoozePresented = true - } label: { - Label { Text("Snooze") } icon: { Image.moon } - } + FeedItemContextMenuView( + item: item, + selectedLinkItem: $selectedLinkItem, + snoozePresented: $snoozePresented, + itemToSnooze: $itemToSnooze, + viewModel: viewModel + ) } if #available(iOS 15.0, *) { link @@ -175,14 +149,16 @@ import Views } Button("Cancel", role: .cancel) { self.itemToRemove = nil } } - // .swipeActions(edge: .leading, allowsFullSwipe: true) { - // Button { - // itemToSnooze = item - // snoozePresented = true - // } label: { - // Label { Text("Snooze") } icon: { Image.moon } - // }.tint(.appYellow48) - // } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if FeatureFlag.enableSnooze { + Button { + itemToSnooze = item + snoozePresented = true + } label: { + Label { Text("Snooze") } icon: { Image.moon } + }.tint(.appYellow48) + } + } } else { link } @@ -190,14 +166,7 @@ import Views } if viewModel.isLoading { - Section { - HStack(alignment: .center) { - Spacer() - Text("Loading...") - Spacer() - } - .frame(maxWidth: .infinity) - } + LoadingSection() } } .listStyle(PlainListStyle()) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift index 3190812b3..56aa8dee5 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift @@ -65,5 +65,4 @@ import Views } } -// TODO: handle $snoozePresented == true #endif diff --git a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift index 3dfabc24c..43385fcc4 100644 --- a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift +++ b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift @@ -13,4 +13,5 @@ public enum FeatureFlag { public static let enableRemindersFromShareExtension = false public static let enablePushNotifications = false public static let enableShareButton = false + public static let enableSnooze = false } From 2040f8efe2c8a23e9e972fb831ca21b76d74a2cb Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Mon, 28 Feb 2022 10:21:44 -0800 Subject: [PATCH 6/9] move home feed components into separate files --- .../Components/FeedCardNavigationLink.swift | 30 ++++++++++++++ .../FeedItemContextMenuView.swift} | 39 ------------------- .../Sources/Views/LoadingSection.swift | 16 ++++++++ 3 files changed, 46 insertions(+), 39 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift rename apple/OmnivoreKit/Sources/App/Views/Home/{HomeFeedViewComponents.swift => Components/FeedItemContextMenuView.swift} (58%) create mode 100644 apple/OmnivoreKit/Sources/Views/LoadingSection.swift diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift new file mode 100644 index 000000000..7fd196068 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift @@ -0,0 +1,30 @@ +import Models +import Services +import SwiftUI +import Views + +struct FeedCardNavigationLink: View { + @EnvironmentObject var dataService: DataService + + let item: FeedItem + let searchQuery: String + + @Binding var selectedLinkItem: FeedItem? + + @ObservedObject var viewModel: HomeFeedViewModel + var body: some View { + NavigationLink( + destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item)), + tag: item, + selection: $selectedLinkItem + ) { + EmptyView() + } + .opacity(0) + .buttonStyle(PlainButtonStyle()) + .onAppear { + viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService) + } + FeedCard(item: item) + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewComponents.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedItemContextMenuView.swift similarity index 58% rename from apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewComponents.swift rename to apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedItemContextMenuView.swift index 62b756050..6441e5454 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewComponents.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedItemContextMenuView.swift @@ -42,42 +42,3 @@ struct FeedItemContextMenuView: View { } } } - -struct FeedCardNavigationLink: View { - @EnvironmentObject var dataService: DataService - - let item: FeedItem - let searchQuery: String - - @Binding var selectedLinkItem: FeedItem? - - @ObservedObject var viewModel: HomeFeedViewModel - var body: some View { - NavigationLink( - destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item)), - tag: item, - selection: $selectedLinkItem - ) { - EmptyView() - } - .opacity(0) - .buttonStyle(PlainButtonStyle()) - .onAppear { - viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService) - } - FeedCard(item: item) - } -} - -struct LoadingSection: View { - var body: some View { - Section { - HStack(alignment: .center) { - Spacer() - Text("Loading...") - Spacer() - } - .frame(maxWidth: .infinity) - } - } -} diff --git a/apple/OmnivoreKit/Sources/Views/LoadingSection.swift b/apple/OmnivoreKit/Sources/Views/LoadingSection.swift new file mode 100644 index 000000000..ab534ad54 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/LoadingSection.swift @@ -0,0 +1,16 @@ +import SwiftUI + +public struct LoadingSection: View { + public init() {} + + public var body: some View { + Section { + HStack(alignment: .center) { + Spacer() + Text("Loading...") + Spacer() + } + .frame(maxWidth: .infinity) + } + } +} From cba037336c9b3bddb4deb046d0eff9970e66379b Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Mon, 28 Feb 2022 11:54:52 -0800 Subject: [PATCH 7/9] don't initialize root view model in root view init --- .../Sources/App/RootViewModel.swift | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/RootViewModel.swift b/apple/OmnivoreKit/Sources/App/RootViewModel.swift index 6684d1a45..17cbc97ab 100644 --- a/apple/OmnivoreKit/Sources/App/RootViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/RootViewModel.swift @@ -22,13 +22,9 @@ public final class RootViewModel: ObservableObject { public var subscriptions = Set() - public init(pdfViewerProvider: ((URL, PDFViewerViewModel) -> AnyView)?) { + public init() { registerFonts() - if let pdfViewerProvider = pdfViewerProvider { - configurePDFProvider(pdfViewerProvider: pdfViewerProvider) - } - #if DEBUG if CommandLine.arguments.contains("--uitesting") { services.authenticator.logout() @@ -122,13 +118,15 @@ private struct SafariWebLinkPath: Identifiable { } public struct RootView: View { - @StateObject private var viewModel: RootViewModel + @StateObject private var viewModel = RootViewModel() public init( pdfViewerProvider: ((URL, PDFViewerViewModel) -> AnyView)?, intercomProvider: IntercomProvider? ) { - self._viewModel = StateObject(wrappedValue: RootViewModel(pdfViewerProvider: pdfViewerProvider)) + if let pdfViewerProvider = pdfViewerProvider { + viewModel.configurePDFProvider(pdfViewerProvider: pdfViewerProvider) + } if let intercomProvider = intercomProvider { DataService.showIntercomMessenger = intercomProvider.showIntercomMessenger @@ -137,8 +135,21 @@ public struct RootView: View { } } + public var body: some View { + InnerRootView(viewModel: viewModel) + .environmentObject(viewModel.services.authenticator) + .environmentObject(viewModel.services.dataService) + } +} + +struct InnerRootView: View { + @EnvironmentObject var dataService: DataService + @EnvironmentObject var authenticator: Authenticator + + @ObservedObject var viewModel: RootViewModel + @ViewBuilder private var innerBody: some View { - if viewModel.services.authenticator.isLoggedIn { + if authenticator.isLoggedIn { PrimaryContentView() .onAppear { viewModel.triggerPushNotificationRequestIfNeeded() @@ -170,7 +181,7 @@ public struct RootView: View { } } - public var body: some View { + var body: some View { Group { #if os(iOS) innerBody @@ -179,8 +190,6 @@ public struct RootView: View { .frame(minWidth: 400, idealWidth: 1200, minHeight: 400, idealHeight: 1200) #endif } - .environmentObject(viewModel.services.authenticator) - .environmentObject(viewModel.services.dataService) #if os(iOS) .onOpenURL { url in withoutAnimation { From 40153855df1a2ebc12966d301872e18b20383b34 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Mon, 28 Feb 2022 12:00:08 -0800 Subject: [PATCH 8/9] configure configurePDFProvider onAppear of RootView --- apple/OmnivoreKit/Sources/App/RootViewModel.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/RootViewModel.swift b/apple/OmnivoreKit/Sources/App/RootViewModel.swift index 17cbc97ab..f7071b702 100644 --- a/apple/OmnivoreKit/Sources/App/RootViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/RootViewModel.swift @@ -33,6 +33,8 @@ public final class RootViewModel: ObservableObject { } func configurePDFProvider(pdfViewerProvider: @escaping (URL, PDFViewerViewModel) -> AnyView) { + guard PDFProvider.pdfViewerProvider == nil else { return } + PDFProvider.pdfViewerProvider = { [weak self] url, feedItem in guard let self = self else { return AnyView(Text("")) } return pdfViewerProvider(url, PDFViewerViewModel(services: self.services, feedItem: feedItem)) @@ -118,15 +120,14 @@ private struct SafariWebLinkPath: Identifiable { } public struct RootView: View { + let pdfViewerProvider: ((URL, PDFViewerViewModel) -> AnyView)? @StateObject private var viewModel = RootViewModel() public init( pdfViewerProvider: ((URL, PDFViewerViewModel) -> AnyView)?, intercomProvider: IntercomProvider? ) { - if let pdfViewerProvider = pdfViewerProvider { - viewModel.configurePDFProvider(pdfViewerProvider: pdfViewerProvider) - } + self.pdfViewerProvider = pdfViewerProvider if let intercomProvider = intercomProvider { DataService.showIntercomMessenger = intercomProvider.showIntercomMessenger @@ -139,6 +140,11 @@ public struct RootView: View { InnerRootView(viewModel: viewModel) .environmentObject(viewModel.services.authenticator) .environmentObject(viewModel.services.dataService) + .onAppear { + if let pdfViewerProvider = pdfViewerProvider { + viewModel.configurePDFProvider(pdfViewerProvider: pdfViewerProvider) + } + } } } From c24264ff2727724649aa0b0f6bc02ffa17ceaa33 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Mon, 28 Feb 2022 12:07:35 -0800 Subject: [PATCH 9/9] Split root view and rootviewmodel into separate files --- .../RootView/RootView.swift} | 129 +---------------- .../App/Views/RootView/RootViewModel.swift | 131 ++++++++++++++++++ 2 files changed, 132 insertions(+), 128 deletions(-) rename apple/OmnivoreKit/Sources/App/{RootViewModel.swift => Views/RootView/RootView.swift} (51%) create mode 100644 apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift diff --git a/apple/OmnivoreKit/Sources/App/RootViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift similarity index 51% rename from apple/OmnivoreKit/Sources/App/RootViewModel.swift rename to apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift index f7071b702..0d4af5f7c 100644 --- a/apple/OmnivoreKit/Sources/App/RootViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift @@ -1,120 +1,9 @@ -import Combine -import Foundation -import Models import Services import SwiftUI import Utils import Views -#if os(iOS) - let isMacApp = false -#elseif os(macOS) - let isMacApp = true -#endif - -public final class RootViewModel: ObservableObject { - let services = Services() - - @Published public var showPushNotificationPrimer = false - @Published fileprivate var webLinkPath: SafariWebLinkPath? - @Published fileprivate var snackbarMessage: String? - @Published fileprivate var showSnackbar = false - - public var subscriptions = Set() - - public init() { - registerFonts() - - #if DEBUG - if CommandLine.arguments.contains("--uitesting") { - services.authenticator.logout() - } - #endif - } - - func configurePDFProvider(pdfViewerProvider: @escaping (URL, PDFViewerViewModel) -> AnyView) { - guard PDFProvider.pdfViewerProvider == nil else { return } - - PDFProvider.pdfViewerProvider = { [weak self] url, feedItem in - guard let self = self else { return AnyView(Text("")) } - return pdfViewerProvider(url, PDFViewerViewModel(services: self.services, feedItem: feedItem)) - } - } - - func webAppWrapperViewModel(webLinkPath: String) -> WebAppWrapperViewModel { - let baseURL = services.dataService.appEnvironment.webAppBaseURL - - let urlRequest = URLRequest.webRequest( - baseURL: services.dataService.appEnvironment.webAppBaseURL, - urlPath: webLinkPath, - queryParams: ["isAppEmbedView": "true", "highlightBarDisabled": isMacApp ? "false" : "true"] - ) - - return WebAppWrapperViewModel( - webViewURLRequest: urlRequest, - baseURL: baseURL, - rawAuthCookie: services.authenticator.omnivoreAuthCookieString - ) - } - - func onOpenURL(url: URL) { - guard let linkRequestID = DeepLink.make(from: url)?.linkRequestID else { return } - - if let username = services.dataService.currentViewer?.username { - let path = linkRequestPath(username: username, requestID: linkRequestID) - webLinkPath = SafariWebLinkPath(id: UUID(), path: path) - return - } - - services.dataService.viewerPublisher().sink( - receiveCompletion: { completion in - guard case let .failure(error) = completion else { return } - print(error) - }, - receiveValue: { [weak self] viewer in - let path = self?.linkRequestPath(username: viewer.username, requestID: linkRequestID) ?? "" - self?.webLinkPath = SafariWebLinkPath(id: UUID(), path: path) - } - ) - .store(in: &subscriptions) - } - - func triggerPushNotificationRequestIfNeeded() { - guard FeatureFlag.enablePushNotifications else { return } - - if UserDefaults.standard.bool(forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue) { - return - } - - #if os(iOS) - UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in - switch settings.authorizationStatus { - case .notDetermined: - DispatchQueue.main.async { - self?.showPushNotificationPrimer = true - } - case .authorized, .provisional, .ephemeral, .denied: - return - @unknown default: - return - } - } - #endif - } - - #if os(iOS) - func handlePushNotificationPrimerAcceptance() { - showPushNotificationPrimer = false - UNUserNotificationCenter.current().requestAuth() - } - #endif - - private func linkRequestPath(username: String, requestID: String) -> String { - "/app/\(username)/link-request/\(requestID)" - } -} - -private struct SafariWebLinkPath: Identifiable { +struct SafariWebLinkPath: Identifiable { let id: UUID let path: String } @@ -237,22 +126,6 @@ struct InnerRootView: View { #endif } -public struct IntercomProvider { - public init( - registerIntercomUser: @escaping (String) -> Void, - unregisterIntercomUser: @escaping () -> Void, - showIntercomMessenger: @escaping () -> Void - ) { - self.registerIntercomUser = registerIntercomUser - self.unregisterIntercomUser = unregisterIntercomUser - self.showIntercomMessenger = showIntercomMessenger - } - - public let registerIntercomUser: (String) -> Void - public let unregisterIntercomUser: () -> Void - public let showIntercomMessenger: () -> Void -} - #if os(iOS) // Allows us to present a sheet without animation // Used to configure full screen modal view coming from share extension read now button action diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift new file mode 100644 index 000000000..fa8762ec0 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift @@ -0,0 +1,131 @@ +import Combine +import Foundation +import Models +import Services +import SwiftUI +import Utils +import Views + +#if os(iOS) + let isMacApp = false +#elseif os(macOS) + let isMacApp = true +#endif + +public final class RootViewModel: ObservableObject { + let services = Services() + + @Published public var showPushNotificationPrimer = false + @Published var webLinkPath: SafariWebLinkPath? + @Published var snackbarMessage: String? + @Published var showSnackbar = false + + public var subscriptions = Set() + + public init() { + registerFonts() + + #if DEBUG + if CommandLine.arguments.contains("--uitesting") { + services.authenticator.logout() + } + #endif + } + + func configurePDFProvider(pdfViewerProvider: @escaping (URL, PDFViewerViewModel) -> AnyView) { + guard PDFProvider.pdfViewerProvider == nil else { return } + + PDFProvider.pdfViewerProvider = { [weak self] url, feedItem in + guard let self = self else { return AnyView(Text("")) } + return pdfViewerProvider(url, PDFViewerViewModel(services: self.services, feedItem: feedItem)) + } + } + + func webAppWrapperViewModel(webLinkPath: String) -> WebAppWrapperViewModel { + let baseURL = services.dataService.appEnvironment.webAppBaseURL + + let urlRequest = URLRequest.webRequest( + baseURL: services.dataService.appEnvironment.webAppBaseURL, + urlPath: webLinkPath, + queryParams: ["isAppEmbedView": "true", "highlightBarDisabled": isMacApp ? "false" : "true"] + ) + + return WebAppWrapperViewModel( + webViewURLRequest: urlRequest, + baseURL: baseURL, + rawAuthCookie: services.authenticator.omnivoreAuthCookieString + ) + } + + func onOpenURL(url: URL) { + guard let linkRequestID = DeepLink.make(from: url)?.linkRequestID else { return } + + if let username = services.dataService.currentViewer?.username { + let path = linkRequestPath(username: username, requestID: linkRequestID) + webLinkPath = SafariWebLinkPath(id: UUID(), path: path) + return + } + + services.dataService.viewerPublisher().sink( + receiveCompletion: { completion in + guard case let .failure(error) = completion else { return } + print(error) + }, + receiveValue: { [weak self] viewer in + let path = self?.linkRequestPath(username: viewer.username, requestID: linkRequestID) ?? "" + self?.webLinkPath = SafariWebLinkPath(id: UUID(), path: path) + } + ) + .store(in: &subscriptions) + } + + func triggerPushNotificationRequestIfNeeded() { + guard FeatureFlag.enablePushNotifications else { return } + + if UserDefaults.standard.bool(forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue) { + return + } + + #if os(iOS) + UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in + switch settings.authorizationStatus { + case .notDetermined: + DispatchQueue.main.async { + self?.showPushNotificationPrimer = true + } + case .authorized, .provisional, .ephemeral, .denied: + return + @unknown default: + return + } + } + #endif + } + + #if os(iOS) + func handlePushNotificationPrimerAcceptance() { + showPushNotificationPrimer = false + UNUserNotificationCenter.current().requestAuth() + } + #endif + + private func linkRequestPath(username: String, requestID: String) -> String { + "/app/\(username)/link-request/\(requestID)" + } +} + +public struct IntercomProvider { + public init( + registerIntercomUser: @escaping (String) -> Void, + unregisterIntercomUser: @escaping () -> Void, + showIntercomMessenger: @escaping () -> Void + ) { + self.registerIntercomUser = registerIntercomUser + self.unregisterIntercomUser = unregisterIntercomUser + self.showIntercomMessenger = showIntercomMessenger + } + + public let registerIntercomUser: (String) -> Void + public let unregisterIntercomUser: () -> Void + public let showIntercomMessenger: () -> Void +}