Merge pull request #397 from omnivore-app/refactor/home-feed-state-vars

Refactor - HomeFeed state
This commit is contained in:
Satindar Dhillon 2022-04-08 10:09:13 -07:00 committed by GitHub
commit fb24d6d9ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 475 additions and 491 deletions

View file

@ -7,9 +7,6 @@ struct FeedCardNavigationLink: View {
@EnvironmentObject var dataService: DataService
let item: FeedItem
let searchQuery: String
@Binding var selectedLinkItem: FeedItem?
@ObservedObject var viewModel: HomeFeedViewModel
@ -18,14 +15,14 @@ struct FeedCardNavigationLink: View {
NavigationLink(
destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item, homeFeedViewModel: viewModel)),
tag: item,
selection: $selectedLinkItem
selection: $viewModel.selectedLinkItem
) {
EmptyView()
}
.opacity(0)
.buttonStyle(PlainButtonStyle())
.onAppear {
viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService)
viewModel.itemAppeared(item: item, dataService: dataService)
}
FeedCard(item: item)
}
@ -38,10 +35,8 @@ struct GridCardNavigationLink: View {
@State private var scale = 1.0
let item: FeedItem
let searchQuery: String
let actionHandler: (GridCardAction) -> Void
@Binding var selectedLinkItem: FeedItem?
@Binding var isContextMenuOpen: Bool
@ObservedObject var viewModel: HomeFeedViewModel
@ -51,7 +46,7 @@ struct GridCardNavigationLink: View {
NavigationLink(
destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item, homeFeedViewModel: viewModel)),
tag: item,
selection: $selectedLinkItem
selection: $viewModel.selectedLinkItem
) {
EmptyView()
}
@ -60,12 +55,12 @@ struct GridCardNavigationLink: View {
scale = 0.95
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(150)) {
scale = 1.0
selectedLinkItem = item
viewModel.selectedLinkItem = item
}
}
})
.onAppear {
viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService)
viewModel.itemAppeared(item: item, dataService: dataService)
}
}
.aspectRatio(1.8, contentMode: .fill)

View file

