From 00fcd9946697fcb3518f4e367ec06d69b4072351 Mon Sep 17 00:00:00 2001 From: Satindar Dhillon Date: Mon, 28 Feb 2022 08:53:58 -0800 Subject: [PATCH] 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