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/RootViewModel.swift b/apple/OmnivoreKit/Sources/App/RootViewModel.swift deleted file mode 100644 index 6684d1a45..000000000 --- a/apple/OmnivoreKit/Sources/App/RootViewModel.swift +++ /dev/null @@ -1,253 +0,0 @@ -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(pdfViewerProvider: ((URL, PDFViewerViewModel) -> AnyView)?) { - registerFonts() - - if let pdfViewerProvider = pdfViewerProvider { - configurePDFProvider(pdfViewerProvider: pdfViewerProvider) - } - - #if DEBUG - if CommandLine.arguments.contains("--uitesting") { - services.authenticator.logout() - } - #endif - } - - func configurePDFProvider(pdfViewerProvider: @escaping (URL, PDFViewerViewModel) -> AnyView) { - 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 { - let id: UUID - let path: String -} - -public struct RootView: View { - @StateObject private var viewModel: RootViewModel - - public init( - pdfViewerProvider: ((URL, PDFViewerViewModel) -> AnyView)?, - intercomProvider: IntercomProvider? - ) { - self._viewModel = StateObject(wrappedValue: RootViewModel(pdfViewerProvider: pdfViewerProvider)) - - if let intercomProvider = intercomProvider { - DataService.showIntercomMessenger = intercomProvider.showIntercomMessenger - DataService.registerIntercomUser = intercomProvider.registerIntercomUser - Authenticator.unregisterIntercomUser = intercomProvider.unregisterIntercomUser - } - } - - @ViewBuilder private var innerBody: some View { - if viewModel.services.authenticator.isLoggedIn { - PrimaryContentView() - .onAppear { - viewModel.triggerPushNotificationRequestIfNeeded() - } - #if os(iOS) - .fullScreenCover(item: $viewModel.webLinkPath, content: { safariLinkPath in - NavigationView { - FullScreenWebAppView( - viewModel: viewModel.webAppWrapperViewModel(webLinkPath: safariLinkPath.path), - handleClose: { viewModel.webLinkPath = nil } - ) - } - }) - #endif - .snackBar( - isShowing: $viewModel.showSnackbar, - text: Text(viewModel.snackbarMessage ?? "") - ) - #if os(iOS) - .customAlert(isPresented: $viewModel.showPushNotificationPrimer) { - pushNotificationPrimerView - } - #endif - - } else { - WelcomeView() - .accessibilityElement() - .accessibilityIdentifier("welcomeView") - } - } - - public var body: some View { - Group { - #if os(iOS) - innerBody - #elseif os(macOS) - innerBody - .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 { - if viewModel.webLinkPath != nil { - viewModel.webLinkPath = nil - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { - viewModel.onOpenURL(url: url) - } - } else { - viewModel.onOpenURL(url: url) - } - } - } - .onReceive(NSNotification.operationSuccessPublisher) { notification in - if let message = notification.userInfo?["message"] as? String { - viewModel.showSnackbar = true - viewModel.snackbarMessage = message - } - } - .onReceive(NSNotification.operationFailedPublisher) { notification in - if let message = notification.userInfo?["message"] as? String { - viewModel.showSnackbar = true - viewModel.snackbarMessage = message - } - } - #endif - } - - #if os(iOS) - private var pushNotificationPrimerView: PushNotificationPrimer { - PushNotificationPrimer( - acceptAction: { viewModel.handlePushNotificationPrimerAcceptance() }, - denyAction: { - UserDefaults.standard.set(true, forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue) - viewModel.showPushNotificationPrimer = false - } - ) - } - #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 - private extension View { - func withoutAnimation(_ completion: @escaping () -> Void) { - UIView.setAnimationsEnabled(false) - completion() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { - UIView.setAnimationsEnabled(true) - } - } - } -#endif 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/Components/FeedItemContextMenuView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedItemContextMenuView.swift new file mode 100644 index 000000000..6441e5454 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedItemContextMenuView.swift @@ -0,0 +1,44 @@ +import Models +import Services +import SwiftUI +import Utils +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") }) + } + 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 new file mode 100644 index 000000000..69d3f436f --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -0,0 +1,208 @@ +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 + + var body: some View { + List { + Section { + ForEach(viewModel.items) { item in + let link = ZStack { + FeedCardNavigationLink( + item: item, + searchQuery: searchQuery, + selectedLinkItem: $selectedLinkItem, + viewModel: viewModel + ) + }.contextMenu { + FeedItemContextMenuView( + item: item, + selectedLinkItem: $selectedLinkItem, + snoozePresented: $snoozePresented, + itemToSnooze: $itemToSnooze, + viewModel: viewModel + ) + } + 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) { + if FeatureFlag.enableSnooze { + Button { + itemToSnooze = item + snoozePresented = true + } label: { + Label { Text("Snooze") } icon: { Image.moon } + }.tint(.appYellow48) + } + } + } else { + link + } + } + } + + if viewModel.isLoading { + LoadingSection() + } + } + .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..56aa8dee5 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift @@ -0,0 +1,68 @@ +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) + } + } + } + } + +#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 422e770cb..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 @@ -28,15 +28,13 @@ public struct PrimaryContentView: View { PrimaryContentSidebar(categories: categories) .navigationTitle("Categories") - // Initial Content of second column - if let destinationView = categories.first?.destinationView { - destinationView - } else { - Text("Select a Category") - } + // Second column is the Primary Nav Stack + PrimaryContentCategory.feed.destinationView - // Initial content of detail view - Text("No Selection") + // Add a third column for macOS only + #if os(macOS) + Text("Select a link from the feed") + #endif } .accentColor(.appGrayTextContrast) } diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift new file mode 100644 index 000000000..0d4af5f7c --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift @@ -0,0 +1,141 @@ +import Services +import SwiftUI +import Utils +import Views + +struct SafariWebLinkPath: Identifiable { + let id: UUID + let path: String +} + +public struct RootView: View { + let pdfViewerProvider: ((URL, PDFViewerViewModel) -> AnyView)? + @StateObject private var viewModel = RootViewModel() + + public init( + pdfViewerProvider: ((URL, PDFViewerViewModel) -> AnyView)?, + intercomProvider: IntercomProvider? + ) { + self.pdfViewerProvider = pdfViewerProvider + + if let intercomProvider = intercomProvider { + DataService.showIntercomMessenger = intercomProvider.showIntercomMessenger + DataService.registerIntercomUser = intercomProvider.registerIntercomUser + Authenticator.unregisterIntercomUser = intercomProvider.unregisterIntercomUser + } + } + + public var body: some View { + InnerRootView(viewModel: viewModel) + .environmentObject(viewModel.services.authenticator) + .environmentObject(viewModel.services.dataService) + .onAppear { + if let pdfViewerProvider = pdfViewerProvider { + viewModel.configurePDFProvider(pdfViewerProvider: pdfViewerProvider) + } + } + } +} + +struct InnerRootView: View { + @EnvironmentObject var dataService: DataService + @EnvironmentObject var authenticator: Authenticator + + @ObservedObject var viewModel: RootViewModel + + @ViewBuilder private var innerBody: some View { + if authenticator.isLoggedIn { + PrimaryContentView() + .onAppear { + viewModel.triggerPushNotificationRequestIfNeeded() + } + #if os(iOS) + .fullScreenCover(item: $viewModel.webLinkPath, content: { safariLinkPath in + NavigationView { + FullScreenWebAppView( + viewModel: viewModel.webAppWrapperViewModel(webLinkPath: safariLinkPath.path), + handleClose: { viewModel.webLinkPath = nil } + ) + } + }) + #endif + .snackBar( + isShowing: $viewModel.showSnackbar, + text: Text(viewModel.snackbarMessage ?? "") + ) + #if os(iOS) + .customAlert(isPresented: $viewModel.showPushNotificationPrimer) { + pushNotificationPrimerView + } + #endif + + } else { + WelcomeView() + .accessibilityElement() + .accessibilityIdentifier("welcomeView") + } + } + + var body: some View { + Group { + #if os(iOS) + innerBody + #elseif os(macOS) + innerBody + .frame(minWidth: 400, idealWidth: 1200, minHeight: 400, idealHeight: 1200) + #endif + } + #if os(iOS) + .onOpenURL { url in + withoutAnimation { + if viewModel.webLinkPath != nil { + viewModel.webLinkPath = nil + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { + viewModel.onOpenURL(url: url) + } + } else { + viewModel.onOpenURL(url: url) + } + } + } + .onReceive(NSNotification.operationSuccessPublisher) { notification in + if let message = notification.userInfo?["message"] as? String { + viewModel.showSnackbar = true + viewModel.snackbarMessage = message + } + } + .onReceive(NSNotification.operationFailedPublisher) { notification in + if let message = notification.userInfo?["message"] as? String { + viewModel.showSnackbar = true + viewModel.snackbarMessage = message + } + } + #endif + } + + #if os(iOS) + private var pushNotificationPrimerView: PushNotificationPrimer { + PushNotificationPrimer( + acceptAction: { viewModel.handlePushNotificationPrimerAcceptance() }, + denyAction: { + UserDefaults.standard.set(true, forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue) + viewModel.showPushNotificationPrimer = false + } + ) + } + #endif +} + +#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 + private extension View { + func withoutAnimation(_ completion: @escaping () -> Void) { + UIView.setAnimationsEnabled(false) + completion() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { + UIView.setAnimationsEnabled(true) + } + } + } +#endif 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 +} 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 } 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) + } + } +}