Merge pull request #155 from omnivore-app/feature/ipad-layout

iPad Layout - Drop to two panes
This commit is contained in:
Satindar Dhillon 2022-02-28 12:11:08 -08:00 committed by GitHub
commit 76c56dcdad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 852 additions and 691 deletions

View file

@ -38,7 +38,7 @@ enum PrimaryContentCategory: Identifiable, Hashable, Equatable {
@ViewBuilder var destinationView: some View {
switch self {
case .feed:
HomeFeedView()
HomeView()
case .profile:
ProfileView()
}

View file

@ -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<AnyCancellable>()
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

View file

@ -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)
}
}

View file

@ -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 }
}
}
}
}

View file

@ -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

View file

@ -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

View 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
}

View 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
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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

View file

@ -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<AnyCancellable>()
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
}

View file

@ -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
}

View file

@ -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)
}
}
}