destructure home feed components into platform specific parent components
This commit is contained in:
@ -38,7 +38,7 @@ enum PrimaryContentCategory: Identifiable, Hashable, Equatable {
|
||||
@ViewBuilder var destinationView: some View {
|
||||
switch self {
|
||||
case .feed:
|
||||
HomeFeedView()
|
||||
HomeView()
|
||||
case .profile:
|
||||
ProfileView()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
239
apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift
Normal file
239
apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift
Normal file
@ -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
|
||||
@ -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
|
||||
188
apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift
Normal file
188
apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift
Normal file
@ -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<AnyCancellable>()
|
||||
|
||||
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
|
||||
}
|
||||
17
apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift
Normal file
17
apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
@ -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<AnyCancellable>()
|
||||
|
||||
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
|
||||
}
|
||||
@ -9,7 +9,7 @@ public struct PrimaryContentView: View {
|
||||
if UIDevice.isIPad {
|
||||
regularView
|
||||
} else {
|
||||
HomeFeedView()
|
||||
HomeView()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
regularView
|
||||
|
||||
Reference in New Issue
Block a user