Files
omnivore/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift
Jackson Harper 85caaa7ea3 Use transmission for snackbar, fix issue with ownership of currently viewed item
The list object of the root library view had ownership of the
currently selected item, so object modifications that removed
the item from the current library list (like move or archive)
could cause the object to be released and the current screen
to continue operating on an invalid object.
2024-02-06 11:43:43 +08:00

695 lines
22 KiB
Swift

import AVFoundation
import Models
import Services
import SwiftUI
import Transmission
import Utils
import Views
import WebKit
// swiftlint:disable file_length type_body_length
struct WebReaderContainerView: View {
@State var item: Models.LibraryItem
@Environment(\.dismiss) private var dismiss
@State private var showPreferencesPopover = false
@State private var showPreferencesFormsheet = false
@State private var showLabelsModal = false
@State private var showHighlightLabelsModal = false
@State private var showTitleEdit = false
@State private var showNotebookView = false
@State private var hasPerformedHighlightMutations = false
@State var showHighlightAnnotationModal = false
@State private var navBarVisible = true
@State private var progressViewOpacity = 0.0
@State var readerSettingsChangedTransactionID: UUID?
@State var annotationSaveTransactionID: UUID?
@State var showNavBarActionID: UUID?
@State var showExpandedAudioPlayer = false
@State var shareActionID: UUID?
@State var annotation = String()
@State private var bottomBarOpacity = 0.0
@State private var errorAlertMessage: String?
@State private var showErrorAlertMessage = false
@State private var showRecommendSheet = false
@State private var showOpenArchiveSheet = false
@State private var lastScrollPercentage: Int?
@State private var isRecovering = false
@State var safariWebLink: SafariWebLink?
@State var displayLinkSheet = false
@State var linkToOpen: URL?
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@Environment(\.openURL) var openURL
@StateObject var viewModel = WebReaderViewModel()
@AppStorage(UserDefaultKey.prefersHideStatusBarInReader.rawValue) var prefersHideStatusBarInReader = false
func webViewActionHandler(message: WKScriptMessage, replyHandler: WKScriptMessageReplyHandler?) {
if let replyHandler = replyHandler {
viewModel.webViewActionWithReplyHandler(
message: message,
replyHandler: replyHandler,
dataService: dataService
)
return
}
if message.name == WebViewAction.highlightAction.rawValue {
handleHighlightAction(message: message)
}
}
func scrollPercentHandler(percent: Int) {
lastScrollPercentage = percent
}
func onNotebookViewDismissal() {
// Reload the web view if mutation happened in highlights list modal
guard hasPerformedHighlightMutations else { return }
hasPerformedHighlightMutations.toggle()
Task {
if let username = dataService.currentViewer?.username {
await viewModel.loadContent(
dataService: dataService,
username: username,
itemID: item.unwrappedID
)
}
}
}
private func tapHandler() {
withAnimation(.easeIn(duration: 0.08)) {
navBarVisible = !navBarVisible
showNavBarActionID = UUID()
}
}
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
case "noteCreated":
showHighlightAnnotationModal = false
case "highlightError":
errorAlertMessage = messageBody["error"] ?? "An error occurred."
showErrorAlertMessage = true
case "setHighlightLabels":
annotation = messageBody["highlightID"] ?? ""
showHighlightLabelsModal = true
case "pageTapped":
withAnimation {
navBarVisible = !navBarVisible
showNavBarActionID = UUID()
}
case "dismissNavBars":
withAnimation {
navBarVisible = false
showNavBarActionID = UUID()
}
default:
break
}
}
#if os(iOS)
var audioNavbarItem: some View {
if !audioController.playbackError, audioController.isLoadingItem(itemID: item.unwrappedID) {
return AnyView(ProgressView()
.padding(.horizontal))
} else {
return AnyView(
Button(
action: {
switch audioController.state {
case .playing:
if audioController.itemAudioProperties?.itemID == self.item.unwrappedID {
audioController.pause()
return
}
fallthrough
case .paused:
if audioController.itemAudioProperties?.itemID == self.item.unwrappedID {
audioController.unpause()
return
}
fallthrough
default:
audioController.play(itemAudioProperties: item.audioProperties)
}
},
label: {
textToSpeechButtonImage
}
)
.buttonStyle(.plain)
.padding(.trailing, 4)
)
}
}
var textToSpeechButtonImage: some View {
if audioController.playbackError || audioController.state == .stopped || audioController.itemAudioProperties?.itemID != self.item.id {
return Image.audioPlay
}
if audioController.isPlayingItem(itemID: item.unwrappedID) {
return Image.audioPause
}
return Image.audioPlay
}
#endif
func audioMenuItem() -> some View {
Button(
action: {
viewModel.downloadAudio(audioController: audioController, item: item)
},
label: {
// swiftlint:disable:next line_length
Label(viewModel.isDownloadingAudio ? "Downloading Audio" : "Download Audio", systemImage: "icloud.and.arrow.down")
}
)
}
func menuItems(for item: Models.LibraryItem) -> some View {
let hasLabels = item.labels?.count != 0
return Group {
Button(
action: { showTitleEdit = true },
label: { Label("Edit Info", systemImage: "info.circle") }
)
Button(
action: editLabels,
label: { Label(hasLabels ? "Edit Labels" : "Add Labels", systemImage: "tag") }
)
Button(
action: {
archive()
},
label: {
Label(
item.isArchived ? "Unarchive" : "Archive",
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
)
}
)
Button(
action: {
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0, force: true)
},
label: { Label("Reset Read Location", systemImage: "arrow.counterclockwise.circle") }
)
if viewModel.hasOriginalUrl(item) {
Button(
action: {
openOriginalURL(urlString: item.pageURLString)
},
label: { Label("Open Original", systemImage: "safari") }
)
Button(
action: {
showOpenArchiveSheet = true
},
label: { Label("Open on Archive.today", systemImage: "globe") }
)
Button(
action: share,
label: { Label("Share Original", systemImage: "square.and.arrow.up") }
)
}
Button(
action: copyDeeplink,
label: { Label("Copy Deeplink", systemImage: "link") }
)
Button(
action: delete,
label: { Label("Remove", systemImage: "trash") }
)
Button(
action: {
showRecommendSheet = true
},
label: { Label("Recommend", systemImage: "sparkles") }
)
}
}
let navBarOffset = 100
var navBar: some View {
HStack(alignment: .center, spacing: 10) {
#if os(iOS)
Button(
action: {
dismiss()
},
label: {
Image.chevronRight
.padding(.horizontal, 10)
.padding(.vertical)
}
)
.buttonStyle(.plain)
Spacer()
#endif
Button(
action: { showLabelsModal = true },
label: {
Image.label
}
)
.buttonStyle(.plain)
.padding(.trailing, 4)
Button(
action: { showNotebookView = true },
label: {
Image.notebook
}
)
.buttonStyle(.plain)
.padding(.trailing, 4)
#if os(iOS)
audioNavbarItem
Button(
action: {
if UIDevice.current.userInterfaceIdiom == .phone {
showPreferencesFormsheet.toggle()
} else {
showPreferencesPopover.toggle()
}
},
label: {
Image.readerSettings
}
)
.buttonStyle(.plain)
.padding(.trailing, 5)
.popover(isPresented: $showPreferencesPopover) {
webPreferencesPopoverView
.frame(maxWidth: 400, maxHeight: 475)
}
.formSheet(isPresented: $showPreferencesFormsheet, modalSize: CGSize(width: 400, height: 475)) {
webPreferencesPopoverView
}
#endif
#if os(macOS)
Spacer()
#endif
Menu(
content: {
menuItems(for: item)
},
label: {
#if os(iOS)
Image.utilityMenu
#else
Text(LocalText.genericOptions)
#endif
}
)
.buttonStyle(.plain)
#if os(macOS)
.frame(maxWidth: 100)
.padding(.trailing, 16)
#else
.padding(.trailing, 16)
#endif
}
.tint(Color(hex: "#2A2A2A"))
.frame(height: readerViewNavBarHeight)
.frame(maxWidth: .infinity)
.foregroundColor(ThemeManager.currentTheme.toolbarColor)
.background(ThemeManager.currentBgColor)
#if os(macOS)
.buttonStyle(PlainButtonStyle())
#endif
}
#if os(iOS)
var webPreferencesPopoverView: some View {
WebPreferencesPopoverView(
updateReaderPreferences: { readerSettingsChangedTransactionID = UUID() },
dismissAction: { showPreferencesPopover = false }
)
}
#endif
var body: some View {
ZStack {
if let articleContent = viewModel.articleContent {
WebReader(
item: item,
viewModel: viewModel,
articleContent: articleContent,
openLinkAction: {
#if os(macOS)
NSWorkspace.shared.open($0)
#elseif os(iOS)
if UIDevice.current.userInterfaceIdiom == .phone, $0.absoluteString != item.unwrappedPageURLString {
linkToOpen = $0
displayLinkSheet = true
} else {
safariWebLink = SafariWebLink(id: UUID(), url: $0)
}
#endif
},
tapHandler: tapHandler,
scrollPercentHandler: scrollPercentHandler,
webViewActionHandler: webViewActionHandler,
navBarVisibilityUpdater: { visible in
withAnimation {
navBarVisible = visible
}
},
readerSettingsChangedTransactionID: $readerSettingsChangedTransactionID,
annotationSaveTransactionID: $annotationSaveTransactionID,
showNavBarActionID: $showNavBarActionID,
shareActionID: $shareActionID,
annotation: $annotation,
showHighlightAnnotationModal: $showHighlightAnnotationModal
)
.background(ThemeManager.currentBgColor)
#if os(iOS)
.statusBar(hidden: prefersHideStatusBarInReader)
#endif
.onAppear {
dataService.updateLinkReadingProgress(
itemID: item.unwrappedID,
readingProgress: max(item.readingProgress, 0.1),
anchorIndex: Int(item.readingProgressAnchor),
force: false
)
Task {
await audioController.preload(itemIDs: [item.unwrappedID])
}
}
.confirmationDialog(linkToOpen?.absoluteString ?? "", isPresented: $displayLinkSheet,
titleVisibility: .visible) {
Button(action: {
if let linkToOpen = linkToOpen {
safariWebLink = SafariWebLink(id: UUID(), url: linkToOpen)
}
}, label: { Text(LocalText.genericOpen) })
Button(action: {
#if os(iOS)
UIPasteboard.general.string = item.unwrappedPageURLString
#else
// Pasteboard.general.string = item.unwrappedPageURLString TODO: fix for mac
#endif
Snackbar.show(message: "Link copied", dismissAfter: 2000)
}, label: { Text(LocalText.readerCopyLink) })
Button(action: {
if let linkToOpen = linkToOpen {
viewModel.saveLink(dataService: dataService, url: linkToOpen)
}
}, label: { Text(LocalText.readerSave) })
}
#if os(iOS)
.sheet(item: $safariWebLink) {
SafariView(url: $0.url)
.ignoresSafeArea(.all, edges: .bottom)
}
.sheet(isPresented: $showExpandedAudioPlayer) {
ExpandedAudioPlayer(delete: { _ in
showExpandedAudioPlayer = false
audioController.stop()
delete()
}, archive: { _ in
showExpandedAudioPlayer = false
audioController.stop()
archive()
}, viewArticle: { _ in
showExpandedAudioPlayer = false
})
}
#endif
.alert(errorAlertMessage ?? LocalText.readerError, isPresented: $showErrorAlertMessage) {
Button(LocalText.genericOk, role: .cancel, action: {
errorAlertMessage = nil
showErrorAlertMessage = false
})
}
#if os(iOS)
.formSheet(isPresented: $showRecommendSheet) {
let highlightCount = item.highlights.asArray(of: Highlight.self).filter(\.createdByMe).count
NavigationView {
RecommendToView(
dataService: dataService,
viewModel: RecommendToViewModel(pageID: item.unwrappedID,
highlightCount: highlightCount)
)
}.onDisappear {
showRecommendSheet = false
}
}
.formSheet(isPresented: $showOpenArchiveSheet) {
OpenArchiveTodayView(item: item)
}
#endif
.sheet(isPresented: $showHighlightAnnotationModal) {
NavigationView {
HighlightAnnotationSheet(
annotation: $annotation,
onSave: {
annotationSaveTransactionID = UUID()
},
onCancel: {
showHighlightAnnotationModal = false
},
errorAlertMessage: $errorAlertMessage,
showErrorAlertMessage: $showErrorAlertMessage
)
}
#if os(iOS)
.navigationViewStyle(StackNavigationViewStyle())
#endif
}
.sheet(isPresented: $showHighlightLabelsModal) {
if let highlight = Highlight.lookup(byID: self.annotation, inContext: self.dataService.viewContext) {
ApplyLabelsView(mode: .highlight(highlight)) { selectedLabels in
viewModel.setLabelsForHighlight(highlightID: highlight.unwrappedID,
labelIDs: selectedLabels.map(\.unwrappedID),
dataService: dataService)
}
}
}
.sheet(isPresented: $showTitleEdit) {
LinkedItemMetadataEditView(item: item, onSave: { title, _ in
item.title = title
// We dont need to update description because its never rendered in this view
readerSettingsChangedTransactionID = UUID()
})
}
.sheet(isPresented: $showLabelsModal) {
ApplyLabelsView(mode: .item(item), onSave: { labels in
showLabelsModal = false
item.labels = NSSet(array: labels)
readerSettingsChangedTransactionID = UUID()
})
}
#if os(iOS)
.sheet(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) {
NotebookView(
viewModel: NotebookViewModel(item: item),
hasHighlightMutations: $hasPerformedHighlightMutations
)
}
#endif
} else if let errorMessage = viewModel.errorMessage {
VStack {
if viewModel.allowRetry, viewModel.hasOriginalUrl(item) {
if item.state == "DELETED" {
Text("Item has been deleted, would you like to recover it?").padding()
if isRecovering {
ProgressView()
} else {
Button("Recover", action: {
self.isRecovering = true
Task {
if !(await dataService.recoverItem(itemID: item.unwrappedID)) {
Snackbar.show(message: "Error recoviering item", dismissAfter: 2000)
} else {
await viewModel.loadContent(
dataService: dataService,
username: dataService.currentViewer?.username ?? "me",
itemID: item.unwrappedID
)
}
isRecovering = false
}
}).buttonStyle(RoundedRectButtonStyle())
}
} else {
Text(errorMessage).padding()
Button("Open Original", action: {
openOriginalURL(urlString: item.pageURLString)
}).buttonStyle(RoundedRectButtonStyle())
if let urlStr = item.pageURLString, let username = dataService.currentViewer?.username, let url = URL(string: urlStr) {
Button("Attempt to Save Again", action: {
viewModel.errorMessage = nil
viewModel.saveLinkAndFetch(dataService: dataService, username: username, url: url)
}).buttonStyle(RoundedRectButtonStyle())
}
}
}
}
} else {
ProgressView()
.opacity(progressViewOpacity)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) {
progressViewOpacity = 1
}
}
.task {
if let username = dataService.currentViewer?.username {
await viewModel.loadContent(
dataService: dataService,
username: username,
itemID: item.unwrappedID
)
} else {
viewModel.errorMessage = "You are not logged in."
}
}
}
#if os(iOS)
VStack(spacing: 0) {
navBar
.offset(y: navBarVisible ? 0 : -150)
Spacer()
if let audioProperties = audioController.itemAudioProperties {
MiniPlayerViewer(itemAudioProperties: audioProperties)
.padding(.top, 10)
.padding(.bottom, navBarVisible ? 10 : 40)
.background(Color.themeTabBarColor)
.onTapGesture {
showExpandedAudioPlayer = true
}
}
if navBarVisible {
CustomToolBar(
isFollowing: item.folder == "following",
isArchived: item.isArchived,
moveToInboxAction: moveToInbox,
archiveAction: archive,
unarchiveAction: archive,
shareAction: share,
deleteAction: delete
)
}
}
#endif
}
#if os(macOS)
.onReceive(NSNotification.readerSettingsChangedPublisher) { _ in
readerSettingsChangedTransactionID = UUID()
}
#endif
.onAppear {
try? WebViewManager.shared().dispatchEvent(.saveReadPosition)
}
.onDisappear {
// WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
WebViewManager.shared().loadHTMLString(WebReaderContent.emptyContent(isDark: Color.isDarkMode), baseURL: nil)
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PopToRoot"))) { _ in
dismiss()
}
.ignoresSafeArea(.all, edges: .bottom)
}
func moveToInbox() {
Task {
do {
try await dataService.moveItem(itemID: item.unwrappedID, folder: "inbox")
Snackbar.show(message: "Moved to library", dismissAfter: 2000)
} catch {
Snackbar.show(message: "Error moving item to inbox", dismissAfter: 2000)
}
}
}
func archive() {
let isArchived = item.isArchived
dataService.archiveLink(objectID: item.objectID, archived: !isArchived)
#if os(iOS)
dismiss()
Snackbar.show(message: isArchived ? "Unarchived" : "Archived", undoAction: {
dataService.archiveLink(objectID: item.objectID, archived: isArchived)
Snackbar.show(message: isArchived ? "Archived" : "Unarchived", dismissAfter: 2000)
}, dismissAfter: 2000)
#endif
}
func recommend() {
showRecommendSheet = true
}
func share() {
shareActionID = UUID()
}
func copyDeeplink() {
if let deepLink = item.deepLink {
#if os(iOS)
UIPasteboard.general.string = deepLink.absoluteString
#else
let pasteBoard = NSPasteboard.general
pasteBoard.clearContents()
pasteBoard.writeObjects([deepLink.absoluteString as NSString])
#endif
Snackbar.show(message: "Deeplink Copied", dismissAfter: 2000)
} else {
Snackbar.show(message: "Error copying deeplink", dismissAfter: 2000)
}
}
func delete() {
dismiss()
#if os(iOS)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
removeLibraryItemAction(dataService: dataService, objectID: item.objectID)
}
#endif
}
func editLabels() {
showLabelsModal = true
}
func scrollToTop() {}
func openOriginalURL(urlString: String?) {
guard
let urlString = urlString,
let url = URL(string: urlString)
else { return }
openURL(url)
}
}