Merge pull request #1475 from omnivore-app/feat/highlight-labels

Allow setting labels from the highlights list view on iOS
This commit is contained in:
Jackson Harper
2022-12-01 15:34:20 +08:00
committed by GitHub
42 changed files with 500 additions and 137 deletions

View File

@ -76,6 +76,8 @@
var menuButton: some View {
Menu {
Menu(String(format: "Playback Speed (%.1f×)", audioController.playbackRate)) {
playbackRateButton(rate: 0.8, title: "0.8×", selected: audioController.playbackRate == 0.8)
playbackRateButton(rate: 0.9, title: "0.9×", selected: audioController.playbackRate == 0.9)
playbackRateButton(rate: 1.0, title: "1.0×", selected: audioController.playbackRate == 1.0)
playbackRateButton(rate: 1.1, title: "1.1×", selected: audioController.playbackRate == 1.1)
playbackRateButton(rate: 1.2, title: "1.2×", selected: audioController.playbackRate == 1.2)
@ -344,6 +346,10 @@
if let itemAudioProperties = self.audioController.itemAudioProperties {
playerContent(itemAudioProperties)
.tint(.appGrayTextContrast)
.alert("There was an error playing back your audio.",
isPresented: $audioController.playbackError) {
Button("Dismiss", role: .none) {}
}
} else {
EmptyView()
}

View File

@ -11,6 +11,10 @@ struct HighlightsListCard: View {
@Binding var hasHighlightMutations: Bool
let onSaveAnnotation: (String) -> Void
let onDeleteHighlight: () -> Void
let onSetLabels: (String) -> Void
@State var errorAlertMessage: String?
@State var showErrorAlertMessage = false
var contextMenuView: some View {
Group {
@ -30,6 +34,12 @@ struct HighlightsListCard: View {
},
label: { Label("Copy", systemImage: "doc.on.doc") }
)
Button(
action: {
onSetLabels(highlightParams.highlightID)
},
label: { Label("Labels", systemImage: "tag") }
)
Button(
action: onDeleteHighlight,
label: { Label("Delete", systemImage: "trash") }
@ -38,19 +48,19 @@ struct HighlightsListCard: View {
}
var noteSection: some View {
Group {
HStack {
Image(systemName: "note.text")
HStack {
let isEmpty = highlightParams.annotation.isEmpty
Spacer(minLength: 6)
Text("Note")
.font(.appSubheadline)
.foregroundColor(.appGrayTextContrast)
.lineLimit(1)
Spacer()
}
Text(highlightParams.annotation)
Text(isEmpty ? "Add Notes..." : highlightParams.annotation)
.lineSpacing(6)
.accentColor(.appGraySolid)
.foregroundColor(isEmpty ? .appGrayText : .appGrayTextContrast)
.font(.appSubheadline)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.appButtonBackground)
.cornerRadius(8)
}
.onTapGesture {
annotation = highlightParams.annotation
@ -58,20 +68,24 @@ struct HighlightsListCard: View {
}
}
var addNoteSection: some View {
HStack {
Image(systemName: "note.text.badge.plus").foregroundColor(.appGrayTextContrast)
Text("ADD NOTE")
.font(.appFootnote)
.foregroundColor(Color.appCtaYellow)
.lineLimit(1)
Spacer()
}
.onTapGesture {
annotation = highlightParams.annotation
showAnnotationModal = true
var labelsView: some View {
if highlightParams.labels.count > 0 {
return AnyView(ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(highlightParams.labels, id: \.self) {
TextChip(feedItemLabel: $0)
.padding(.leading, 0)
}
Spacer()
}
}.introspectScrollView { scrollView in
scrollView.bounces = false
}
.padding(.top, 0)
.padding(.leading, 0)
.padding(.bottom, 0))
} else {
return AnyView(EmptyView())
}
}
@ -101,17 +115,14 @@ struct HighlightsListCard: View {
.padding(.top, 2)
.padding(.trailing, 6)
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 16) {
Text(highlightParams.quote)
if highlightParams.annotation.isEmpty {
addNoteSection
} else {
noteSection
}
labelsView
}
.padding(.bottom, 8)
}
.padding(.bottom, 4)
noteSection
}
.sheet(isPresented: $showAnnotationModal) {
HighlightAnnotationSheet(
@ -123,7 +134,9 @@ struct HighlightsListCard: View {
},
onCancel: {
showAnnotationModal = false
}
},
errorAlertMessage: $errorAlertMessage,
showErrorAlertMessage: $showErrorAlertMessage
)
}
}

View File

@ -11,10 +11,11 @@ struct HighlightsListView: View {
let itemObjectID: NSManagedObjectID
@Binding var hasHighlightMutations: Bool
@State var setLabelsHighlight: Highlight?
var emptyView: some View {
Text("""
You have not added any highlights to this page.
You have not added any highlights or notes to this page.
""")
.multilineTextAlignment(.center)
.padding(16)
@ -22,7 +23,7 @@ struct HighlightsListView: View {
var innerBody: some View {
(viewModel.highlightItems.count > 0 ? AnyView(listView) : AnyView(emptyView))
.navigationTitle("Highlights & Notes")
.navigationTitle("Notebook")
.listStyle(PlainListStyle())
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
@ -61,10 +62,22 @@ struct HighlightsListView: View {
highlightID: highlightParams.highlightID,
dataService: dataService
)
},
onSetLabels: { highlightID in
setLabelsHighlight = Highlight.lookup(byID: highlightID, inContext: dataService.viewContext)
}
)
.listRowSeparator(.hidden)
}
}
}.sheet(item: $setLabelsHighlight) { highlight in
ApplyLabelsView(mode: .highlight(highlight), onSave: { selectedLabels in
hasHighlightMutations = true
viewModel.setLabelsForHighlight(highlightID: highlight.unwrappedID,
labels: selectedLabels,
dataService: dataService)
})
}
}

View File

@ -10,6 +10,7 @@ struct HighlightListItemParams: Identifiable {
let title: String
let annotation: String
let quote: String
let labels: [LinkedItemLabel]
}
@MainActor final class HighlightsListViewModel: ObservableObject {
@ -29,7 +30,8 @@ struct HighlightListItemParams: Identifiable {
highlightID: highlightID,
title: highlightItems[index].title,
annotation: annotation,
quote: highlightItems[index].quote
quote: highlightItems[index].quote,
labels: highlightItems[index].labels
)
}
}
@ -39,6 +41,20 @@ struct HighlightListItemParams: Identifiable {
highlightItems.removeAll { $0.highlightID == highlightID }
}
func setLabelsForHighlight(highlightID: String, labels: [LinkedItemLabel], dataService: DataService) {
dataService.setLabelsForHighlight(highlightID: highlightID, labelIDs: labels.map(\.unwrappedID))
if let index = highlightItems.firstIndex(where: { $0.highlightID == highlightID }) {
highlightItems[index] = HighlightListItemParams(
highlightID: highlightID,
title: highlightItems[index].title,
annotation: highlightItems[index].annotation,
quote: highlightItems[index].quote,
labels: labels
)
}
}
private func loadHighlights(item: LinkedItem) {
let unsortedHighlights = item.highlights.asArray(of: Highlight.self)
@ -51,7 +67,8 @@ struct HighlightListItemParams: Identifiable {
highlightID: $0.unwrappedID,
title: "Highlight",
annotation: $0.annotation ?? "",
quote: $0.quote ?? ""
quote: $0.quote ?? "",
labels: $0.labels.asArray(of: LinkedItemLabel.self)
)
}
}

