move home feed view into binders
This commit is contained in:
@ -1,14 +1,15 @@
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
public enum PrimaryContentCategory: Identifiable, Hashable, Equatable {
|
||||
enum PrimaryContentCategory: Identifiable, Hashable, Equatable {
|
||||
case feed(viewModel: HomeFeedViewModel)
|
||||
case profile(viewModel: ProfileContainerViewModel)
|
||||
|
||||
public static func == (lhs: PrimaryContentCategory, rhs: PrimaryContentCategory) -> Bool {
|
||||
static func == (lhs: PrimaryContentCategory, rhs: PrimaryContentCategory) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
public var id: String {
|
||||
var id: String {
|
||||
title
|
||||
}
|
||||
|
||||
@ -39,11 +40,11 @@ public enum PrimaryContentCategory: Identifiable, Hashable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public var listLabel: some View {
|
||||
var listLabel: some View {
|
||||
Label { Text(title) } icon: { image.renderingMode(.template) }
|
||||
}
|
||||
|
||||
@ViewBuilder public var destinationView: some View {
|
||||
@ViewBuilder var destinationView: some View {
|
||||
switch self {
|
||||
case let .feed(viewModel: viewModel):
|
||||
HomeFeedView(viewModel: viewModel)
|
||||
@ -52,7 +53,7 @@ public enum PrimaryContentCategory: Identifiable, Hashable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
@ -1,197 +0,0 @@
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
extension HomeFeedViewModel {
|
||||
static func make(services: Services) -> HomeFeedViewModel {
|
||||
let viewModel = HomeFeedViewModel { feedItem in
|
||||
LinkItemDetailViewModel.make(feedItem: feedItem, services: services)
|
||||
}
|
||||
|
||||
if UIDevice.isIPhone {
|
||||
viewModel.profileContainerViewModel = ProfileContainerViewModel.make(services: services)
|
||||
}
|
||||
|
||||
viewModel.bind(services: services)
|
||||
viewModel.loadItems(dataService: services.dataService, searchQuery: nil, isRefresh: false)
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func bind(services: Services) {
|
||||
performActionSubject.sink { [weak self] action in
|
||||
switch action {
|
||||
case let .refreshItems(query: query):
|
||||
self?.loadItems(dataService: services.dataService, searchQuery: query, isRefresh: true)
|
||||
case let .loadItems(query):
|
||||
self?.loadItems(dataService: services.dataService, searchQuery: query, isRefresh: false)
|
||||
case let .archive(linkId):
|
||||
self?.setLinkArchived(dataService: services.dataService, linkId: linkId, archived: true)
|
||||
case let .unarchive(linkId):
|
||||
self?.setLinkArchived(dataService: services.dataService, linkId: linkId, archived: false)
|
||||
case let .remove(linkId):
|
||||
self?.removeLink(dataService: services.dataService, linkId: linkId)
|
||||
case let .snooze(linkId, until, successMessage):
|
||||
self?.snoozeUntil(
|
||||
dataService: services.dataService,
|
||||
linkId: linkId,
|
||||
until: until,
|
||||
successMessage: successMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private 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)
|
||||
}
|
||||
|
||||
private 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()
|
||||
NSNotification.operationSuccess(message: archived ? "Link archived" : "Link moved to Inbox")
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private 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 let .failure(error) = completion else { return }
|
||||
self?.isLoading = false
|
||||
stopNetworkActivityIndicator()
|
||||
print(error)
|
||||
NSNotification.operationFailed(message: "Failed to remove link")
|
||||
},
|
||||
receiveValue: { [weak self] _ in
|
||||
self?.isLoading = false
|
||||
stopNetworkActivityIndicator()
|
||||
NSNotification.operationSuccess(message: "Link removed")
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private 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 {
|
||||
NSNotification.operationSuccess(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
|
||||
}
|
||||
@ -1,24 +1,219 @@
|
||||
import Combine
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
public final class HomeFeedViewModel: ObservableObject {
|
||||
extension HomeFeedViewModel {
|
||||
static func make(services: Services) -> HomeFeedViewModel {
|
||||
let viewModel = HomeFeedViewModel { feedItem in
|
||||
LinkItemDetailViewModel.make(feedItem: feedItem, services: services)
|
||||
}
|
||||
|
||||
if UIDevice.isIPhone {
|
||||
viewModel.profileContainerViewModel = ProfileContainerViewModel.make(services: services)
|
||||
}
|
||||
|
||||
viewModel.bind(services: services)
|
||||
viewModel.loadItems(dataService: services.dataService, searchQuery: nil, isRefresh: false)
|
||||
return viewModel
|
||||
}
|
||||
|
||||
func bind(services: Services) {
|
||||
performActionSubject.sink { [weak self] action in
|
||||
switch action {
|
||||
case let .refreshItems(query: query):
|
||||
self?.loadItems(dataService: services.dataService, searchQuery: query, isRefresh: true)
|
||||
case let .loadItems(query):
|
||||
self?.loadItems(dataService: services.dataService, searchQuery: query, isRefresh: false)
|
||||
case let .archive(linkId):
|
||||
self?.setLinkArchived(dataService: services.dataService, linkId: linkId, archived: true)
|
||||
case let .unarchive(linkId):
|
||||
self?.setLinkArchived(dataService: services.dataService, linkId: linkId, archived: false)
|
||||
case let .remove(linkId):
|
||||
self?.removeLink(dataService: services.dataService, linkId: linkId)
|
||||
case let .snooze(linkId, until, successMessage):
|
||||
self?.snoozeUntil(
|
||||
dataService: services.dataService,
|
||||
linkId: linkId,
|
||||
until: until,
|
||||
successMessage: successMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private 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)
|
||||
}
|
||||
|
||||
private 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()
|
||||
NSNotification.operationSuccess(message: archived ? "Link archived" : "Link moved to Inbox")
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private 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 let .failure(error) = completion else { return }
|
||||
self?.isLoading = false
|
||||
stopNetworkActivityIndicator()
|
||||
print(error)
|
||||
NSNotification.operationFailed(message: "Failed to remove link")
|
||||
},
|
||||
receiveValue: { [weak self] _ in
|
||||
self?.isLoading = false
|
||||
stopNetworkActivityIndicator()
|
||||
NSNotification.operationSuccess(message: "Link removed")
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private 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 {
|
||||
NSNotification.operationSuccess(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
|
||||
}
|
||||
|
||||
// TODO: remove this view model
|
||||
final class HomeFeedViewModel: ObservableObject {
|
||||
let detailViewModelCreator: (FeedItem) -> LinkItemDetailViewModel
|
||||
var currentDetailViewModel: LinkItemDetailViewModel?
|
||||
public var profileContainerViewModel: ProfileContainerViewModel?
|
||||
var profileContainerViewModel: ProfileContainerViewModel?
|
||||
|
||||
@Published public var items = [FeedItem]()
|
||||
@Published public var isLoading = false
|
||||
@Published public var showPushNotificationPrimer = false
|
||||
public var cursor: String?
|
||||
@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
|
||||
public var searchIdx = 0
|
||||
public var receivedIdx = 0
|
||||
var searchIdx = 0
|
||||
var receivedIdx = 0
|
||||
|
||||
public enum Action {
|
||||
enum Action {
|
||||
case refreshItems(query: String)
|
||||
case loadItems(query: String)
|
||||
case archive(linkId: String)
|
||||
@ -27,10 +222,10 @@ public final class HomeFeedViewModel: ObservableObject {
|
||||
case snooze(linkId: String, until: Date, successMessage: String?)
|
||||
}
|
||||
|
||||
public var subscriptions = Set<AnyCancellable>()
|
||||
public let performActionSubject = PassthroughSubject<Action, Never>()
|
||||
var subscriptions = Set<AnyCancellable>()
|
||||
let performActionSubject = PassthroughSubject<Action, Never>()
|
||||
|
||||
public init(detailViewModelCreator: @escaping (FeedItem) -> LinkItemDetailViewModel) {
|
||||
init(detailViewModelCreator: @escaping (FeedItem) -> LinkItemDetailViewModel) {
|
||||
self.detailViewModelCreator = detailViewModelCreator
|
||||
}
|
||||
|
||||
@ -50,7 +245,7 @@ public final class HomeFeedViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
public struct HomeFeedView: View {
|
||||
struct HomeFeedView: View {
|
||||
@ObservedObject private var viewModel: HomeFeedViewModel
|
||||
@State private var selectedLinkItem: FeedItem?
|
||||
@State private var searchQuery = ""
|
||||
@ -59,7 +254,7 @@ public struct HomeFeedView: View {
|
||||
@State private var snoozePresented = false
|
||||
@State private var itemToSnooze: FeedItem?
|
||||
|
||||
public init(viewModel: HomeFeedViewModel) {
|
||||
init(viewModel: HomeFeedViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
@ -255,7 +450,7 @@ public struct HomeFeedView: View {
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
if UIDevice.isIPhone, let profileContainerViewModel = viewModel.profileContainerViewModel {
|
||||
NavigationView {
|
||||
@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Image {
|
||||
public extension Image {
|
||||
static var smallOmnivoreLogo: Image { Image("_smallOmnivoreLogo", bundle: .module) }
|
||||
static var omnivoreTitleLogo: Image { Image("_omnivoreTitleLogo", bundle: .module) }
|
||||
static var readingIllustration: Image { Image("_readingIllustration", bundle: .module) }
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import Models
|
||||
import SwiftUI
|
||||
|
||||
struct FeedCard: View {
|
||||
public struct FeedCard: View {
|
||||
let item: FeedItem
|
||||
|
||||
var body: some View {
|
||||
public init(item: FeedItem) {
|
||||
self.item = item
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(item.title)
|
||||
|
||||
@ -1,12 +1,22 @@
|
||||
import Models
|
||||
import SwiftUI
|
||||
|
||||
struct SnoozeView: View {
|
||||
public struct SnoozeView: View {
|
||||
@Binding var snoozePresented: Bool
|
||||
@Binding var itemToSnooze: FeedItem?
|
||||
let snoozeAction: (SnoozeActionParams) -> Void
|
||||
|
||||
var body: some View {
|
||||
public init(
|
||||
snoozePresented: Binding<Bool>,
|
||||
itemToSnooze: Binding<FeedItem?>,
|
||||
snoozeAction: @escaping (SnoozeActionParams) -> Void
|
||||
) {
|
||||
self._snoozePresented = snoozePresented
|
||||
self._itemToSnooze = itemToSnooze
|
||||
self.snoozeAction = snoozeAction
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
@ -42,10 +52,10 @@ struct SnoozeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct SnoozeActionParams {
|
||||
let feedItemId: String
|
||||
let snoozeUntilDate: Date
|
||||
let successMessage: String?
|
||||
public struct SnoozeActionParams {
|
||||
public let feedItemId: String
|
||||
public let snoozeUntilDate: Date
|
||||
public let successMessage: String?
|
||||
}
|
||||
|
||||
private struct SnoozeIconButtonView: View {
|
||||
|
||||
Reference in New Issue
Block a user