@ -10,10 +10,6 @@ import Views
struct HomeFeedContainerView: View {
@EnvironmentObject var dataService: DataService
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = UIDevice.isIPhone
@State private var searchQuery = ""
@State private var snoozePresented = false
@State private var itemToSnooze: FeedItem?
@State private var selectedLinkItem: FeedItem?
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
@ -21,33 +17,29 @@ import Views
if #available(iOS 15.0, *) {
HomeFeedView(
prefersListLayout: $prefersListLayout,
searchQuery: $searchQuery,
selectedLinkItem: $selectedLinkItem,
snoozePresented: $snoozePresented,
itemToSnooze: $itemToSnooze,
viewModel: viewModel
)
.refreshable {
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.searchable(
text: $searchQuery,
text: $viewModel.searchQuery,
placement: .sidebar
) {
if searchQuery.isEmpty {
if viewModel.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
.onChange(of: viewModel.searchQuery) { _ in
// Maybe we should debounce this, but
// it feels like it works ok without
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.onSubmit(of: .search) {
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(item: item) { labels in
@ -57,10 +49,6 @@ import Views
} else {
HomeFeedView(
prefersListLayout: $prefersListLayout,
searchQuery: $searchQuery,
selectedLinkItem: $selectedLinkItem,
snoozePresented: $snoozePresented,
itemToSnooze: $itemToSnooze,
viewModel: viewModel
)
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
@ -74,7 +62,7 @@ import Views
Button(action: {}, label: { ProgressView() })
} else {
Button(
action: { viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) },
action: { viewModel.loadItems(dataService: dataService, isRefresh: true) },
label: { Label("Refresh Feed", systemImage: "arrow.clockwise") }
)
}
@ -85,18 +73,18 @@ import Views
.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 {
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
if viewModel.selectedLinkItem == nil {
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
}
.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
viewModel.selectedLinkItem = feedItem
}
}
.formSheet(isPresented: $snoozePresented) {
SnoozeView(snoozePresented: $snoozePresented, itemToSnooze: $itemToSnooze) {
.formSheet(isPresented: $viewModel.snoozePresented) {
SnoozeView(snoozePresented: $viewModel.snoozePresented, itemToSnooze: $viewModel.itemToSnooze) {
viewModel.snoozeUntil(
dataService: dataService,
linkId: $0.feedItemId,
@ -107,10 +95,10 @@ import Views
}
.onAppear {
if viewModel.items.isEmpty {
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
}
.onChange(of: selectedLinkItem) { _ in
.onChange(of: viewModel.selectedLinkItem) { _ in
viewModel.commitProgressUpdates()
}
}
@ -120,63 +108,46 @@ import Views
@EnvironmentObject var dataService: DataService
@Binding var prefersListLayout: Bool
@Binding var searchQuery: String
@Binding var selectedLinkItem: FeedItem?
@Binding var snoozePresented: Bool
@Binding var itemToSnooze: FeedItem?
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
if prefersListLayout {
HomeFeedListView(
prefersListLayout: $prefersListLayout,
searchQuery: $searchQuery,
selectedLinkItem: $selectedLinkItem,
snoozePresented: $snoozePresented,
itemToSnooze: $itemToSnooze,
viewModel: viewModel
)
HomeFeedListView(prefersListLayout: $prefersListLayout, viewModel: viewModel)
} else {
HomeFeedGridView(
searchQuery: $searchQuery,
selectedLinkItem: $selectedLinkItem,
snoozePresented: $snoozePresented,
itemToSnooze: $itemToSnooze,
viewModel: viewModel
)
.toolbar {
ToolbarItem {
if #available(iOS 15.0, *) {
Button("", action: {})
.disabled(true)
.overlay {
if viewModel.isLoading {
ProgressView()
HomeFeedGridView(viewModel: viewModel)
.toolbar {
ToolbarItem {
if #available(iOS 15.0, *) {
Button("", action: {})
.disabled(true)
.overlay {
if viewModel.isLoading {
ProgressView()
}
}
}
} else {
if viewModel.isLoading {
Button(action: {}, label: { ProgressView() })
} else {
if viewModel.isLoading {
Button(action: {}, label: { ProgressView() })
} else {
Button(
action: { viewModel.loadItems(dataService: dataService, isRefresh: true) },
label: { Label("Refresh Feed", systemImage: "arrow.clockwise") }
)
}
}
}
ToolbarItem {
if UIDevice.isIPad {
Button(
action: { viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) },
label: { Label("Refresh Feed", systemImage: "arrow.clockwise") }
action: { prefersListLayout.toggle() },
label: {
Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet")
}
)
}
}
}
ToolbarItem {
if UIDevice.isIPad {
Button(
action: { prefersListLayout.toggle() },
label: {
Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet")
}
)
}
}
}
}
}
}
@ -184,10 +155,6 @@ import Views
struct HomeFeedListView: View {
@EnvironmentObject var dataService: DataService
@Binding var prefersListLayout: Bool
@Binding var searchQuery: String
@Binding var selectedLinkItem: FeedItem?
@Binding var snoozePresented: Bool
@Binding var itemToSnooze: FeedItem?
@State private var itemToRemove: FeedItem?
@State private var confirmationShown = false
@ -200,8 +167,6 @@ import Views
ForEach(viewModel.items) { item in
let link = FeedCardNavigationLink(
item: item,
searchQuery: searchQuery,
selectedLinkItem: $selectedLinkItem,
viewModel: viewModel
)
.contextMenu {
@ -224,8 +189,8 @@ import Views
)
if FeatureFlag.enableSnooze {
Button {
itemToSnooze = item
snoozePresented = true
viewModel.itemToSnooze = item
viewModel.snoozePresented = true
} label: {
Label { Text("Snooze") } icon: { Image.moon }
}
@ -277,8 +242,8 @@ import Views
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if FeatureFlag.enableSnooze {
Button {
itemToSnooze = item
snoozePresented = true
viewModel.itemToSnooze = item
viewModel.snoozePresented = true
} label: {
Label { Text("Snooze") } icon: { Image.moon }
}.tint(.appYellow48)
@ -315,10 +280,6 @@ import Views
struct HomeFeedGridView: View {
@EnvironmentObject var dataService: DataService
@Binding var searchQuery: String
@Binding var selectedLinkItem: FeedItem?
@Binding var snoozePresented: Bool
@Binding var itemToSnooze: FeedItem?
@State private var itemToRemove: FeedItem?
@State private var confirmationShown = false
@ -344,9 +305,7 @@ import Views
ForEach(viewModel.items) { item in
let link = GridCardNavigationLink(
item: item,
searchQuery: searchQuery,
actionHandler: { contextMenuActionHandler(item: item, action: $0) },
selectedLinkItem: $selectedLinkItem,
isContextMenuOpen: $isContextMenuOpen,
viewModel: viewModel
)
@ -380,7 +339,7 @@ import Views
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { offset in
DispatchQueue.main.async {
if !viewModel.isLoading, offset > 240 {
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
}
}

View file

@ -9,12 +9,8 @@ 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
@ -33,8 +29,6 @@ import Views
ForEach(viewModel.items) { item in
FeedCardNavigationLink(
item: item,
searchQuery: searchQuery,
selectedLinkItem: $selectedLinkItem,
viewModel: viewModel
)
.contextMenu {
@ -55,8 +49,8 @@ import Views
)
if FeatureFlag.enableSnooze {
Button {
itemToSnooze = item
snoozePresented = true
viewModel.itemToSnooze = item
viewModel.snoozePresented = true
} label: {
Label { Text("Snooze") } icon: { Image.moon }
}
@ -83,29 +77,29 @@ import Views
.listStyle(PlainListStyle())
.navigationTitle("Home")
.searchable(
text: $searchQuery,
text: $viewModel.searchQuery,
placement: .toolbar
) {
if searchQuery.isEmpty {
if viewModel.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
.onChange(of: viewModel.searchQuery) { _ in
// Maybe we should debounce this, but
// it feels like it works ok without
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.onSubmit(of: .search) {
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
.toolbar {
ToolbarItem {
Button(
action: {
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
viewModel.loadItems(dataService: dataService, isRefresh: true)
},
label: { Label("Refresh Feed", systemImage: "arrow.clockwise") }
)
@ -120,7 +114,7 @@ import Views
}
.onAppear {
if viewModel.items.isEmpty {
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
}
}
@ -131,8 +125,6 @@ import Views
ForEach(viewModel.items) { item in
FeedCardNavigationLink(
item: item,
searchQuery: searchQuery,
selectedLinkItem: $selectedLinkItem,
viewModel: viewModel
)
}
@ -148,7 +140,7 @@ import Views
ToolbarItem {
Button(
action: {
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
viewModel.loadItems(dataService: dataService, isRefresh: true)
},
label: { Label("Refresh Feed", systemImage: "arrow.clockwise") }
)
@ -156,7 +148,7 @@ import Views
}
.onAppear {
if viewModel.items.isEmpty {
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
viewModel.loadItems(dataService: dataService, isRefresh: true)
}
}
}

View file

@ -15,6 +15,11 @@ final class HomeFeedViewModel: ObservableObject {
@Published var isLoading = false
@Published var showPushNotificationPrimer = false
@Published var itemUnderLabelEdit: FeedItem?
@Published var searchQuery = ""
@Published var snoozePresented = false
@Published var itemToSnooze: FeedItem?
@Published var selectedLinkItem: FeedItem?
var cursor: String?
var sendProgressUpdates = false
@ -27,14 +32,14 @@ final class HomeFeedViewModel: ObservableObject {
init() {}
func itemAppeared(item: FeedItem, searchQuery: String, dataService: DataService) {
func itemAppeared(item: FeedItem, 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)
loadItems(dataService: dataService, isRefresh: false)
}
}
@ -42,7 +47,7 @@ final class HomeFeedViewModel: ObservableObject {
items.insert(item, at: 0)
}
func loadItems(dataService: DataService, searchQuery: String?, isRefresh: Bool) {
func loadItems(dataService: DataService, isRefresh: Bool) {
// Clear offline highlights since we'll be populating new FeedItems with the correct highlights set
dataService.clearHighlights()
@ -63,7 +68,7 @@ final class HomeFeedViewModel: ObservableObject {
dataService.libraryItemsPublisher(
limit: 10,
sortDescending: true,
searchQuery: searchQuery,
searchQuery: searchQuery.isEmpty ? nil : searchQuery,
cursor: isRefresh ? nil : cursor
)
.sink(

View file

@ -64,26 +64,28 @@ struct ApplyLabelsView: View {
}
}
.navigationTitle("Assign Labels")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(
action: { presentationMode.wrappedValue.dismiss() },
label: { Text("Cancel").foregroundColor(.appGrayTextContrast) }
)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(
action: { presentationMode.wrappedValue.dismiss() },
label: { Text("Cancel").foregroundColor(.appGrayTextContrast) }
)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(
action: {
viewModel.saveItemLabelChanges(itemID: item.id, dataService: dataService) { labels in
commitLabelChanges(labels)
presentationMode.wrappedValue.dismiss()
}
},
label: { Text("Save").foregroundColor(.appGrayTextContrast) }
)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(
action: {
viewModel.saveItemLabelChanges(itemID: item.id, dataService: dataService) { labels in
commitLabelChanges(labels)
presentationMode.wrappedValue.dismiss()
}
},
label: { Text("Save").foregroundColor(.appGrayTextContrast) }
)
}
}
#endif
.sheet(isPresented: $viewModel.showCreateEmailModal) {
CreateLabelView(viewModel: viewModel)
}
@ -93,17 +95,20 @@ struct ApplyLabelsView: View {
NavigationView {
if viewModel.isLoading {
EmptyView()
} else {
if #available(iOS 15.0, *) {
#if os(iOS)
if #available(iOS 15.0, *) {
innerBody
.searchable(
text: $labelSearchFilter,
placement: .navigationBarDrawer(displayMode: .always)
)
} else {
innerBody
}
#else
innerBody
.searchable(
text: $labelSearchFilter,
placement: .navigationBarDrawer(displayMode: .always)
)
} else {
innerBody
}
#endif
}
}
.onAppear {

View file

@ -96,8 +96,10 @@ struct CreateLabelView: View {
NavigationView {
VStack(spacing: 16) {
TextField("Label Name", text: $newLabelName)
#if os(iOS)
.keyboardType(.alphabet)
.textFieldStyle(StandardTextFieldStyle())
#endif
.textFieldStyle(StandardTextFieldStyle())
ColorPicker(
newLabelColor == .clear ? "Select Color" : newLabelColor.description,
selection: $newLabelColor
@ -130,7 +132,9 @@ struct CreateLabelView: View {
}
}
.navigationTitle("Create New Label")
.navigationBarTitleDisplayMode(.inline)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
}

View file

@ -4,107 +4,109 @@ import Utils
import Views
import WebKit
struct WebReader: UIViewRepresentable {
let articleContent: ArticleContent
let item: FeedItem
let openLinkAction: (URL) -> Void
let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void
let navBarVisibilityRatioUpdater: (Double) -> Void
#if os(iOS)
struct WebReader: UIViewRepresentable {
let articleContent: ArticleContent
let item: FeedItem
let openLinkAction: (URL) -> Void
let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void
let navBarVisibilityRatioUpdater: (Double) -> Void
@Binding var increaseFontActionID: UUID?
@Binding var decreaseFontActionID: UUID?
@Binding var annotationSaveTransactionID: UUID?
@Binding var annotation: String
@Binding var increaseFontActionID: UUID?
@Binding var decreaseFontActionID: UUID?
@Binding var annotationSaveTransactionID: UUID?
@Binding var annotation: String
func makeCoordinator() -> WebReaderCoordinator {
WebReaderCoordinator()
}
func fontSize() -> Int {
let storedSize = UserDefaults.standard.integer(forKey: UserDefaultKey.preferredWebFontSize.rawValue)
return storedSize <= 1 ? UITraitCollection.current.preferredWebFontSize : storedSize
}
func makeUIView(context: Context) -> WKWebView {
let webView = WebViewManager.shared()
let contentController = WKUserContentController()
webView.navigationDelegate = context.coordinator
webView.isOpaque = false
webView.backgroundColor = .clear
webView.configuration.userContentController = contentController
webView.scrollView.delegate = context.coordinator
webView.scrollView.contentInset.top = readerViewNavBarHeight
webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight
webView.configuration.userContentController.removeAllScriptMessageHandlers()
for action in WebViewAction.allCases {
webView.configuration.userContentController.add(context.coordinator, name: action.rawValue)
func makeCoordinator() -> WebReaderCoordinator {
WebReaderCoordinator()
}
webView.configuration.userContentController.add(webView, name: "viewerAction")
webView.configuration.userContentController.addScriptMessageHandler(
context.coordinator, contentWorld: .page, name: "articleAction"
)
context.coordinator.linkHandler = openLinkAction
context.coordinator.webViewActionHandler = webViewActionHandler
context.coordinator.updateNavBarVisibilityRatio = navBarVisibilityRatioUpdater
loadContent(webView: webView)
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
if annotationSaveTransactionID != context.coordinator.lastSavedAnnotationID {
context.coordinator.lastSavedAnnotationID = annotationSaveTransactionID
(webView as? WebView)?.saveAnnotation(annotation: annotation)
func fontSize() -> Int {
let storedSize = UserDefaults.standard.integer(forKey: UserDefaultKey.preferredWebFontSize.rawValue)
return storedSize <= 1 ? UITraitCollection.current.preferredWebFontSize : storedSize
}
if increaseFontActionID != context.coordinator.previousIncreaseFontActionID {
context.coordinator.previousIncreaseFontActionID = increaseFontActionID
(webView as? WebView)?.increaseFontSize()
}
func makeUIView(context: Context) -> WKWebView {
let webView = WebViewManager.shared()
let contentController = WKUserContentController()
if decreaseFontActionID != context.coordinator.previousDecreaseFontActionID {
context.coordinator.previousDecreaseFontActionID = decreaseFontActionID
(webView as? WebView)?.decreaseFontSize()
}
webView.navigationDelegate = context.coordinator
webView.isOpaque = false
webView.backgroundColor = .clear
webView.configuration.userContentController = contentController
webView.scrollView.delegate = context.coordinator
webView.scrollView.contentInset.top = readerViewNavBarHeight
webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight
// If the webview had been terminated `needsReload` will have been set to true
if context.coordinator.needsReload {
webView.configuration.userContentController.removeAllScriptMessageHandlers()
for action in WebViewAction.allCases {
webView.configuration.userContentController.add(context.coordinator, name: action.rawValue)
}
webView.configuration.userContentController.add(webView, name: "viewerAction")
webView.configuration.userContentController.addScriptMessageHandler(
context.coordinator, contentWorld: .page, name: "articleAction"
)
context.coordinator.linkHandler = openLinkAction
context.coordinator.webViewActionHandler = webViewActionHandler
context.coordinator.updateNavBarVisibilityRatio = navBarVisibilityRatioUpdater
loadContent(webView: webView)
context.coordinator.needsReload = false
return
return webView
}
if webView.isLoading { return }
func updateUIView(_ webView: WKWebView, context: Context) {
if annotationSaveTransactionID != context.coordinator.lastSavedAnnotationID {
context.coordinator.lastSavedAnnotationID = annotationSaveTransactionID
(webView as? WebView)?.saveAnnotation(annotation: annotation)
}
// If the root element is not detected then `WKWebView` may have unloaded the content
// so we need to load it again.
webView.evaluateJavaScript("document.getElementById('root') ? true : false") { hasRootElement, _ in
guard let hasRootElement = hasRootElement as? Bool else { return }
if increaseFontActionID != context.coordinator.previousIncreaseFontActionID {
context.coordinator.previousIncreaseFontActionID = increaseFontActionID
(webView as? WebView)?.increaseFontSize()
}
if !hasRootElement {
DispatchQueue.main.async {
loadContent(webView: webView)
if decreaseFontActionID != context.coordinator.previousDecreaseFontActionID {
context.coordinator.previousDecreaseFontActionID = decreaseFontActionID
(webView as? WebView)?.decreaseFontSize()
}
// If the webview had been terminated `needsReload` will have been set to true
if context.coordinator.needsReload {
loadContent(webView: webView)
context.coordinator.needsReload = false
return
}
if webView.isLoading { return }
// If the root element is not detected then `WKWebView` may have unloaded the content
// so we need to load it again.
webView.evaluateJavaScript("document.getElementById('root') ? true : false") { hasRootElement, _ in
guard let hasRootElement = hasRootElement as? Bool else { return }
if !hasRootElement {
DispatchQueue.main.async {
loadContent(webView: webView)
}
}
}
}
}
func loadContent(webView: WKWebView) {
webView.loadHTMLString(
WebReaderContent(
articleContent: articleContent,
item: item,
isDark: UITraitCollection.current.userInterfaceStyle == .dark,
fontSize: fontSize()
func loadContent(webView: WKWebView) {
webView.loadHTMLString(
WebReaderContent(
articleContent: articleContent,
item: item,
isDark: UITraitCollection.current.userInterfaceStyle == .dark,
fontSize: fontSize()
)
.styledContent,
baseURL: ViewsPackage.bundleURL
)
.styledContent,
baseURL: ViewsPackage.bundleURL
)
}
}
}
#endif

View file

@ -5,278 +5,280 @@ import SwiftUI
import Views
import WebKit
struct WebReaderContainerView: View {
let item: FeedItem
let homeFeedViewModel: HomeFeedViewModel
#if os(iOS)
struct WebReaderContainerView: View {
let item: FeedItem
let homeFeedViewModel: HomeFeedViewModel
@State private var showFontSizePopover = false
@State var showHighlightAnnotationModal = false
@State var safariWebLink: SafariWebLink?
@State private var navBarVisibilityRatio = 1.0
@State private var showDeleteConfirmation = false
@State private var showOverlay = true
@State var increaseFontActionID: UUID?
@State var decreaseFontActionID: UUID?
@State var annotationSaveTransactionID: UUID?
@State var annotation = String()
@State private var showFontSizePopover = false
@State var showHighlightAnnotationModal = false
@State var safariWebLink: SafariWebLink?
@State private var navBarVisibilityRatio = 1.0
@State private var showDeleteConfirmation = false
@State private var showOverlay = true
@State var increaseFontActionID: UUID?
@State var decreaseFontActionID: UUID?
@State var annotationSaveTransactionID: UUID?
@State var annotation = String()
@EnvironmentObject var dataService: DataService
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
@StateObject var viewModel = WebReaderViewModel()
@EnvironmentObject var dataService: DataService
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
@StateObject var viewModel = WebReaderViewModel()
var fontAdjustmentPopoverView: some View {
FontSizeAdjustmentPopoverView(
increaseFontAction: { increaseFontActionID = UUID() },
decreaseFontAction: { decreaseFontActionID = UUID() }
)
}
var fontAdjustmentPopoverView: some View {
FontSizeAdjustmentPopoverView(
increaseFontAction: { increaseFontActionID = UUID() },
decreaseFontAction: { decreaseFontActionID = UUID() }
)
}
func webViewActionHandler(message: WKScriptMessage, replyHandler: WKScriptMessageReplyHandler?) {
if message.name == WebViewAction.readingProgressUpdate.rawValue {
let messageBody = message.body as? [String: Double]
func webViewActionHandler(message: WKScriptMessage, replyHandler: WKScriptMessageReplyHandler?) {
if message.name == WebViewAction.readingProgressUpdate.rawValue {
let messageBody = message.body as? [String: Double]
if let messageBody = messageBody, let progress = messageBody["progress"] {
if let messageBody = messageBody, let progress = messageBody["progress"] {
homeFeedViewModel.uncommittedReadingProgressUpdates[item.id] = Double(progress)
}
}
if let replyHandler = replyHandler {
viewModel.webViewActionWithReplyHandler(
message: message,
replyHandler: replyHandler,
dataService: dataService
)
return
}
if message.name == WebViewAction.highlightAction.rawValue {
handleHighlightAction(message: message)
}
if message.name == WebViewAction.readingProgressUpdate.rawValue {
guard let messageBody = message.body as? [String: Double] else { return }
guard let progress = messageBody["progress"] else { return }
homeFeedViewModel.uncommittedReadingProgressUpdates[item.id] = Double(progress)
}
}
if let replyHandler = replyHandler {
viewModel.webViewActionWithReplyHandler(
message: message,
replyHandler: replyHandler,
dataService: dataService
)
return
}
private func handleHighlightAction(message: WKScriptMessage) {
guard let messageBody = message.body as? [String: String] else { return }
guard let actionID = messageBody["actionID"] else { return }
if message.name == WebViewAction.highlightAction.rawValue {
handleHighlightAction(message: message)
}
if message.name == WebViewAction.readingProgressUpdate.rawValue {
guard let messageBody = message.body as? [String: Double] else { return }
guard let progress = messageBody["progress"] else { return }
homeFeedViewModel.uncommittedReadingProgressUpdates[item.id] = Double(progress)
}
}
private func handleHighlightAction(message: WKScriptMessage) {
guard let messageBody = message.body as? [String: String] else { return }
guard let actionID = messageBody["actionID"] else { return }
switch actionID {
case "annotate":
annotation = messageBody["annotation"] ?? ""
showHighlightAnnotationModal = true
default:
break
}
}
var navBariOS14: some View {
HStack(alignment: .center) {
Button(
action: { self.presentationMode.wrappedValue.dismiss() },
label: {
Image(systemName: "chevron.backward")
.font(.appTitleTwo)
.foregroundColor(.appGrayTextContrast)
.padding(.horizontal)
}
)
.scaleEffect(navBarVisibilityRatio)
Spacer()
Button(
action: { showFontSizePopover.toggle() },
label: {
Image(systemName: "textformat.size")
.font(.appTitleTwo)
}
)
.padding(.horizontal)
.scaleEffect(navBarVisibilityRatio)
}
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
.opacity(navBarVisibilityRatio)
.background(Color.systemBackground)
.onTapGesture {
showFontSizePopover = false
}
}
@available(macOS 12.0, *)
@available(iOS 15.0, *)
var navBar: some View {
HStack(alignment: .center) {
Button(
action: { self.presentationMode.wrappedValue.dismiss() },
label: {
Image(systemName: "chevron.backward")
.font(.appTitleTwo)
.foregroundColor(.appGrayTextContrast)
.padding(.horizontal)
}
)
.scaleEffect(navBarVisibilityRatio)
Spacer()
Button(
action: { showFontSizePopover.toggle() },
label: {
Image(systemName: "textformat.size")
.font(.appTitleTwo)
}
)
.padding(.horizontal)
.scaleEffect(navBarVisibilityRatio)
Menu(
content: {
Group {
Button(
action: {
homeFeedViewModel.setLinkArchived(
dataService: dataService,
linkId: item.id,
archived: !item.isArchived
)
},
label: {
Label(
item.isArchived ? "Unarchive" : "Archive",
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
)
}
)
Button(
action: { showDeleteConfirmation = true },
label: { Label("Delete", systemImage: "trash") }
)
}
},
label: {
Image.profile
.padding(.horizontal)
.scaleEffect(navBarVisibilityRatio)
}
)
}
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
.opacity(navBarVisibilityRatio)
.background(Color.systemBackground)
.onTapGesture {
showFontSizePopover = false
}
.alert("Are you sure?", isPresented: $showDeleteConfirmation) {
Button("Remove Link", role: .destructive) {
homeFeedViewModel.removeLink(dataService: dataService, linkId: item.id)
switch actionID {
case "annotate":
annotation = messageBody["annotation"] ?? ""
showHighlightAnnotationModal = true
default:
break
}
Button("Cancel", role: .cancel, action: {})
}
}
var body: some View {
ZStack {
if let articleContent = viewModel.articleContent {
WebReader(
articleContent: articleContent,
item: item,
openLinkAction: {
#if os(macOS)
NSWorkspace.shared.open($0)
#elseif os(iOS)
safariWebLink = SafariWebLink(id: UUID(), url: $0)
#endif
},
webViewActionHandler: webViewActionHandler,
navBarVisibilityRatioUpdater: {
if $0 < 1 {
showFontSizePopover = false
}
navBarVisibilityRatio = $0
},
increaseFontActionID: $increaseFontActionID,
decreaseFontActionID: $decreaseFontActionID,
annotationSaveTransactionID: $annotationSaveTransactionID,
annotation: $annotation
var navBariOS14: some View {
HStack(alignment: .center) {
Button(
action: { self.presentationMode.wrappedValue.dismiss() },
label: {
Image(systemName: "chevron.backward")
.font(.appTitleTwo)
.foregroundColor(.appGrayTextContrast)
.padding(.horizontal)
}
)
.overlay(
Group {
if showOverlay {
Color.systemBackground
.transition(.opacity)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
withAnimation(.linear(duration: 0.2)) {
showOverlay = false
.scaleEffect(navBarVisibilityRatio)
Spacer()
Button(
action: { showFontSizePopover.toggle() },
label: {
Image(systemName: "textformat.size")
.font(.appTitleTwo)
}
)
.padding(.horizontal)
.scaleEffect(navBarVisibilityRatio)
}
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
.opacity(navBarVisibilityRatio)
.background(Color.systemBackground)
.onTapGesture {
showFontSizePopover = false
}
}
@available(macOS 12.0, *)
@available(iOS 15.0, *)
var navBar: some View {
HStack(alignment: .center) {
Button(
action: { self.presentationMode.wrappedValue.dismiss() },
label: {
Image(systemName: "chevron.backward")
.font(.appTitleTwo)
.foregroundColor(.appGrayTextContrast)
.padding(.horizontal)
}
)
.scaleEffect(navBarVisibilityRatio)
Spacer()
Button(
action: { showFontSizePopover.toggle() },
label: {
Image(systemName: "textformat.size")
.font(.appTitleTwo)
}
)
.padding(.horizontal)
.scaleEffect(navBarVisibilityRatio)
Menu(
content: {
Group {
Button(
action: {
homeFeedViewModel.setLinkArchived(
dataService: dataService,
linkId: item.id,
archived: !item.isArchived
)
},
label: {
Label(
item.isArchived ? "Unarchive" : "Archive",
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
)
}
)
Button(
action: { showDeleteConfirmation = true },
label: { Label("Delete", systemImage: "trash") }
)
}
},
label: {
Image.profile
.padding(.horizontal)
.scaleEffect(navBarVisibilityRatio)
}
)
}
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
.opacity(navBarVisibilityRatio)
.background(Color.systemBackground)
.onTapGesture {
showFontSizePopover = false
}
.alert("Are you sure?", isPresented: $showDeleteConfirmation) {
Button("Remove Link", role: .destructive) {
homeFeedViewModel.removeLink(dataService: dataService, linkId: item.id)
}
Button("Cancel", role: .cancel, action: {})
}
}
var body: some View {
ZStack {
if let articleContent = viewModel.articleContent {
WebReader(
articleContent: articleContent,
item: item,
openLinkAction: {
#if os(macOS)
NSWorkspace.shared.open($0)
#elseif os(iOS)
safariWebLink = SafariWebLink(id: UUID(), url: $0)
#endif
},
webViewActionHandler: webViewActionHandler,
navBarVisibilityRatioUpdater: {
if $0 < 1 {
showFontSizePopover = false
}
navBarVisibilityRatio = $0
},
increaseFontActionID: $increaseFontActionID,
decreaseFontActionID: $decreaseFontActionID,
annotationSaveTransactionID: $annotationSaveTransactionID,
annotation: $annotation
)
.overlay(
Group {
if showOverlay {
Color.systemBackground
.transition(.opacity)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
withAnimation(.linear(duration: 0.2)) {
showOverlay = false
}
}
}
}
}
}
)
.sheet(item: $safariWebLink) {
SafariView(url: $0.url)
}
.sheet(isPresented: $showHighlightAnnotationModal) {
HighlightAnnotationSheet(
annotation: $annotation,
onSave: {
annotationSaveTransactionID = UUID()
showHighlightAnnotationModal = false
},
onCancel: {
showHighlightAnnotationModal = false
}
}
)
}
} else {
Color.clear
.contentShape(Rectangle())
.onAppear {
if !viewModel.isLoading {
viewModel.loadContent(dataService: dataService, slug: item.slug)
}
.sheet(item: $safariWebLink) {
SafariView(url: $0.url)
}
}
if showFontSizePopover {
VStack {
.sheet(isPresented: $showHighlightAnnotationModal) {
HighlightAnnotationSheet(
annotation: $annotation,
onSave: {
annotationSaveTransactionID = UUID()
showHighlightAnnotationModal = false
},
onCancel: {
showHighlightAnnotationModal = false
}
)
}
} else {
Color.clear
.contentShape(Rectangle())
.frame(height: LinkItemDetailView.navBarHeight)
HStack {
.onAppear {
if !viewModel.isLoading {
viewModel.loadContent(dataService: dataService, slug: item.slug)
}
}
}
if showFontSizePopover {
VStack {
Color.clear
.contentShape(Rectangle())
.frame(height: LinkItemDetailView.navBarHeight)
HStack {
Spacer()
fontAdjustmentPopoverView
.background(Color.appButtonBackground)
.cornerRadius(8)
.padding(.trailing, 44)
}
Spacer()
fontAdjustmentPopoverView
.background(Color.appButtonBackground)
.cornerRadius(8)
.padding(.trailing, 44)
}
Spacer()
.background(
Color.clear
.contentShape(Rectangle())
.onTapGesture {
showFontSizePopover = false
}
)
}
.background(
Color.clear
.contentShape(Rectangle())
.onTapGesture {
showFontSizePopover = false
}
)
}
if #available(iOS 15.0, *) {
VStack(spacing: 0) {
navBar
Spacer()
if #available(iOS 15.0, *) {
VStack(spacing: 0) {
navBar
Spacer()
}
.navigationBarHidden(true)
} else {
VStack(spacing: 0) {
navBariOS14
Spacer()
}
.navigationBarHidden(true)
}
.navigationBarHidden(true)
} else {
VStack(spacing: 0) {
navBariOS14
Spacer()
}
.navigationBarHidden(true)
}
}.onDisappear {
// Clear the shared webview content when exiting
WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
}.onDisappear {
// Clear the shared webview content when exiting
WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
}
.navigationBarHidden(true)
}
.navigationBarHidden(true)
}
}
#endif

View file

@ -2,7 +2,9 @@ import Combine
import Models
import Services
import SwiftUI
import UIKit
#if os(iOS)
import UIKit
#endif
import Utils
import Views
import WebKit

View file

@ -45,9 +45,17 @@ public extension Color {
}
private func toHex() -> String? {
guard let components = UIColor(self).cgColor.components, components.count >= 3 else {
return nil
}
#if os(iOS)
guard let components = UIColor(self).cgColor.components, components.count >= 3 else {
return nil
}
#endif
#if os(macOS)
guard let components = NSColor(self).cgColor.components, components.count >= 3 else {
return nil
}
#endif
let red = Float(components[0])
let green = Float(components[1])
let blue = Float(components[2])
@ -61,9 +69,17 @@ public extension Color {
}
var isDark: Bool {
guard let components = UIColor(self).cgColor.components, components.count >= 3 else {
return false
}
#if os(iOS)
guard let components = UIColor(self).cgColor.components, components.count >= 3 else {
return false
}
#endif
#if os(macOS)
guard let components = NSColor(self).cgColor.components, components.count >= 3 else {
return false
}
#endif
let lum = 0.2126 * Float(components[0]) + 0.7152 * Float(components[1]) + 0.0722 * Float(components[2])
return lum < 0.50

View file

@ -10,7 +10,9 @@ enum WebViewConfigurationManager {
static func create() -> WKWebViewConfiguration {
let config = WKWebViewConfiguration()
config.processPool = processPool
config.allowsInlineMediaPlayback = true
#if os(iOS)
config.allowsInlineMediaPlayback = true
#endif
config.mediaTypesRequiringUserActionForPlayback = .audio
return config
}

View file

@ -55,7 +55,7 @@ public final class WebView: WKWebView {
}
#elseif os(macOS)
override func viewDidChangeEffectiveAppearance() {
override public func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
switch effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) {
case .some(.darkAqua):