View File

@ -271,15 +271,13 @@ import Views
func menuItems(for item: LinkedItem) -> some View {
Group {
if (item.highlights?.count ?? 0) > 0 {
Button(
action: { viewModel.itemForHighlightsView = item },
label: { Label("View Highlights & Notes", systemImage: "highlighter") }
)
}
Button(
action: { viewModel.itemForHighlightsView = item },
label: { Label("Notebook", systemImage: "highlighter") }
)
Button(
action: { viewModel.itemUnderTitleEdit = item },
label: { Label("Edit Metadata", systemImage: "textbox") }
label: { Label("Edit Info", systemImage: "info.circle") }
)
Button(
action: { viewModel.itemUnderLabelEdit = item },
@ -346,6 +344,7 @@ import Views
List {
filtersHeader
.listRowSeparator(.hidden, edges: .top)
ForEach(viewModel.items) { item in
FeedCardNavigationLink(

View File

@ -46,7 +46,7 @@ import Views
// TODO: add highlights view button
Button(
action: { viewModel.itemUnderTitleEdit = item },
label: { Label("Edit Metadata", systemImage: "textbox") }
label: { Label("Edit Info", systemImage: "info.circle") }
)
Button(
action: { viewModel.itemUnderLabelEdit = item },

View File

@ -6,11 +6,12 @@ import Views
struct ApplyLabelsView: View {
enum Mode {
case item(LinkedItem)
case highlight(Highlight)
case list([LinkedItemLabel])
var navTitle: String {
switch self {
case .item:
case .item, .highlight:
return "Assign Labels"
case .list:
return "Apply Label Filters"
@ -19,7 +20,7 @@ struct ApplyLabelsView: View {
var confirmButtonText: String {
switch self {
case .item:
case .item, .highlight:
return "Save"
case .list:
return "Apply"
@ -109,6 +110,8 @@ struct ApplyLabelsView: View {
switch mode {
case let .item(feedItem):
viewModel.saveItemLabelChanges(itemID: feedItem.unwrappedID, dataService: dataService)
case .highlight:
onSave?(viewModel.selectedLabels)
case .list:
onSave?(viewModel.selectedLabels)
}
@ -148,6 +151,8 @@ struct ApplyLabelsView: View {
switch mode {
case let .item(feedItem):
await viewModel.loadLabels(dataService: dataService, item: feedItem)
case let .highlight(highlight):
await viewModel.loadLabels(dataService: dataService, highlight: highlight)
case let .list(labels):
await viewModel.loadLabels(dataService: dataService, initiallySelectedLabels: labels)
}

View File

@ -36,6 +36,29 @@ import Views
isLoading = false
}
func loadLabels(
dataService: DataService,
highlight: Highlight
) async {
isLoading = true
if let labelIDs = try? await dataService.labels() {
dataService.viewContext.performAndWait {
self.labels = labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel }
}
let selLabels = highlight.labels ?? []
for label in labels {
if selLabels.contains(label) {
selectedLabels.append(label)
} else {
unselectedLabels.append(label)
}
}
}
isLoading = false
}
func loadLabelsFromStore(dataService: DataService) async {
let fetchRequest: NSFetchRequest<Models.LinkedItemLabel> = LinkedItemLabel.fetchRequest()
@ -95,6 +118,10 @@ import Views
dataService.updateItemLabels(itemID: itemID, labelIDs: selectedLabels.map(\.unwrappedID))
}
func saveHighlightLabelChanges(highlightID: String, dataService: DataService) {
dataService.setLabelsForHighlight(highlightID: highlightID, labelIDs: selectedLabels.map(\.unwrappedID))
}
func addLabelToItem(_ label: LinkedItemLabel) {
selectedLabels.insert(label, at: 0)
unselectedLabels.removeAll { $0.name == label.name }

View File

@ -150,7 +150,7 @@ struct LinkItemDetailView: View {
Group {
Button(
action: { showTitleEdit = true },
label: { Label("Edit Metadata", systemImage: "textbox") }
label: { Label("Edit Info", systemImage: "info.circle") }
)
Button(
action: { viewModel.handleArchiveAction(dataService: dataService) },

View File

@ -18,7 +18,7 @@
}
innerBody
}
}
}.navigationTitle("Text to Speech")
}
private var innerBody: some View {

View File

@ -87,9 +87,6 @@ struct WebReader: PlatformViewRepresentable {
context.coordinator.lastSavedAnnotationID = annotationSaveTransactionID
do {
try (webView as? OmnivoreWebView)?.dispatchEvent(.saveAnnotation(annotation: annotation))
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
showHighlightAnnotationModal = false
}
} catch {
showInSnackbar("Error saving note.")
}

View File

@ -11,6 +11,7 @@ struct WebReaderContainerView: View {
@State private var showPreferencesPopover = false
@State private var showLabelsModal = false
@State private var showHighlightLabelsModal = false
@State private var showTitleEdit = false
@State private var showHighlightsView = false
@State private var hasPerformedHighlightMutations = false
@ -26,6 +27,8 @@ struct WebReaderContainerView: View {
@State var annotation = String()
@State var showBottomBar = false
@State private var bottomBarOpacity = 0.0
@State private var errorAlertMessage: String?
@State private var showErrorAlertMessage = false
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@ -72,6 +75,14 @@ struct WebReaderContainerView: View {
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
default:
break
}
@ -153,19 +164,27 @@ struct WebReaderContainerView: View {
}.foregroundColor(.appGrayTextContrast)
}
func audioMenuItem() -> some View {
Button(
action: {
viewModel.downloadAudio(audioController: audioController, item: item)
},
label: {
Label(viewModel.isDownloadingAudio ? "Downloading Audio" : "Download Audio", systemImage: "icloud.and.arrow.down")
}
)
}
func menuItems(for item: LinkedItem) -> some View {
let hasLabels = item.labels?.count == 0
let hasHighlights = (item.highlights?.count ?? 0) > 0
return Group {
if hasHighlights {
Button(
action: { showHighlightsView = true },
label: { Label("View Highlights & Notes", systemImage: "highlighter") }
)
}
Button(
action: { showHighlightsView = true },
label: { Label("Notebook", systemImage: "highlighter") }
)
Button(
action: { showTitleEdit = true },
label: { Label("Edit Metadata", systemImage: "textbox") }
label: { Label("Edit Info", systemImage: "info.circle") }
)
Button(
action: editLabels,
@ -188,12 +207,8 @@ struct WebReaderContainerView: View {
},
label: { Label("Reset Read Location", systemImage: "arrow.counterclockwise.circle") }
)
Button(
action: {
viewModel.downloadAudio(audioController: audioController, item: item)
},
label: { Label("Download Audio", systemImage: "icloud.and.arrow.down") }
)
audioMenuItem()
if viewModel.hasOriginalUrl(item) {
Button(
action: share,
@ -330,6 +345,12 @@ struct WebReaderContainerView: View {
SafariView(url: $0.url)
}
#endif
.alert(errorAlertMessage ?? "An error occurred", isPresented: $showErrorAlertMessage) {
Button("Ok", role: .cancel, action: {
errorAlertMessage = nil
showErrorAlertMessage = false
})
}
.sheet(isPresented: $showHighlightAnnotationModal) {
HighlightAnnotationSheet(
annotation: $annotation,
@ -338,9 +359,20 @@ struct WebReaderContainerView: View {
},
onCancel: {
showHighlightAnnotationModal = false
}
},
errorAlertMessage: $errorAlertMessage,
showErrorAlertMessage: $showErrorAlertMessage
)
}
.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)
}
}
}
} else if let errorMessage = viewModel.errorMessage {
Text(errorMessage).padding()
} else {

View File

@ -12,6 +12,12 @@ struct SafariWebLink: Identifiable {
@MainActor final class WebReaderViewModel: ObservableObject {
@Published var articleContent: ArticleContent?
@Published var errorMessage: String?
@Published var isDownloadingAudio: Bool = false
@Published var audioDownloadTask: Task<Void, Error>?
deinit {
print("deinit WebReaderViewModel")
}
func hasOriginalUrl(_ item: LinkedItem) -> Bool {
if let pageURLString = item.pageURLString, let host = URL(string: pageURLString)?.host {
@ -25,9 +31,22 @@ struct SafariWebLink: Identifiable {
func downloadAudio(audioController: AudioController, item: LinkedItem) {
Snackbar.show(message: "Downloading Offline Audio")
Task {
let downloaded = await audioController.downloadForOffline(itemID: item.unwrappedID)
Snackbar.show(message: downloaded ? "Audio file downloaded" : "Error downloading audio")
isDownloadingAudio = true
if let audioDownloadTask = audioDownloadTask {
audioDownloadTask.cancel()
}
let itemID = item.unwrappedID
audioDownloadTask = Task.detached(priority: .background) {
let canceled = Task.isCancelled
let downloaded = await audioController.downloadForOffline(itemID: itemID)
DispatchQueue.main.async {
self.isDownloadingAudio = false
if !canceled {
Snackbar.show(message: downloaded ? "Audio file downloaded" : "Error downloading audio")
}
}
}
}
@ -169,4 +188,11 @@ struct SafariWebLink: Identifiable {
replyHandler(nil, "Unknown actionID: \(actionID)")
}
}
func setLabelsForHighlight(highlightID: String,
labelIDs: [String],
dataService: DataService)
{
dataService.setLabelsForHighlight(highlightID: highlightID, labelIDs: labelIDs)
}
}

View File

@ -13,6 +13,7 @@
<attribute name="shortId" attributeType="String"/>
<attribute name="suffix" optional="YES" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="labels" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItemLabel" inverseName="highlights" inverseEntity="LinkedItemLabel"/>
<relationship name="linkedItem" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="highlights" inverseEntity="LinkedItem"/>
<uniquenessConstraints>
<uniquenessConstraint>
@ -67,6 +68,7 @@
<attribute name="labelDescription" optional="YES" attributeType="String"/>
<attribute name="name" attributeType="String"/>
<attribute name="serverSyncStatus" attributeType="Integer 64" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="highlights" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Highlight" inverseName="labels" inverseEntity="Highlight"/>
<relationship name="linkedItems" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="labels" inverseEntity="LinkedItem"/>
<uniquenessConstraints>
<uniquenessConstraint>

View File

@ -120,7 +120,7 @@
}
public func stopWithError() {
stop()
pause()
playbackError = true
}
@ -667,7 +667,7 @@
@objc func fireTimer() {
if let player = player {
if player.error != nil || player.currentItem?.error != nil {
stop()
stopWithError()
}
if let durations = durations {

View File

@ -30,8 +30,13 @@ public enum VoiceCategory: String, CaseIterable {
case enSG = "English (Singapore)"
case enUK = "English (UK)"
case deDE = "German (Germany)"
case hiIN = "Hindi (India)"
case esES = "Spanish (Spain)"
case jaJP = "Japanese (Japan)"
case taIN = "Tamil (India)"
case taLK = "Tamil (Sri Lanka)"
case taMY = "Tamil (Malaysia)"
case taSG = "Tamil (Singapore)"
case zhCN = "Chinese (China Mainland)"
}
@ -62,8 +67,10 @@ public enum Voices {
English,
VoiceLanguage(key: "zh", name: "Chinese", defaultVoice: "zh-CN-XiaochenNeural", categories: [.zhCN]),
VoiceLanguage(key: "de", name: "German", defaultVoice: "de-CH-JanNeural", categories: [.deDE]),
VoiceLanguage(key: "hi", name: "Hindi", defaultVoice: "hi-IN-MadhurNeural", categories: [.hiIN]),
VoiceLanguage(key: "ja", name: "Japanese", defaultVoice: "ja-JP-NanamiNeural", categories: [.jaJP]),
VoiceLanguage(key: "es", name: "Spanish", defaultVoice: "es-ES-AlvaroNeural", categories: [.esES])
VoiceLanguage(key: "es", name: "Spanish", defaultVoice: "es-ES-AlvaroNeural", categories: [.esES]),
VoiceLanguage(key: "ta", name: "Tamil", defaultVoice: "ta-IN-PallaviNeural", categories: [.taIN, .taLK, .taMY, .taSG])
]
// swiftlint:disable all
@ -83,7 +90,12 @@ public enum Voices {
VoicePair(firstKey: "de-CH-LeniNeural", secondKey: "de-DE-KatjaNeural", firstName: "Leni", secondName: "Katja", language: "de-DE", category: .deDE),
VoicePair(firstKey: "de-DE-AmalaNeural", secondKey: "de-DE-BerndNeural", firstName: "Amala", secondName: "Bernd", language: "de-DE", category: .deDE),
VoicePair(firstKey: "de-DE-ChristophNeural", secondKey: "de-DE-LouisaNeural", firstName: "Christoph", secondName: "Louisa", language: "de-DE", category: .deDE),
VoicePair(firstKey: "ja-JP-NanamiNeural", secondKey: "ja-JP-KeitaNeural", firstName: "Nanami", secondName: "Keita", language: "ja-JP", category: .jaJP)
VoicePair(firstKey: "ja-JP-NanamiNeural", secondKey: "ja-JP-KeitaNeural", firstName: "Nanami", secondName: "Keita", language: "ja-JP", category: .jaJP),
VoicePair(firstKey: "hi-IN-MadhurNeural", secondKey: "hi-IN-SwaraNeural", firstName: "Madhur", secondName: "Swara", language: "hi-IN", category: .hiIN),
VoicePair(firstKey: "ta-IN-PallaviNeural", secondKey: "ta-IN-ValluvarNeural", firstName: "Pallavi", secondName: "Valluvar", language: "ta-IN", category: .taIN),
VoicePair(firstKey: "ta-LK-KumarNeural", secondKey: "ta-LK-SaranyaNeural", firstName: "Kumar", secondName: "Saranya", language: "ta-LK", category: .taLK),
VoicePair(firstKey: "ta-MY-KaniNeural", secondKey: "ta-MY-SuryaNeural", firstName: "Kani", secondName: "Surya", language: "ta-MY", category: .taMY),
VoicePair(firstKey: "ta-SG-AnbuNeural", secondKey: "ta-SG-VenbaNeural", firstName: "Anbu", secondName: "Venba", language: "ta-SG", category: .taSG)
]
public static let UltraPairs = [

View File

@ -6315,6 +6315,7 @@ extension Objects {
let highlightPositionAnchorIndex: [String: Int]
let highlightPositionPercent: [String: Double]
let id: [String: String]
let labels: [String: [Objects.Label]]
let patch: [String: String]
let prefix: [String: String]
let quote: [String: String]
@ -6368,6 +6369,10 @@ extension Objects.Highlight: Decodable {
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "labels":
if let value = try container.decode([Objects.Label]?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "patch":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
@ -6424,6 +6429,7 @@ extension Objects.Highlight: Decodable {
highlightPositionAnchorIndex = map["highlightPositionAnchorIndex"]
highlightPositionPercent = map["highlightPositionPercent"]
id = map["id"]
labels = map["labels"]
patch = map["patch"]
prefix = map["prefix"]
quote = map["quote"]
@ -6537,6 +6543,22 @@ extension Fields where TypeLock == Objects.Highlight {
}
}
func labels<Type>(selection: Selection<Type, [Objects.Label]?>) throws -> Type {
let field = GraphQLField.composite(
name: "labels",
arguments: [],
selection: selection.selection
)
select(field)
switch response {
case let .decoding(data):
return try selection.decode(data: data.labels[field.alias!])
case .mocking:
return selection.mock()
}
}
func patch() throws -> String {
let field = GraphQLField.leaf(
name: "patch",

View File

@ -21,7 +21,8 @@ extension DataService {
annotation: annotation,
createdAt: nil,
updatedAt: nil,
createdByMe: true
createdByMe: true,
labels: []
)
internalHighlight.persist(context: backgroundContext, associatedItemID: articleId)

View File

@ -23,7 +23,8 @@ extension DataService {
annotation: nil,
createdAt: nil,
updatedAt: nil,
createdByMe: true
createdByMe: true,
labels: []
)
internalHighlight.persist(

View File

@ -0,0 +1,72 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func setLabelsForHighlight(highlightID: String, labelIDs: [String]) {
backgroundContext.perform { [weak self] in
guard let self = self else { return }
guard let highlight = Highlight.lookup(byID: highlightID, inContext: self.backgroundContext) else { return }
if let existingLabels = highlight.labels {
highlight.removeFromLabels(existingLabels)
}
for labelID in labelIDs {
if let labelObject = LinkedItemLabel.lookup(byID: labelID, inContext: self.backgroundContext) {
highlight.addToLabels(labelObject)
}
}
// Send update to server
self.syncHighlightLabelUpdates(highlightID: highlightID, labelIDs: labelIDs)
}
}
func syncHighlightLabelUpdates(highlightID: String, labelIDs: [String]) {
enum MutationResult {
case saved(labels: [InternalLinkedItemLabel])
case error(errorCode: Enums.SetLabelsErrorCode)
}
let selection = Selection<MutationResult, Unions.SetLabelsResult> {
try $0.on(
setLabelsError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) },
setLabelsSuccess: .init { .saved(labels: try $0.labels(selection: highlightLabelSelection.list)) }
)
}
let mutation = Selection.Mutation {
try $0.setLabelsForHighlight(
input: InputObjects.SetLabelsForHighlightInput(
highlightId: highlightID,
labelIds: labelIDs
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
let context = backgroundContext
send(mutation, to: path, headers: headers) { result in
let data = try? result.get()
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
context.perform {
guard let highlight = Highlight.lookup(byID: highlightID, inContext: context) else { return }
highlight.serverSyncStatus = Int64(syncStatus.rawValue)
do {
try context.save()
logger.debug("Highlight labels updated succesfully")
} catch {
context.rollback()
logger.debug("Failed to update highlight labels: \(error.localizedDescription)")
}
}
}
}
}

View File

@ -1,6 +1,16 @@
import Models
import SwiftGraphQL
let highlightLabelSelection = Selection.Label {
InternalLinkedItemLabel(
id: try $0.id(),
name: try $0.name(),
color: try $0.color(),
createdAt: try $0.createdAt()?.value,
labelDescription: try $0.description()
)
}
let highlightSelection = Selection.Highlight {
InternalHighlight(
id: try $0.id(),
@ -12,6 +22,7 @@ let highlightSelection = Selection.Highlight {
annotation: try $0.annotation(),
createdAt: try $0.createdAt().value,
updatedAt: try $0.updatedAt().value,
createdByMe: try $0.createdByMe()
createdByMe: try $0.createdByMe(),
labels: try $0.labels(selection: highlightLabelSelection.list.nullable) ?? []
)
}

View File

@ -13,6 +13,7 @@ struct InternalHighlight: Encodable {
let createdAt: Date?
let updatedAt: Date?
let createdByMe: Bool
var labels: [InternalLinkedItemLabel]
func asManagedObject(context: NSManagedObjectContext) -> Highlight {
let fetchRequest: NSFetchRequest<Models.Highlight> = Highlight.fetchRequest()
@ -33,6 +34,15 @@ struct InternalHighlight: Encodable {
highlight.createdAt = createdAt
highlight.updatedAt = updatedAt
highlight.createdByMe = createdByMe
if let existingLabels = highlight.labels {
highlight.removeFromLabels(existingLabels)
}
for label in labels {
highlight.addToLabels(label.asManagedObject(inContext: context))
}
return highlight
}
@ -47,7 +57,8 @@ struct InternalHighlight: Encodable {
annotation: highlight.annotation,
createdAt: highlight.createdAt,
updatedAt: highlight.updatedAt,
createdByMe: highlight.createdByMe
createdByMe: highlight.createdByMe,
labels: InternalLinkedItemLabel.make(highlight.labels)
)
}

View File

@ -2,7 +2,7 @@ import CoreData
import Foundation
import Models
struct InternalLinkedItemLabel {
public struct InternalLinkedItemLabel: Encodable {
let id: String
let name: String
let color: String
@ -38,6 +38,22 @@ struct InternalLinkedItemLabel {
label.labelDescription = labelDescription
return label
}
public static func make(_ labels: NSSet?) -> [InternalLinkedItemLabel] {
labels?
.compactMap { label in
if let label = label as? LinkedItemLabel {
return InternalLinkedItemLabel(
id: label.id ?? "",
name: label.name ?? "",
color: label.color ?? "",
createdAt: label.createdAt,
labelDescription: label.labelDescription
)
}
return nil
} ?? []
}
}
extension LinkedItemLabel {

View File

@ -3,6 +3,8 @@ import SwiftUI
public struct HighlightAnnotationSheet: View {
@Binding var annotation: String
@Binding var errorAlertMessage: String?
@Binding var showErrorAlertMessage: Bool
let onSave: () -> Void
let onCancel: () -> Void
@ -10,11 +12,15 @@ public struct HighlightAnnotationSheet: View {
public init(
annotation: Binding<String>,
onSave: @escaping () -> Void,
onCancel: @escaping () -> Void
onCancel: @escaping () -> Void,
errorAlertMessage: Binding<String?>,
showErrorAlertMessage: Binding<Bool>
) {
self._annotation = annotation
self.onSave = onSave
self.onCancel = onCancel
self._errorAlertMessage = errorAlertMessage
self._showErrorAlertMessage = showErrorAlertMessage
}
public var body: some View {
@ -43,5 +49,11 @@ public struct HighlightAnnotationSheet: View {
Spacer()
}
.padding()
.alert(errorAlertMessage ?? "An error occurred", isPresented: $showErrorAlertMessage) {
Button("Ok", role: .cancel, action: {
errorAlertMessage = nil
showErrorAlertMessage = false
})
}
}
}

View File

@ -208,10 +208,12 @@ public final class OmnivoreWebView: WKWebView {
// on iOS16 we use menuBuilder to create these items
currentMenu = .defaultMenu
let annotate = UIMenuItem(title: "Annotate", action: #selector(annotateSelection))
let labels = UIMenuItem(title: "Labels", action: #selector(setLabels))
let remove = UIMenuItem(title: "Remove", action: #selector(removeSelection))
// let share = UIMenuItem(title: "Share", action: #selector(shareSelection))
UIMenuController.shared.menuItems = [remove, /* share, */ annotate]
UIMenuController.shared.menuItems = [remove, labels, annotate]
}
}
@ -235,6 +237,7 @@ public final class OmnivoreWebView: WKWebView {
case #selector(shareSelection): return true
case #selector(removeSelection): return true
case #selector(copy(_:)): return true
case #selector(setLabels(_:)): return true
case Selector(("_lookup:")): return true
case Selector(("_define:")): return true
case Selector(("_findSelected:")): return true
@ -292,15 +295,25 @@ public final class OmnivoreWebView: WKWebView {
hideMenu()
}
@objc public func setLabels(_: Any?) {
do {
try dispatchEvent(.setHighlightLabels)
} catch {
showErrorInSnackbar("Error setting labels for highlight")
}
hideMenu()
}
override public func buildMenu(with builder: UIMenuBuilder) {
if #available(iOS 16.0, *) {
let annotate = UICommand(title: "Note", action: #selector(annotateSelection))
let highlight = UICommand(title: "Highlight", action: #selector(highlightSelection))
let remove = UICommand(title: "Remove", action: #selector(removeSelection))
let setLabels = UICommand(title: "Labels", action: #selector(setLabels))
let omnivore = UIMenu(title: "",
options: .displayInline,
children: currentMenu == .defaultMenu ? [highlight, annotate] : [annotate, remove])
children: currentMenu == .defaultMenu ? [highlight, annotate] : [annotate, setLabels, remove])
builder.insertSibling(omnivore, beforeMenu: .lookup)
}
@ -362,6 +375,7 @@ public enum WebViewDispatchEvent {
case highlight
case share
case remove
case setHighlightLabels
case copyHighlight
case dismissHighlight
case speakingSection(anchorIdx: String)
@ -399,6 +413,8 @@ public enum WebViewDispatchEvent {
return "share"
case .remove:
return "remove"
case .setHighlightLabels:
return "setHighlightLabels"
case .copyHighlight:
return "copyHighlight"
case .dismissHighlight:
@ -435,7 +451,7 @@ public enum WebViewDispatchEvent {
}
case let .speakingSection(anchorIdx: anchorIdx):
return "event.anchorIdx = '\(anchorIdx)';"
case .annotate, .highlight, .share, .remove, .copyHighlight, .dismissHighlight:
case .annotate, .highlight, .setHighlightLabels, .share, .remove, .copyHighlight, .dismissHighlight:
return ""
}
}

View File

@ -45,15 +45,13 @@ public struct GridCard: View {
var contextMenuView: some View {
Group {
if (item.highlights?.count ?? 0) > 0 {
Button(
action: { menuActionHandler(.viewHighlights) },
label: { Label("View Highlights & Notes", systemImage: "highlighter") }
)
}
Button(
action: { menuActionHandler(.viewHighlights) },
label: { Label("Notebook", systemImage: "highlighter") }
)
Button(
action: { menuActionHandler(.editTitle) },
label: { Label("Edit Metadata", systemImage: "textbox") }
label: { Label("Edit Info", systemImage: "info.circle") }
)
Button(
action: { menuActionHandler(.editLabels) },

View File

@ -23,7 +23,7 @@ public extension Font {
}
static var textToSpeechRead: Font {
Font.custom(InterFont.bold.rawValue, size: 24, relativeTo: .title2)
Font.system(size: 24, weight: .bold)
}
static var appNavbarIcon: Font {

File diff suppressed because one or more lines are too long

View File

@ -58,7 +58,7 @@ public struct TextChip: View {
}
var backgroundColor: Color {
color.opacity(colorScheme == .dark ? 0.08 : 1)
color.opacity(colorScheme == .dark ? 0.2 : 1)
}
var borderColor: Color {
@ -73,13 +73,19 @@ public struct TextChip: View {
ZStack(alignment: .topTrailing) {
Text(text)
.strikethrough(color: negated ? textColor : .clear)
.padding(.horizontal, 10)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.font(.appCaptionBold)
.foregroundColor(textColor)
.lineLimit(1)
.background(Capsule().fill(backgroundColor))
.overlay(Capsule().stroke(borderColor, lineWidth: 1))
.background(
RoundedRectangle(cornerRadius: 4)
.fill(backgroundColor)
)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(borderColor, lineWidth: 1)
)
.padding(1)
.overlay(alignment: .topTrailing) {
if checked {

View File

@ -41,7 +41,7 @@ export function LabelChip(props: LabelChipProps): JSX.Element {
css={{
display: 'inline-table',
margin: '4px',
borderRadius: '32px',
borderRadius: '4px',
color: isDarkMode ? darkThemeTextColor : lightThemeTextColor,
fontSize: '12px',
fontWeight: 'bold',
@ -49,7 +49,9 @@ export function LabelChip(props: LabelChipProps): JSX.Element {
whiteSpace: 'nowrap',
cursor: 'pointer',
backgroundClip: 'padding-box',
border: isDarkMode ? `1px solid ${darkThemeTextColor}` :`1px solid rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.7)`,
border: isDarkMode
? `1px solid ${darkThemeTextColor}`
: `1px solid rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.7)`,
backgroundColor: isDarkMode
? `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.08)`
: props.color,

View File

@ -22,6 +22,7 @@ export type HighlightAction =
| 'share'
| 'post'
| 'unshare'
| 'setHighlightLabels'
type HighlightBarProps = {
anchorCoordinates: PageCoordinates
@ -133,10 +134,7 @@ function BarContent(props: HighlightBarProps): JSX.Element {
css={{ color: '$readerFont', height: '100%', m: 0, p: 0 }}
>
<HStack css={{ height: '100%', alignItems: 'center' }}>
<Trash
size={24}
color={theme.colors.omnivoreRed.toString()}
/>
<Trash size={24} color={theme.colors.omnivoreRed.toString()} />
<StyledText
style="body"
css={{
@ -157,7 +155,7 @@ function BarContent(props: HighlightBarProps): JSX.Element {
style="plainIcon"
title="Add Note to Highlight"
onClick={() => props.handleButtonClick('comment')}
css={{ color: '$readerFont', height: '100%', m: 0, p: 0}}
css={{ color: '$readerFont', height: '100%', m: 0, p: 0 }}
>
<HStack css={{ height: '100%', alignItems: 'center' }}>
<Note size={24} color={theme.colors.readerFont.toString()} />

View File

@ -196,6 +196,10 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
props.articleMutations
)
if (result.errorMessage) {
throw 'Failed to create highlight: ' + result.errorMessage
}
if (!result.highlights || result.highlights.length == 0) {
// TODO: show an error message
console.error('Failed to create highlight')
@ -218,26 +222,18 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
if (!selectionData) {
return
}
const result = await createHighlightFromSelection(
selectionData,
annotation
)
if (!result) {
showErrorToast('Error saving highlight', { position: 'bottom-right' })
try {
const result = await createHighlightFromSelection(
selectionData,
annotation
)
if (!result) {
showErrorToast('Error saving highlight', { position: 'bottom-right' })
throw 'Error creating highlight'
}
} catch (error) {
throw error
}
// if (successAction === 'share' && canShareNative) {
// handleNativeShare(highlight.shortId)
// return
// } else {
// setFocusedHighlight(undefined)
// }
// if (successAction === 'addComment') {
// openNoteModal({
// highlightModalAction: 'addComment',
// highlight,
// })
// }
},
[
handleNativeShare,
@ -328,13 +324,13 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
}, [handleClickHighlight])
const handleAction = useCallback(
(action: HighlightAction) => {
async (action: HighlightAction) => {
switch (action) {
case 'delete':
removeHighlightCallback()
await removeHighlightCallback()
break
case 'create':
createHighlightCallback('none')
await createHighlightCallback('none')
break
case 'comment':
if (props.highlightBarDisabled || focusedHighlight) {
@ -375,12 +371,20 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
})
}
} else {
createHighlightCallback('share')
await createHighlightCallback('share')
}
break
case 'unshare':
console.log('unshare')
break // TODO: implement -- need to show confirmation dialog
case 'setHighlightLabels':
if (props.isAppleAppEmbed) {
window?.webkit?.messageHandlers.highlightAction?.postMessage({
actionID: 'setHighlightLabels',
highlightID: focusedHighlight?.id,
})
}
break
}
},
[
@ -395,27 +399,59 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
]
)
const dispatchHighlightError = (action: string, error: unknown) => {
if (props.isAppleAppEmbed) {
window?.webkit?.messageHandlers.highlightAction?.postMessage({
actionID: 'highlightError',
highlightAction: action,
highlightID: focusedHighlight?.id,
error: typeof error === 'string' ? error : JSON.stringify(error),
})
}
}
const dispatchHighlightMessage = (actionID: string) => {
if (props.isAppleAppEmbed) {
window?.webkit?.messageHandlers.highlightAction?.postMessage({
actionID: actionID,
highlightID: focusedHighlight?.id,
})
}
}
useEffect(() => {
const annotate = () => {
handleAction('comment')
const safeHandleAction = async (action: HighlightAction) => {
try {
await handleAction(action)
} catch (error) {
dispatchHighlightError(action, error)
}
}
const highlight = () => {
handleAction('create')
const annotate = async () => {
await safeHandleAction('comment')
}
const share = () => {
handleAction('share')
const highlight = async () => {
await safeHandleAction('create')
}
const remove = () => {
handleAction('delete')
const share = async () => {
await safeHandleAction('share')
}
const remove = async () => {
await safeHandleAction('delete')
}
const dismissHighlight = () => {
setFocusedHighlight(undefined)
}
const setHighlightLabels = () => {
handleAction('setHighlightLabels')
}
const copy = async () => {
if (focusedHighlight) {
await navigator.clipboard.writeText(focusedHighlight.quote)
@ -453,10 +489,20 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
'failed to change annotation for highlight with id',
focusedHighlight.id
)
dispatchHighlightError(
'saveAnnotation',
'Failed to create highlight.'
)
}
setFocusedHighlight(undefined)
dispatchHighlightMessage('noteCreated')
} else {
createHighlightCallback('none', event.annotation)
try {
await createHighlightCallback('none', event.annotation)
dispatchHighlightMessage('noteCreated')
} catch (error) {
dispatchHighlightError('saveAnnotation', error)
}
}
}
@ -468,6 +514,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
document.addEventListener('dismissHighlight', dismissHighlight)
document.addEventListener('saveAnnotation', saveAnnotation)
document.addEventListener('speakingSection', speakingSection)
document.addEventListener('setHighlightLabels', setHighlightLabels)
return () => {
document.removeEventListener('annotate', annotate)
@ -478,6 +525,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
document.removeEventListener('dismissHighlight', dismissHighlight)
document.removeEventListener('saveAnnotation', saveAnnotation)
document.removeEventListener('speakingSection', speakingSection)
document.removeEventListener('setHighlightLabels', setHighlightLabels)
}
})