diff --git a/apple/OmnivoreKit/Sources/App/Views/Highlights/HighlightsListCard.swift b/apple/OmnivoreKit/Sources/App/Views/Highlights/HighlightsListCard.swift new file mode 100644 index 000000000..efa1cd851 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Highlights/HighlightsListCard.swift @@ -0,0 +1,136 @@ +import Models +import SwiftUI +import Views + +struct HighlightsListCard: View { + @State var isContextMenuOpen = false + @State var annotation = String() + @State var showAnnotationModal = false + + let highlightParams: HighlightListItemParams + @Binding var hasHighlightMutations: Bool + let onSaveAnnotation: (String) -> Void + let onDeleteHighlight: () -> Void + + var contextMenuView: some View { + Group { + Button( + action: { + #if os(iOS) + UIPasteboard.general.string = highlightParams.quote + #endif + + #if os(macOS) + let pasteBoard = NSPasteboard.general + pasteBoard.clearContents() + pasteBoard.writeObjects([highlightParams.quote as NSString]) + #endif + + Snackbar.show(message: "Highlight copied") + }, + label: { Label("Copy", systemImage: "doc.on.doc") } + ) + Button( + action: onDeleteHighlight, + label: { Label("Delete", systemImage: "trash") } + ) + } + } + + var noteSection: some View { + Group { + HStack { + Image(systemName: "note.text") + + Text("Note") + .font(.appSubheadline) + .foregroundColor(.appGrayTextContrast) + .lineLimit(1) + + Spacer() + } + + Text(highlightParams.annotation) + } + .onTapGesture { + annotation = highlightParams.annotation + showAnnotationModal = true + } + } + + var addNoteSection: some View { + HStack { + Image(systemName: "note.text.badge.plus").foregroundColor(.appGrayTextContrast) + + Text("Add a Note") + .font(.appSubheadline) + .foregroundColor(.appGrayTextContrast) + .lineLimit(1) + + Spacer() + } + .onTapGesture { + annotation = highlightParams.annotation + showAnnotationModal = true + } + } + + var body: some View { + VStack(alignment: .leading) { + HStack { + Image(systemName: "highlighter") + + Text(highlightParams.title) + .font(.appHeadline) + .foregroundColor(.appGrayTextContrast) + .lineLimit(1) + + Spacer() + + Menu( + content: { contextMenuView }, + label: { + Image(systemName: "ellipsis") + .foregroundColor(.appGrayTextContrast) + .padding() + } + ) + .frame(width: 16, height: 16, alignment: .center) + .onTapGesture { isContextMenuOpen = true } + } + .padding(.top, 8) + + HStack { + Divider() + .frame(width: 6) + .overlay(Color.appYellow48) + + VStack(alignment: .leading, spacing: 8) { + Text(highlightParams.quote) + + Divider() + + if highlightParams.annotation.isEmpty { + addNoteSection + } else { + noteSection + } + } + } + .padding(.bottom, 8) + } + .sheet(isPresented: $showAnnotationModal) { + HighlightAnnotationSheet( + annotation: $annotation, + onSave: { + onSaveAnnotation(annotation) + showAnnotationModal = false + hasHighlightMutations = true + }, + onCancel: { + showAnnotationModal = false + } + ) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Highlights/HighlightsListView.swift b/apple/OmnivoreKit/Sources/App/Views/Highlights/HighlightsListView.swift new file mode 100644 index 000000000..b16a0c3bf --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Highlights/HighlightsListView.swift @@ -0,0 +1,81 @@ +import CoreData +import Models +import Services +import SwiftUI +import Views + +struct HighlightsListView: View { + @EnvironmentObject var dataService: DataService + @Environment(\.presentationMode) private var presentationMode + @StateObject var viewModel = HighlightsListViewModel() + + let itemObjectID: NSManagedObjectID + @Binding var hasHighlightMutations: Bool + + var innerBody: some View { + List { + Section { + ForEach(viewModel.highlightItems) { highlightParams in + HighlightsListCard( + highlightParams: highlightParams, + hasHighlightMutations: $hasHighlightMutations, + onSaveAnnotation: { + viewModel.updateAnnotation( + highlightID: highlightParams.highlightID, + annotation: $0, + dataService: dataService + ) + }, + onDeleteHighlight: { + hasHighlightMutations = true + + viewModel.deleteHighlight( + highlightID: highlightParams.highlightID, + dataService: dataService + ) + } + ) + } + } + } + .navigationTitle("Highlights") + .listStyle(PlainListStyle()) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + dismissButton + } + } + #else + .toolbar { + ToolbarItemGroup { + dismissButton + } + } + #endif + } + + var dismissButton: some View { + Button( + action: { presentationMode.wrappedValue.dismiss() }, + label: { Text("Done").foregroundColor(.appGrayTextContrast) } + ) + } + + var body: some View { + Group { + #if os(iOS) + NavigationView { + innerBody + } + #elseif os(macOS) + innerBody + .frame(minWidth: 400, minHeight: 400) + #endif + } + .task { + viewModel.load(itemObjectID: itemObjectID, dataService: dataService) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Highlights/HighlightsListViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Highlights/HighlightsListViewModel.swift new file mode 100644 index 000000000..6eca3b4e9 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Highlights/HighlightsListViewModel.swift @@ -0,0 +1,58 @@ +import CoreData +import Models +import Services +import SwiftUI +import Views + +struct HighlightListItemParams: Identifiable { + let id = UUID() + let highlightID: String + let title: String + let annotation: String + let quote: String +} + +@MainActor final class HighlightsListViewModel: ObservableObject { + @Published var highlightItems = [HighlightListItemParams]() + + func load(itemObjectID: NSManagedObjectID, dataService: DataService) { + if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? LinkedItem { + loadHighlights(item: linkedItem) + } + } + + func updateAnnotation(highlightID: String, annotation: String, dataService: DataService) { + dataService.updateHighlightAttributes(highlightID: highlightID, annotation: annotation) + + if let index = highlightItems.firstIndex(where: { $0.highlightID == highlightID }) { + highlightItems[index] = HighlightListItemParams( + highlightID: highlightID, + title: highlightItems[index].title, + annotation: annotation, + quote: highlightItems[index].quote + ) + } + } + + func deleteHighlight(highlightID: String, dataService: DataService) { + dataService.deleteHighlight(highlightID: highlightID) + highlightItems.removeAll { $0.highlightID == highlightID } + } + + private func loadHighlights(item: LinkedItem) { + let unsortedHighlights = item.highlights.asArray(of: Highlight.self) + + let highlights = unsortedHighlights.sorted { + ($0.createdAt ?? Date()) < ($1.createdAt ?? Date()) + } + + highlightItems = highlights.map { + HighlightListItemParams( + highlightID: $0.unwrappedID, + title: "Highlight", + annotation: $0.annotation ?? "", + quote: $0.quote ?? "" + ) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index a4a1e91b4..b7d1cfa21 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -11,6 +11,7 @@ import Views private let enableGrid = UIDevice.isIPad || FeatureFlag.enableGridCardsOnPhone struct HomeFeedContainerView: View { + @State var hasHighlightMutations = false @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController @@ -62,6 +63,9 @@ import Views .sheet(item: $viewModel.itemUnderTitleEdit) { item in LinkedItemTitleEditView(item: item) } + .sheet(item: $viewModel.itemForHighlightsView) { item in + HighlightsListView(itemObjectID: item.objectID, hasHighlightMutations: $hasHighlightMutations) + } .toolbar { ToolbarItem(placement: .barTrailing) { Button("", action: {}) @@ -255,6 +259,10 @@ import Views viewModel: viewModel ) .contextMenu { + Button( + action: { viewModel.itemForHighlightsView = item }, + label: { Label("View Highlights", systemImage: "highlighter") } + ) Button( action: { viewModel.itemUnderTitleEdit = item }, label: { Label("Edit Title/Description", systemImage: "textbox") } @@ -372,6 +380,8 @@ import Views func contextMenuActionHandler(item: LinkedItem, action: GridCardAction) { switch action { + case .viewHighlights: + viewModel.itemForHighlightsView = item case .toggleArchiveStatus: viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: !item.isArchived) case .delete: diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift index adced8706..49a12d1c9 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift @@ -36,6 +36,7 @@ import Views viewModel: viewModel ) .contextMenu { + // TODO: add highlights view button Button( action: { viewModel.itemUnderTitleEdit = item }, label: { Label("Edit Title/Description", systemImage: "textbox") } @@ -137,6 +138,7 @@ import Views .sheet(item: $viewModel.itemUnderTitleEdit) { item in LinkedItemTitleEditView(item: item) } + // TODO: add highlights view sheet .task { if viewModel.items.isEmpty { loadItems(isRefresh: true) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 543d23727..65d4f4d75 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -17,6 +17,7 @@ import Views @Published var showPushNotificationPrimer = false @Published var itemUnderLabelEdit: LinkedItem? @Published var itemUnderTitleEdit: LinkedItem? + @Published var itemForHighlightsView: LinkedItem? @Published var searchTerm = "" @Published var selectedLabels = [LinkedItemLabel]() @Published var negatedLabels = [LinkedItemLabel]() diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift index f30aab097..78a8f3dce 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift @@ -70,6 +70,7 @@ struct WebReader: PlatformViewRepresentable { context.coordinator.linkHandler = openLinkAction context.coordinator.webViewActionHandler = webViewActionHandler context.coordinator.updateNavBarVisibilityRatio = navBarVisibilityRatioUpdater + context.coordinator.articleContentID = articleContent.id loadContent(webView: webView) return webView @@ -101,8 +102,10 @@ struct WebReader: PlatformViewRepresentable { } // If the webview had been terminated `needsReload` will have been set to true - if context.coordinator.needsReload { + // Or if the articleContent value has changed then it's id will be different from the coordinator's + if context.coordinator.needsReload || context.coordinator.articleContentID != articleContent.id { loadContent(webView: webView) + context.coordinator.articleContentID = articleContent.id context.coordinator.needsReload = false return } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 3624aec4b..83c78fbf9 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -12,6 +12,8 @@ struct WebReaderContainerView: View { @State private var showPreferencesPopover = false @State private var showLabelsModal = false @State private var showTitleEdit = false + @State private var showHighlightsView = false + @State private var hasPerformedHighlightMutations = false @State var showHighlightAnnotationModal = false @State var safariWebLink: SafariWebLink? @State private var navBarVisibilityRatio = 1.0 @@ -43,6 +45,23 @@ struct WebReaderContainerView: View { } } + func onHighlightListViewDismissal() { + // 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 handleHighlightAction(message: WKScriptMessage) { guard let messageBody = message.body as? [String: String] else { return } guard let actionID = messageBody["actionID"] else { return } @@ -131,6 +150,10 @@ struct WebReaderContainerView: View { Menu( content: { Group { + Button( + action: { showHighlightsView = true }, + label: { Label("View Highlights", systemImage: "highlighter") } + ) Button( action: { showTitleEdit = true }, label: { Label("Edit Title/Description", systemImage: "textbox") } @@ -198,6 +221,12 @@ struct WebReaderContainerView: View { .sheet(isPresented: $showTitleEdit) { LinkedItemTitleEditView(item: item) } + .sheet(isPresented: $showHighlightsView, onDismiss: onHighlightListViewDismissal) { + HighlightsListView( + itemObjectID: item.objectID, + hasHighlightMutations: $hasPerformedHighlightMutations + ) + } #if os(macOS) .buttonStyle(PlainButtonStyle()) #endif diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift index acaaafd18..a08377a45 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift @@ -19,6 +19,7 @@ final class WebReaderCoordinator: NSObject { var previousShowNavBarActionID: UUID? var previousShareActionID: UUID? var updateNavBarVisibilityRatio: (Double) -> Void = { _ in } + var articleContentID = UUID() private var yOffsetAtStartOfDrag: Double? private var lastYOffset: Double = 0 private var hasDragged = false diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/ArticleContent.swift b/apple/OmnivoreKit/Sources/Models/DataModels/ArticleContent.swift index 028dcbacc..86c5469d1 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/ArticleContent.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/ArticleContent.swift @@ -9,6 +9,7 @@ public enum ArticleContentStatus: String { } public struct ArticleContent { + public let id = UUID() public let title: String public let htmlContent: String public let highlightsJSONString: String diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift index e96ba857c..e8785777e 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift @@ -87,6 +87,12 @@ public extension LinkedItem { } } + var sortedHighlights: [Highlight] { + highlights.asArray(of: Highlight.self).sorted { + ($0.createdAt ?? Date()) < ($1.createdAt ?? Date()) + } + } + var labelsJSONString: String { let labels = self.labels.asArray(of: LinkedItemLabel.self).map { label in [ diff --git a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift index 5ace227a6..5c8ac8803 100644 --- a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift +++ b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift @@ -15,4 +15,5 @@ public enum FeatureFlag { public static let enableSnooze = false public static let enableGridCardsOnPhone = false public static let enableTextToSpeechButton = true + public static let enableHighlightsView = true } diff --git a/apple/OmnivoreKit/Sources/Views/Article/HighlightAnnotationSheet.swift b/apple/OmnivoreKit/Sources/Views/Article/HighlightAnnotationSheet.swift index 30dc1def4..bf0a872c6 100644 --- a/apple/OmnivoreKit/Sources/Views/Article/HighlightAnnotationSheet.swift +++ b/apple/OmnivoreKit/Sources/Views/Article/HighlightAnnotationSheet.swift @@ -22,15 +22,13 @@ public struct HighlightAnnotationSheet: View { HStack { Button("Cancel", action: onCancel) Spacer() - HStack { - Image(systemName: "note.text") - Text("Note") - } + Label("Note", systemImage: "note.text") Spacer() Button("Save") { onSave() } } + .foregroundColor(.appGrayTextContrast) ScrollView { TextEditor(text: $annotation) diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift index d3f1707e8..99223de4d 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift @@ -8,6 +8,7 @@ public enum GridCardAction { case editLabels case editTitle case downloadAudio + case viewHighlights } public struct GridCard: View { @@ -45,6 +46,10 @@ public struct GridCard: View { var contextMenuView: some View { Group { + Button( + action: { menuActionHandler(.viewHighlights) }, + label: { Label("View Highlights", systemImage: "highlighter") } + ) Button( action: { menuActionHandler(.editTitle) }, label: { Label("Edit Title/Description", systemImage: "textbox") }