mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #3037 from omnivore-app/feat/ios-extension-cleanup
iOS Extension and notes cleanup
This commit is contained in:
commit
356a32727a
58 changed files with 1421 additions and 838 deletions
|
|
@ -3,11 +3,17 @@ import Utils
|
|||
import Views
|
||||
|
||||
public extension PlatformViewController {
|
||||
static func makeShareExtensionController(extensionContext: NSExtensionContext?) -> PlatformViewController {
|
||||
static func makeShareExtensionController(
|
||||
viewModel: ShareExtensionViewModel,
|
||||
labelsViewModel: LabelsViewModel,
|
||||
extensionContext: NSExtensionContext?
|
||||
) -> PlatformViewController {
|
||||
registerFonts()
|
||||
|
||||
let hostingController = PlatformHostingController(
|
||||
rootView: ShareExtensionView(extensionContext: extensionContext)
|
||||
rootView: ShareExtensionView(viewModel: viewModel,
|
||||
labelsViewModel: labelsViewModel,
|
||||
extensionContext: extensionContext)
|
||||
)
|
||||
#if os(iOS)
|
||||
hostingController.view.layer.cornerRadius = 12
|
||||
|
|
|
|||
|
|
@ -9,14 +9,18 @@ public class ShareExtensionViewModel: ObservableObject {
|
|||
@Published public var status: ShareExtensionStatus = .processing
|
||||
@Published public var title: String = ""
|
||||
@Published public var url: String?
|
||||
@Published public var iconURL: URL?
|
||||
@Published public var highlightData: HighlightData?
|
||||
@Published public var linkedItem: LinkedItem?
|
||||
@Published public var requestId = UUID().uuidString.lowercased()
|
||||
@Published var debugText: String?
|
||||
@Published var noteText: String = ""
|
||||
|
||||
let services = Services()
|
||||
let queue = OperationQueue()
|
||||
|
||||
public init() {}
|
||||
|
||||
func handleReadNowAction(extensionContext: NSExtensionContext?) {
|
||||
#if os(iOS)
|
||||
if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication {
|
||||
|
|
@ -60,6 +64,22 @@ public class ShareExtensionViewModel: ObservableObject {
|
|||
)
|
||||
}
|
||||
|
||||
func saveNote() {
|
||||
if let linkedItem = linkedItem {
|
||||
if let noteHighlight = linkedItem.noteHighlight, let noteHighlightID = noteHighlight.id {
|
||||
services.dataService.updateHighlightAttributes(highlightID: noteHighlightID, annotation: noteText)
|
||||
} else {
|
||||
let createdHighlightId = UUID().uuidString.lowercased()
|
||||
let createdShortId = NanoID.generate(alphabet: NanoID.Alphabet.urlSafe.rawValue, size: 8)
|
||||
|
||||
_ = services.dataService.createNote(shortId: createdShortId,
|
||||
highlightID: createdHighlightId,
|
||||
articleId: linkedItem.unwrappedID,
|
||||
annotation: noteText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func queueSaveOperation(_ payload: PageScrapePayload) {
|
||||
ProcessInfo().performExpiringActivity(withReason: "app.omnivore.SaveActivity") { [self] expiring in
|
||||
|
|
@ -88,9 +108,10 @@ public class ShareExtensionViewModel: ObservableObject {
|
|||
let hostname = URL(string: payload.url)?.host ?? ""
|
||||
|
||||
switch payload.contentType {
|
||||
case let .html(html: _, title: title, highlightData: highlightData):
|
||||
case let .html(html: _, title: title, iconURL: iconURL, highlightData: highlightData):
|
||||
self.title = title ?? ""
|
||||
self.url = hostname
|
||||
self.iconURL = iconURL
|
||||
self.highlightData = highlightData
|
||||
case .none:
|
||||
self.url = hostname
|
||||
|
|
@ -145,7 +166,7 @@ public class ShareExtensionViewModel: ObservableObject {
|
|||
localPdfURL: localUrl,
|
||||
url: pageScrapePayload.url
|
||||
)
|
||||
case let .html(html, title, _):
|
||||
case let .html(html, title, _, _):
|
||||
newRequestID = try await services.dataService.createPage(
|
||||
id: requestId,
|
||||
originalHtml: html,
|
||||
|
|
@ -187,7 +208,21 @@ public class ShareExtensionViewModel: ObservableObject {
|
|||
if let title = self.linkedItem?.title {
|
||||
self.title = title
|
||||
}
|
||||
self.url = self.linkedItem?.pageURLString
|
||||
if let iconURL = self.linkedItem?.imageURL {
|
||||
self.iconURL = iconURL
|
||||
}
|
||||
if let noteHighlight = self.linkedItem?.highlights?
|
||||
.compactMap({ $0 as? Highlight })
|
||||
.first(where: { $0.type == "NOTE" }),
|
||||
let noteText = noteHighlight.annotation
|
||||
{
|
||||
self.noteText = noteText
|
||||
}
|
||||
if let urlStr = self.linkedItem?.pageURLString, let hostname = URL(string: urlStr)?.host {
|
||||
self.url = hostname
|
||||
} else {
|
||||
self.url = self.linkedItem?.pageURLString
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
//
|
||||
// AddNoteSheet.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 10/26/23.
|
||||
//
|
||||
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
public struct AddNoteSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@StateObject var viewModel: ShareExtensionViewModel
|
||||
|
||||
enum FocusField: Hashable {
|
||||
case noteEditor
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: FocusField?
|
||||
|
||||
public init(viewModel: ShareExtensionViewModel) {
|
||||
_viewModel = StateObject(wrappedValue: viewModel)
|
||||
UITextView.appearance().textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 10, right: 4)
|
||||
}
|
||||
|
||||
func saveNote() {
|
||||
viewModel.saveNote()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
NavigationView {
|
||||
TextEditor(text: $viewModel.noteText)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.focused($focusedField, equals: .noteEditor)
|
||||
.task {
|
||||
self.focusedField = .noteEditor
|
||||
}
|
||||
.background(Color.extensionBackground)
|
||||
.navigationTitle("Add Note")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(leading: Button(action: {
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text("Cancel")
|
||||
}))
|
||||
.navigationBarItems(trailing: Button(action: {
|
||||
saveNote()
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text("Save").bold()
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
//
|
||||
// EditInfoSheet.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 10/30/23.
|
||||
//
|
||||
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
public struct EditInfoSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@StateObject var viewModel: ShareExtensionViewModel
|
||||
let highlightId = UUID().uuidString.lowercased()
|
||||
let shortId = NanoID.generate(alphabet: NanoID.Alphabet.urlSafe.rawValue, size: 8)
|
||||
|
||||
enum FocusField: Hashable {
|
||||
case noteEditor
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: FocusField?
|
||||
|
||||
public init(viewModel: ShareExtensionViewModel) {
|
||||
_viewModel = StateObject(wrappedValue: viewModel)
|
||||
UITextView.appearance().textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 10, right: 4)
|
||||
}
|
||||
|
||||
// func saveInfo() {
|
||||
// if let linkedItem = viewModel.linkedItem {
|
||||
// _ = viewModel.services.dataService.updateLinkedItemTitleAndDescription(itemID: linkedItem.unwrappedID, title: title, description: description, author: author)
|
||||
// } else {
|
||||
// // Maybe we shouldn't even allow this UI without linkeditem existing
|
||||
// }
|
||||
// }
|
||||
|
||||
public var body: some View {
|
||||
if let item = viewModel.linkedItem {
|
||||
LinkedItemMetadataEditView(item: item) { title, _ in
|
||||
viewModel.title = title
|
||||
}
|
||||
.environmentObject(viewModel.services.dataService)
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
//
|
||||
// EditLabelsSheet.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 10/27/23.
|
||||
//
|
||||
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
@MainActor
|
||||
public struct EditLabelsSheet: View {
|
||||
@State var text = ""
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
@StateObject var labelsViewModel: LabelsViewModel
|
||||
@StateObject var viewModel: ShareExtensionViewModel
|
||||
|
||||
enum FocusField: Hashable {
|
||||
case noteEditor
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: FocusField?
|
||||
|
||||
public init(viewModel: ShareExtensionViewModel, labelsViewModel: LabelsViewModel) {
|
||||
_viewModel = StateObject(wrappedValue: viewModel)
|
||||
_labelsViewModel = StateObject(wrappedValue: labelsViewModel)
|
||||
|
||||
UITextView.appearance().textContainerInset = UIEdgeInsets(top: 5, left: 2, bottom: 5, right: 2)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func onLabelTap(label: LinkedItemLabel, textChip _: TextChip) {
|
||||
if let idx = labelsViewModel.selectedLabels.firstIndex(of: label) {
|
||||
labelsViewModel.selectedLabels.remove(at: idx)
|
||||
} else {
|
||||
labelsViewModel.labelSearchFilter = ZWSP
|
||||
labelsViewModel.selectedLabels.append(label)
|
||||
}
|
||||
|
||||
if let linkedItem = viewModel.linkedItem {
|
||||
labelsViewModel.saveItemLabelChanges(itemID: linkedItem.unwrappedID, dataService: viewModel.services.dataService)
|
||||
}
|
||||
}
|
||||
|
||||
func isSelected(_ label: LinkedItemLabel) -> Bool {
|
||||
labelsViewModel.selectedLabels.contains(where: { $0.id == label.id })
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
VStack {
|
||||
LabelsEntryView(
|
||||
searchTerm: $labelsViewModel.labelSearchFilter,
|
||||
viewModel: labelsViewModel
|
||||
)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 20)
|
||||
|
||||
if labelsViewModel.labelSearchFilter.count >= 63 {
|
||||
Text("The maximum length of a label is 64 chars.").foregroundColor(Color.red).font(.footnote)
|
||||
}
|
||||
|
||||
List {
|
||||
ForEach(labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter), id: \.self) { label in
|
||||
Button(
|
||||
action: {
|
||||
if let idx = labelsViewModel.selectedLabels.firstIndex(of: label) {
|
||||
labelsViewModel.selectedLabels.remove(at: idx)
|
||||
} else {
|
||||
labelsViewModel.labelSearchFilter = ZWSP
|
||||
labelsViewModel.selectedLabels.append(label)
|
||||
}
|
||||
},
|
||||
label: {
|
||||
HStack {
|
||||
TextChip(feedItemLabel: label).allowsHitTesting(false)
|
||||
Spacer()
|
||||
if isSelected(label) {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
)
|
||||
.padding(.vertical, 5)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if os(macOS)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.background(Color.extensionBackground)
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
NavigationView {
|
||||
content
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.extensionBackground)
|
||||
.navigationTitle("Set Labels")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(trailing: Button(action: {
|
||||
if let linkedItem = viewModel.linkedItem, let linkedItemId = linkedItem.id {
|
||||
labelsViewModel.saveItemLabelChanges(
|
||||
itemID: linkedItemId,
|
||||
dataService: viewModel.services.dataService
|
||||
)
|
||||
}
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text("Done").bold()
|
||||
}))
|
||||
}
|
||||
.environmentObject(viewModel.services.dataService)
|
||||
.task {
|
||||
await labelsViewModel.loadLabelsFromStore(dataService: viewModel.services.dataService)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,11 +7,9 @@ import Views
|
|||
// swiftlint:disable file_length type_body_length
|
||||
public struct ShareExtensionView: View {
|
||||
let extensionContext: NSExtensionContext?
|
||||
@StateObject var labelsViewModel = LabelsViewModel()
|
||||
@StateObject private var viewModel = ShareExtensionViewModel()
|
||||
@StateObject var viewModel: ShareExtensionViewModel
|
||||
@StateObject var labelsViewModel: LabelsViewModel
|
||||
|
||||
@State var reminderTime: ReminderTime?
|
||||
@State var hideUntilReminded = false
|
||||
@State var previousLabels: [LinkedItemLabel]?
|
||||
@State var messageText: String?
|
||||
@State var showSearchLabels = false
|
||||
|
|
@ -19,6 +17,8 @@ public struct ShareExtensionView: View {
|
|||
@State var viewState = ViewState.mainView
|
||||
@State var showHighlightInstructionAlert = false
|
||||
|
||||
@State var showAddNoteModal = false
|
||||
|
||||
enum FocusField: Hashable {
|
||||
case titleEditor
|
||||
}
|
||||
|
|
@ -32,36 +32,13 @@ public struct ShareExtensionView: View {
|
|||
|
||||
@FocusState private var focusedField: FocusField?
|
||||
|
||||
private func handleReminderTimeSelection(_ selectedTime: ReminderTime) {
|
||||
if selectedTime == reminderTime {
|
||||
reminderTime = nil
|
||||
hideUntilReminded = false
|
||||
} else {
|
||||
reminderTime = selectedTime
|
||||
hideUntilReminded = true
|
||||
}
|
||||
}
|
||||
|
||||
private var titleText: String {
|
||||
switch viewModel.status {
|
||||
case .saved, .synced, .syncFailed(error: _):
|
||||
return "Saved to Omnivore"
|
||||
case .processing:
|
||||
return "Saving to Omnivore"
|
||||
case .failed(error: _):
|
||||
return "Error saving to Omnivore"
|
||||
}
|
||||
}
|
||||
|
||||
private var titleColor: Color {
|
||||
switch viewModel.status {
|
||||
case .saved, .processing:
|
||||
return .appGrayText
|
||||
case .failed(error: _), .syncFailed(error: _):
|
||||
return .red
|
||||
case .synced:
|
||||
return .appGreenSuccess
|
||||
}
|
||||
public init(viewModel: ShareExtensionViewModel,
|
||||
labelsViewModel: LabelsViewModel,
|
||||
extensionContext: NSExtensionContext?)
|
||||
{
|
||||
_viewModel = StateObject(wrappedValue: viewModel)
|
||||
_labelsViewModel = StateObject(wrappedValue: labelsViewModel)
|
||||
self.extensionContext = extensionContext
|
||||
}
|
||||
|
||||
private func localImage(from url: URL) -> Image? {
|
||||
|
|
@ -86,233 +63,111 @@ public struct ShareExtensionView: View {
|
|||
}
|
||||
}
|
||||
|
||||
var titleBar: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
var articleInfoBox: some View {
|
||||
HStack(alignment: .top, spacing: 15) {
|
||||
AsyncImage(url: self.viewModel.iconURL) { phase in
|
||||
if let image = phase.image {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 56, height: 56)
|
||||
} else {
|
||||
Color.appButtonBackground
|
||||
.frame(width: 56, height: 56)
|
||||
}
|
||||
}
|
||||
.frame(width: 56, height: 56).overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(.white, lineWidth: 1)
|
||||
).cornerRadius(14)
|
||||
VStack(alignment: .leading) {
|
||||
Text(self.viewModel.url ?? "")
|
||||
.font(Font.system(size: 12))
|
||||
.lineLimit(1)
|
||||
.foregroundColor(Color.extensionTextSubtle)
|
||||
.frame(height: 14)
|
||||
|
||||
Text(self.viewModel.title)
|
||||
.font(Font.system(size: 13, weight: .semibold))
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(2)
|
||||
.frame(height: 33)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}.padding(.vertical, 2)
|
||||
// Spacer()
|
||||
Image(systemName: "checkmark.circle")
|
||||
.frame(width: 15, height: 15)
|
||||
.foregroundColor(.appGreenSuccess)
|
||||
.opacity(isSynced ? 1.0 : 0.0)
|
||||
|
||||
Text(messageText ?? titleText)
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(titleColor)
|
||||
|
||||
Spacer()
|
||||
// .opacity(isSynced ? 1.0 : 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
public var titleBox: some View {
|
||||
VStack(alignment: .trailing) {
|
||||
Button(action: {}, label: {
|
||||
Text("Edit")
|
||||
.font(.appFootnote)
|
||||
.padding(.trailing, 8)
|
||||
.onTapGesture {
|
||||
viewState = .editingTitle
|
||||
}
|
||||
})
|
||||
.disabled(viewState == .editingTitle)
|
||||
.opacity(viewState == .editingTitle ? 0.0 : 1.0)
|
||||
var hasNoteText: Bool {
|
||||
!viewModel.noteText.isEmpty
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
if viewState != .editingTitle {
|
||||
Text(self.viewModel.title)
|
||||
.font(.appSubheadline)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(self.viewModel.url ?? "")
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(.appGrayText)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: 60)
|
||||
.padding()
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.appGrayBorder, lineWidth: 1)
|
||||
var noteBox: some View {
|
||||
Button(action: {
|
||||
NotificationCenter.default.post(name: Notification.Name("ShowAddNoteSheet"), object: nil)
|
||||
}, label: {
|
||||
Text(hasNoteText ? viewModel.noteText : "Add note...")
|
||||
.frame(minHeight: 50, alignment: .top)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
})
|
||||
.foregroundColor(hasNoteText ?
|
||||
Color.appGrayTextContrast : Color.extensionTextSubtle
|
||||
)
|
||||
}
|
||||
.font(Font.system(size: 13, weight: .semibold))
|
||||
.frame(height: 50, alignment: .top)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
var labelsSection: some View {
|
||||
HStack {
|
||||
if viewState != .editingLabels {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color.blue)
|
||||
.frame(width: 34, height: 34)
|
||||
|
||||
Image(systemName: "tag")
|
||||
.font(.appCallout)
|
||||
.frame(width: 34, height: 34)
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
|
||||
VStack {
|
||||
Text(LocalText.labelsGeneric)
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
let labelCount = labelsViewModel.selectedLabels.count
|
||||
Text(labelCount > 0 ?
|
||||
"\(labelCount) label\(labelCount > 1 ? "s" : "") selected"
|
||||
: "Add labels to your saved link")
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(Color.appGrayText)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.appCallout)
|
||||
} else {
|
||||
VStack(spacing: 15) {
|
||||
SearchBar(searchTerm: $labelsViewModel.labelSearchFilter)
|
||||
|
||||
// swiftlint:disable line_length
|
||||
ScrollView {
|
||||
LabelsMasonaryView(labels: labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter),
|
||||
selectedLabels: labelsViewModel.selectedLabels.applySearchFilter(labelsViewModel.labelSearchFilter),
|
||||
onLabelTap: onLabelTap)
|
||||
Button(
|
||||
action: { labelsViewModel.showCreateLabelModal = true },
|
||||
label: {
|
||||
HStack {
|
||||
let trimmedLabelName = labelsViewModel.labelSearchFilter.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
Image(systemName: "tag").foregroundColor(.blue)
|
||||
Text(
|
||||
labelsViewModel.labelSearchFilter.count > 0 ?
|
||||
"Create: \"\(trimmedLabelName)\" label" :
|
||||
LocalText.createLabelMessage
|
||||
).foregroundColor(.blue)
|
||||
.font(Font.system(size: 14))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.padding(10)
|
||||
}.background(Color.appButtonBackground)
|
||||
// swiftlint:enable line_length
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(viewState == .editingLabels ? 0 : 16)
|
||||
.background(viewState == .editingLabels ? Color.clear : Color.appButtonBackground)
|
||||
.frame(maxWidth: .infinity, maxHeight: viewState == .editingLabels ? .infinity : 60)
|
||||
.cornerRadius(8)
|
||||
var labelsBox: some View {
|
||||
Button(action: {
|
||||
NotificationCenter.default.post(name: Notification.Name("ShowEditLabelsSheet"), object: nil)
|
||||
}, label: {
|
||||
Label {
|
||||
Text("Add Labels").font(Font.system(size: 12, weight: .medium)).tint(Color.white)
|
||||
} icon: {
|
||||
Image.label.resizable(resizingMode: .stretch).frame(width: 17, height: 17).tint(Color.white)
|
||||
}.padding(.leading, 10).padding(.trailing, 12)
|
||||
})
|
||||
.frame(height: 28)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(24)
|
||||
}
|
||||
|
||||
var highlightSection: some View {
|
||||
HStack {
|
||||
if viewState != .viewingHighlight {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color.appBackground)
|
||||
.frame(width: 34, height: 34)
|
||||
var infoBox: some View {
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
articleInfoBox
|
||||
|
||||
Image(systemName: "highlighter")
|
||||
.font(.appCallout)
|
||||
.frame(width: 34, height: 34)
|
||||
.foregroundColor(Color.black)
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
Divider()
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 1)
|
||||
.background(Color(hex: "545458")?.opacity(0.65))
|
||||
|
||||
VStack {
|
||||
Text(LocalText.genericHighlight)
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
noteBox
|
||||
|
||||
Text(viewModel.highlightData != nil ?
|
||||
viewModel.highlightData!.highlightText
|
||||
: "Select text before saving to create highlight")
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(Color.appGrayText)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.appCallout)
|
||||
} else if let highlightText = self.viewModel.highlightData?.highlightText {
|
||||
Text(highlightText)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.cornerRadius(8)
|
||||
.padding(0)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, maxHeight: viewState == .viewingHighlight ? .infinity : 60)
|
||||
.background(Color.appButtonBackground)
|
||||
.cornerRadius(8)
|
||||
labelsBox
|
||||
}.padding(15)
|
||||
.background(Color.extensionPanelBackground)
|
||||
.cornerRadius(14)
|
||||
}
|
||||
|
||||
func onLabelTap(label: LinkedItemLabel, textChip _: TextChip) {
|
||||
if labelsViewModel.selectedLabels.contains(label) {
|
||||
labelsViewModel.selectedLabels.remove(label)
|
||||
} else {
|
||||
labelsViewModel.selectedLabels.insert(label)
|
||||
}
|
||||
|
||||
if let linkedItem = viewModel.linkedItem {
|
||||
labelsViewModel.saveItemLabelChanges(itemID: linkedItem.unwrappedID, dataService: viewModel.services.dataService)
|
||||
}
|
||||
}
|
||||
|
||||
var primaryButtons: some View {
|
||||
HStack {
|
||||
Button(
|
||||
action: { viewModel.handleReadNowAction(extensionContext: extensionContext) },
|
||||
label: {
|
||||
Label("Read Now", systemImage: "book")
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.background(Color.appButtonBackground)
|
||||
.frame(height: 52)
|
||||
.cornerRadius(8)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Button(
|
||||
action: {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
},
|
||||
label: {
|
||||
Label(LocalText.readLaterGeneric, systemImage: "text.book.closed.fill")
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
)
|
||||
.foregroundColor(.black)
|
||||
.background(Color.appBackground)
|
||||
.frame(height: 52)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
var moreActionsMenu: some View {
|
||||
var moreMenuButton: some View {
|
||||
Menu {
|
||||
Button(
|
||||
action: {},
|
||||
label: {
|
||||
Button(LocalText.dismissButton, role: .cancel, action: {})
|
||||
}
|
||||
)
|
||||
Button(action: {
|
||||
NotificationCenter.default.post(name: Notification.Name("ShowEditInfoSheet"), object: nil)
|
||||
}, label: {
|
||||
Label(
|
||||
"Edit Info",
|
||||
systemImage: "info.circle"
|
||||
)
|
||||
})
|
||||
Button(action: {
|
||||
if let linkedItem = self.viewModel.linkedItem {
|
||||
self.viewModel.setLinkArchived(dataService: self.viewModel.services.dataService,
|
||||
|
|
@ -344,161 +199,79 @@ public struct ShareExtensionView: View {
|
|||
}
|
||||
)
|
||||
} label: {
|
||||
Text("More Actions")
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(Color.blue)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(8)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color.circleButtonBackground)
|
||||
.frame(width: 30, height: 30)
|
||||
|
||||
var editingViewTitle: String {
|
||||
switch viewState {
|
||||
case .editingTitle:
|
||||
return "Edit Title"
|
||||
case .editingLabels:
|
||||
return LocalText.labelsGeneric
|
||||
case .viewingHighlight:
|
||||
return LocalText.genericHighlight
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func submitEditTitle() {
|
||||
if viewState == .editingTitle {
|
||||
if let linkedItem = viewModel.linkedItem {
|
||||
viewModel.submitTitleEdit(dataService: viewModel.services.dataService,
|
||||
itemID: linkedItem.unwrappedID,
|
||||
title: viewModel.title,
|
||||
description: linkedItem.descriptionText ?? "")
|
||||
Image(systemName: "ellipsis")
|
||||
.resizable(resizingMode: Image.ResizingMode.stretch)
|
||||
.foregroundColor(Color.circleButtonForeground)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 15, height: 15)
|
||||
}
|
||||
}
|
||||
viewState = .mainView
|
||||
}
|
||||
|
||||
var closeButton: some View {
|
||||
Button(action: {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}, label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color.circleButtonBackground)
|
||||
.frame(width: 30, height: 30)
|
||||
|
||||
Image(systemName: "xmark")
|
||||
.resizable(resizingMode: Image.ResizingMode.stretch)
|
||||
.foregroundColor(Color.circleButtonForeground)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.font(Font.title.weight(.bold))
|
||||
.frame(width: 12, height: 12)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var titleBar: some View {
|
||||
HStack {
|
||||
Text("Saved to Omnivore")
|
||||
.font(Font.system(size: 22, weight: .bold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
moreMenuButton
|
||||
closeButton
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .center) {
|
||||
Capsule()
|
||||
.fill(.gray)
|
||||
.frame(width: 60, height: 4)
|
||||
.padding(.top, 10)
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
titleBar
|
||||
.padding(.top, 15)
|
||||
|
||||
if viewState == .mainView {
|
||||
titleBar
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
} else {
|
||||
ZStack {
|
||||
Text(editingViewTitle).bold()
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
infoBox
|
||||
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
submitEditTitle()
|
||||
}
|
||||
}, label: { Text(LocalText.doneGeneric).bold() })
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.padding(8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
if viewState == .mainView {
|
||||
titleBox
|
||||
}
|
||||
|
||||
if viewState == .editingTitle {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
TextEditor(text: $viewModel.title)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineSpacing(6)
|
||||
.submitLabel(.done)
|
||||
.accentColor(.appGraySolid)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.font(.appSubheadline)
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.appGrayBorder, lineWidth: 1)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.systemBackground))
|
||||
)
|
||||
.frame(height: 100)
|
||||
.focused($focusedField, equals: .titleEditor)
|
||||
.task {
|
||||
self.focusedField = .titleEditor
|
||||
}
|
||||
.onChange(of: viewModel.title) { text in
|
||||
if text.last?.isNewline == .some(true) {
|
||||
viewModel.title.removeLast()
|
||||
submitEditTitle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
Spacer(minLength: 1)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
viewModel.handleReadNowAction(extensionContext: extensionContext)
|
||||
}, label: {
|
||||
Text("Read Now")
|
||||
.font(Font.system(size: 17, weight: .semibold))
|
||||
.tint(Color.white)
|
||||
.padding(20)
|
||||
})
|
||||
.frame(height: 50)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(24)
|
||||
.padding(.bottom, 15)
|
||||
}.frame(maxWidth: .infinity)
|
||||
}.padding(.horizontal, 15)
|
||||
.background(Color.extensionBackground)
|
||||
.onAppear {
|
||||
viewModel.savePage(extensionContext: extensionContext)
|
||||
}
|
||||
|
||||
if viewState != .editingTitle {
|
||||
if viewState != .viewingHighlight {
|
||||
labelsSection
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
previousLabels = Array(self.labelsViewModel.selectedLabels)
|
||||
viewState = .editingLabels
|
||||
}
|
||||
}
|
||||
}
|
||||
if viewState != .editingLabels {
|
||||
highlightSection
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
if viewModel.highlightData != nil {
|
||||
viewState = .viewingHighlight
|
||||
} else {
|
||||
showHighlightInstructionAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if viewState == .mainView {
|
||||
Divider()
|
||||
.padding(.bottom, 20)
|
||||
|
||||
primaryButtons
|
||||
|
||||
moreActionsMenu
|
||||
}
|
||||
}
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: .infinity,
|
||||
alignment: .topLeading
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.onAppear {
|
||||
viewModel.savePage(extensionContext: extensionContext)
|
||||
}
|
||||
.sheet(isPresented: $labelsViewModel.showCreateLabelModal) {
|
||||
CreateLabelView(viewModel: labelsViewModel, newLabelName: labelsViewModel.labelSearchFilter)
|
||||
}
|
||||
.alert("Before saving an article select text in Safari to create a highlight on save.",
|
||||
isPresented: $showHighlightInstructionAlert) {
|
||||
Button(LocalText.genericOk, role: .cancel) { showHighlightInstructionAlert = false }
|
||||
}
|
||||
.task {
|
||||
await labelsViewModel.loadLabelsFromStore(dataService: viewModel.services.dataService)
|
||||
}.environmentObject(viewModel.services.dataService)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ struct FeedCardNavigationLink: View {
|
|||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
let item: LinkedItem
|
||||
|
||||
let isInMultiSelectMode: Bool
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
var body: some View {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ extension LinkedItemFilter {
|
|||
return LocalText.readLaterGeneric
|
||||
case .newsletters:
|
||||
return LocalText.newslettersGeneric
|
||||
case .downloaded:
|
||||
return "Downloaded"
|
||||
case .feeds:
|
||||
return "Feeds"
|
||||
case .recommended:
|
||||
return "Recommended"
|
||||
case .all:
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = false
|
||||
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = true
|
||||
@AppStorage(UserDefaultKey.shouldPromptCommunityModal.rawValue) var shouldPromptCommunityModal = true
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
|
|
@ -43,12 +43,23 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
|
||||
}
|
||||
|
||||
var showFeatureCards: Bool {
|
||||
viewModel.listConfig.hasFeatureCards &&
|
||||
!viewModel.hideFeatureSection &&
|
||||
viewModel.items.count > 0 &&
|
||||
viewModel.searchTerm.isEmpty &&
|
||||
viewModel.selectedLabels.isEmpty &&
|
||||
viewModel.negatedLabels.isEmpty &&
|
||||
LinkedItemFilter(rawValue: viewModel.appliedFilter) == .inbox
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HomeFeedView(
|
||||
listTitle: $listTitle,
|
||||
isListScrolled: $isListScrolled,
|
||||
prefersListLayout: $prefersListLayout,
|
||||
viewModel: viewModel
|
||||
viewModel: viewModel,
|
||||
showFeatureCards: showFeatureCards
|
||||
)
|
||||
.refreshable {
|
||||
loadItems(isRefresh: true)
|
||||
|
|
@ -86,70 +97,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
// .navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .barLeading) {
|
||||
VStack(alignment: .leading) {
|
||||
let title = (LinkedItemFilter(rawValue: viewModel.appliedFilter) ?? LinkedItemFilter.inbox).displayName
|
||||
|
||||
Text(title)
|
||||
.font(Font.system(size: isListScrolled ? 10 : 18, weight: .semibold))
|
||||
|
||||
if prefersListLayout, isListScrolled {
|
||||
Text(listTitle)
|
||||
.font(Font.system(size: 15, weight: .regular))
|
||||
.foregroundColor(Color.appGrayText)
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
Button("", action: {})
|
||||
.disabled(true)
|
||||
.overlay {
|
||||
if viewModel.isLoading, !prefersListLayout, enableGrid {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: UIDevice.isIPhone ? .barLeading : .barTrailing) {
|
||||
if enableGrid {
|
||||
Button(
|
||||
action: { prefersListLayout.toggle() },
|
||||
label: {
|
||||
Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet")
|
||||
}
|
||||
)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
Button(
|
||||
action: { searchPresented = true },
|
||||
label: {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.resizable()
|
||||
.frame(width: 18, height: 18)
|
||||
.padding(.vertical)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
if UIDevice.isIPhone {
|
||||
Menu(content: {
|
||||
Button(action: { settingsPresented = true }, label: {
|
||||
Label(LocalText.genericProfile, systemImage: "person.circle")
|
||||
})
|
||||
Button(action: { addLinkPresented = true }, label: {
|
||||
Label("Add Link", systemImage: "plus.circle")
|
||||
})
|
||||
}, label: {
|
||||
Image.utilityMenu
|
||||
})
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
toolbarItems
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
|
||||
loadItems(isRefresh: false)
|
||||
|
|
@ -215,6 +163,92 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
var toolbarItems: some ToolbarContent {
|
||||
Group {
|
||||
ToolbarItem(placement: .barLeading) {
|
||||
VStack(alignment: .leading) {
|
||||
let title = (LinkedItemFilter(rawValue: viewModel.appliedFilter) ?? LinkedItemFilter.inbox).displayName
|
||||
|
||||
Text(title)
|
||||
.font(Font.system(size: isListScrolled ? 10 : 18, weight: .semibold))
|
||||
|
||||
if prefersListLayout, isListScrolled || !showFeatureCards {
|
||||
Text(listTitle)
|
||||
.font(Font.system(size: 15, weight: .regular))
|
||||
.foregroundColor(Color.appGrayText)
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
Button("", action: {})
|
||||
.disabled(true)
|
||||
.overlay {
|
||||
if viewModel.isLoading, !prefersListLayout, enableGrid {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: UIDevice.isIPhone ? .barLeading : .barTrailing) {
|
||||
if enableGrid {
|
||||
Button(
|
||||
action: { prefersListLayout.toggle() },
|
||||
label: {
|
||||
Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet")
|
||||
}
|
||||
)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
Button(
|
||||
action: { searchPresented = true },
|
||||
label: {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.resizable()
|
||||
.frame(width: 18, height: 18)
|
||||
.padding(.vertical)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
if UIDevice.isIPhone {
|
||||
Menu(content: {
|
||||
// Button(action: {
|
||||
// // withAnimation {
|
||||
// viewModel.isInMultiSelectMode.toggle()
|
||||
// // }
|
||||
// }, label: {
|
||||
// Label(viewModel.isInMultiSelectMode ? "End Multiselect" : "Select Multiple", systemImage: "checkmark.circle")
|
||||
// })
|
||||
Button(action: { addLinkPresented = true }, label: {
|
||||
Label("Add Link", systemImage: "plus.circle")
|
||||
})
|
||||
Button(action: { settingsPresented = true }, label: {
|
||||
Label(LocalText.genericProfile, systemImage: "person.circle")
|
||||
})
|
||||
|
||||
}, label: {
|
||||
Image.utilityMenu
|
||||
})
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
// if viewModel.isInMultiSelectMode {
|
||||
// ToolbarItemGroup(placement: .bottomBar) {
|
||||
// Button(action: {}, label: { Image(systemName: "archivebox") })
|
||||
// Button(action: {}, label: { Image(systemName: "trash") })
|
||||
// Button(action: {}, label: { Image.label })
|
||||
// Spacer()
|
||||
// Button(action: { viewModel.isInMultiSelectMode = false }, label: { Text("Cancel") })
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -226,6 +260,8 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@Binding var prefersListLayout: Bool
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
let showFeatureCards: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let linkRequest = viewModel.linkRequest {
|
||||
|
|
@ -237,12 +273,15 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
EmptyView()
|
||||
}
|
||||
}
|
||||
NavigationLink(destination: LinkDestination(selectedItem: viewModel.selectedItem), isActive: $viewModel.linkIsActive) {
|
||||
NavigationLink(
|
||||
destination: LinkDestination(selectedItem: viewModel.selectedItem),
|
||||
isActive: $viewModel.linkIsActive
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
if prefersListLayout || !enableGrid {
|
||||
HomeFeedListView(listTitle: $listTitle, isListScrolled: $isListScrolled, prefersListLayout: $prefersListLayout, viewModel: viewModel)
|
||||
HomeFeedListView(listTitle: $listTitle, isListScrolled: $isListScrolled, prefersListLayout: $prefersListLayout, viewModel: viewModel, showFeatureCards: showFeatureCards)
|
||||
} else {
|
||||
HomeFeedGridView(viewModel: viewModel, isListScrolled: $isListScrolled)
|
||||
}
|
||||
|
|
@ -292,6 +331,8 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
let showFeatureCards: Bool
|
||||
|
||||
var filtersHeader: some View {
|
||||
GeometryReader { reader in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
|
|
@ -504,14 +545,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
.listRowSeparator(.hidden, edges: .all)
|
||||
.listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 0, trailing: horizontalInset))
|
||||
|
||||
if viewModel.listConfig.hasFeatureCards,
|
||||
!viewModel.hideFeatureSection,
|
||||
viewModel.items.count > 0,
|
||||
viewModel.searchTerm.isEmpty,
|
||||
viewModel.selectedLabels.isEmpty,
|
||||
viewModel.negatedLabels.isEmpty,
|
||||
LinkedItemFilter(rawValue: viewModel.appliedFilter) == .inbox
|
||||
{
|
||||
if showFeatureCards {
|
||||
featureCard
|
||||
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.listRowSeparator(.hidden, edges: .all)
|
||||
|
|
@ -531,6 +565,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
ForEach(viewModel.items) { item in
|
||||
FeedCardNavigationLink(
|
||||
item: item,
|
||||
isInMultiSelectMode: viewModel.isInMultiSelectMode,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.background(GeometryReader { geometry in
|
||||
|
|
@ -539,10 +574,8 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
})
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
|
||||
if value.y < 100, value.y > 0 {
|
||||
if let date = item.savedAt {
|
||||
if topItem != item {
|
||||
setTopItem(item)
|
||||
}
|
||||
if item.savedAt != nil, topItem != item {
|
||||
setTopItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import Views
|
|||
@Published var itemToSnoozeID: String?
|
||||
@Published var linkRequest: LinkRequest?
|
||||
@Published var showLoadingBar = false
|
||||
@Published var isInMultiSelectMode = false
|
||||
@Published var appliedSort = LinkedItemSort.newest.rawValue
|
||||
|
||||
@Published var selectedLinkItem: NSManagedObjectID? // used by mac app only
|
||||
|
|
@ -202,7 +203,8 @@ import Views
|
|||
await group.waitForAll()
|
||||
}
|
||||
|
||||
let shouldSearch = items.count < 1 || isRefresh
|
||||
let filter = LinkedItemFilter(rawValue: appliedFilter)
|
||||
let shouldSearch = items.count < 1 || isRefresh && filter != LinkedItemFilter.downloaded
|
||||
if shouldSearch {
|
||||
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
|
||||
} else {
|
||||
|
|
@ -219,7 +221,10 @@ import Views
|
|||
isLoading = true
|
||||
showLoadingBar = true
|
||||
|
||||
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
|
||||
let filter = LinkedItemFilter(rawValue: appliedFilter)
|
||||
if filter != LinkedItemFilter.downloaded {
|
||||
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
showLoadingBar = false
|
||||
|
|
@ -325,8 +330,8 @@ import Views
|
|||
|
||||
func addLabel(dataService: DataService, item: LinkedItem, label: String, color: String) {
|
||||
if let label = getOrCreateLabel(dataService: dataService, named: "Pinned", color: color) {
|
||||
let existingLabels = item.labels?.allObjects.compactMap { ($0 as? LinkedItemLabel)?.unwrappedID } ?? []
|
||||
dataService.updateItemLabels(itemID: item.unwrappedID, labelIDs: existingLabels + [label.unwrappedID])
|
||||
let existingLabels = item.labels?.allObjects.compactMap { $0 as? LinkedItemLabel } ?? []
|
||||
dataService.setItemLabels(itemID: item.unwrappedID, labels: InternalLinkedItemLabel.make(Set(existingLabels + [label]) as NSSet))
|
||||
|
||||
item.update(inContext: dataService.viewContext)
|
||||
updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter))
|
||||
|
|
@ -334,10 +339,10 @@ import Views
|
|||
}
|
||||
|
||||
func removeLabel(dataService: DataService, item: LinkedItem, named: String) {
|
||||
let labelIds = item.labels?
|
||||
let labels = item.labels?
|
||||
.filter { ($0 as? LinkedItemLabel)?.name != named }
|
||||
.compactMap { ($0 as? LinkedItemLabel)?.unwrappedID } ?? []
|
||||
dataService.updateItemLabels(itemID: item.unwrappedID, labelIDs: labelIds)
|
||||
.compactMap { $0 as? LinkedItemLabel } ?? []
|
||||
dataService.setItemLabels(itemID: item.unwrappedID, labels: InternalLinkedItemLabel.make(Set(labels) as NSSet))
|
||||
item.update(inContext: dataService.viewContext)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
|
|
@ -49,9 +50,16 @@ struct ApplyLabelsView: View {
|
|||
|
||||
var innerBody: some View {
|
||||
VStack {
|
||||
SearchBar(searchTerm: $viewModel.labelSearchFilter)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 16)
|
||||
LabelsEntryView(
|
||||
searchTerm: $viewModel.labelSearchFilter,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 20)
|
||||
|
||||
if viewModel.labelSearchFilter.count >= 63 {
|
||||
Text("The maximum length of a label is 64 chars.").foregroundColor(Color.red).font(.footnote)
|
||||
}
|
||||
|
||||
List {
|
||||
Section {
|
||||
|
|
@ -59,9 +67,12 @@ struct ApplyLabelsView: View {
|
|||
Button(
|
||||
action: {
|
||||
if isSelected(label) {
|
||||
viewModel.selectedLabels.remove(label)
|
||||
if let idx = viewModel.selectedLabels.firstIndex(of: label) {
|
||||
viewModel.selectedLabels.remove(at: idx)
|
||||
}
|
||||
} else {
|
||||
viewModel.selectedLabels.insert(label)
|
||||
viewModel.labelSearchFilter = ZWSP
|
||||
viewModel.selectedLabels.append(label)
|
||||
}
|
||||
},
|
||||
label: {
|
||||
|
|
@ -84,11 +95,13 @@ struct ApplyLabelsView: View {
|
|||
createLabelButton
|
||||
}
|
||||
}
|
||||
.listStyle(PlainListStyle())
|
||||
.listStyle(.plain)
|
||||
.background(Color.extensionBackground)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.navigationTitle(mode.navTitle)
|
||||
.background(Color.extensionBackground)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
|
@ -166,7 +179,7 @@ struct ApplyLabelsView: View {
|
|||
Group {
|
||||
#if os(iOS)
|
||||
NavigationView {
|
||||
if viewModel.isLoading {
|
||||
if viewModel.labels.isEmpty, viewModel.isLoading {
|
||||
EmptyView()
|
||||
} else {
|
||||
innerBody
|
||||
|
|
@ -177,14 +190,16 @@ struct ApplyLabelsView: View {
|
|||
.frame(minWidth: 400, minHeight: 600)
|
||||
#endif
|
||||
}
|
||||
.task {
|
||||
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)
|
||||
.onAppear {
|
||||
Task {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -192,9 +207,11 @@ struct ApplyLabelsView: View {
|
|||
|
||||
extension Sequence where Element == LinkedItemLabel {
|
||||
func applySearchFilter(_ searchFilter: String) -> [LinkedItemLabel] {
|
||||
if searchFilter.isEmpty {
|
||||
if searchFilter.isEmpty || searchFilter == ZWSP {
|
||||
return map { $0 } // return the identity of the sequence
|
||||
}
|
||||
return filter { ($0.name ?? "").lowercased().contains(searchFilter.lowercased()) }
|
||||
let index = searchFilter.index(searchFilter.startIndex, offsetBy: 1)
|
||||
let trimmed = searchFilter.suffix(from: index).lowercased()
|
||||
return filter { ($0.name ?? "").lowercased().contains(trimmed) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import SwiftUI
|
|||
import Models
|
||||
import Views
|
||||
|
||||
@MainActor
|
||||
struct LabelsMasonaryView: View {
|
||||
var onLabelTap: (LinkedItemLabel, TextChip) -> Void
|
||||
|
||||
|
|
|
|||
|
|
@ -2,17 +2,18 @@ import CoreData
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor final class LabelsViewModel: ObservableObject {
|
||||
@MainActor public final class LabelsViewModel: ObservableObject {
|
||||
let labelNameMaxLength = 64
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var selectedLabels = Set<LinkedItemLabel>()
|
||||
@Published var selectedLabels = [LinkedItemLabel]()
|
||||
@Published var unselectedLabels = Set<LinkedItemLabel>()
|
||||
@Published var labels = [LinkedItemLabel]()
|
||||
@Published var showCreateLabelModal = false
|
||||
@Published var labelSearchFilter = ""
|
||||
@Published var labelSearchFilter = ZWSP
|
||||
|
||||
public init() {}
|
||||
|
||||
func setLabels(_ labels: [LinkedItemLabel]) {
|
||||
self.labels = labels.sorted { left, right in
|
||||
|
|
@ -34,11 +35,14 @@ import Views
|
|||
await loadLabelsFromStore(dataService: dataService)
|
||||
for label in labels {
|
||||
if selLabels.contains(label) {
|
||||
selectedLabels.insert(label)
|
||||
if !selectedLabels.contains(label) {
|
||||
selectedLabels.append(label)
|
||||
}
|
||||
} else {
|
||||
unselectedLabels.insert(label)
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
|
||||
Task.detached(priority: .userInitiated) {
|
||||
if let labelIDs = try? await dataService.labels() {
|
||||
|
|
@ -48,16 +52,17 @@ import Views
|
|||
}
|
||||
for label in self.labels {
|
||||
if selLabels.contains(label) {
|
||||
self.selectedLabels.insert(label)
|
||||
if !self.selectedLabels.contains(label) {
|
||||
self.selectedLabels.append(label)
|
||||
}
|
||||
} else {
|
||||
self.unselectedLabels.insert(label)
|
||||
}
|
||||
}
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func loadLabelsFromStore(dataService: DataService) async {
|
||||
|
|
@ -98,7 +103,7 @@ import Views
|
|||
|
||||
if let label = dataService.viewContext.object(with: labelObjectID) as? LinkedItemLabel {
|
||||
labels.insert(label, at: 0)
|
||||
selectedLabels.insert(label)
|
||||
selectedLabels.append(label)
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
|
|
@ -111,7 +116,7 @@ import Views
|
|||
}
|
||||
|
||||
func saveItemLabelChanges(itemID: String, dataService: DataService) {
|
||||
dataService.updateItemLabels(itemID: itemID, labelIDs: selectedLabels.map(\.unwrappedID))
|
||||
dataService.setItemLabels(itemID: itemID, labels: InternalLinkedItemLabel.make(Set(selectedLabels) as NSSet))
|
||||
}
|
||||
|
||||
func saveHighlightLabelChanges(highlightID: String, dataService: DataService) {
|
||||
|
|
|
|||
198
apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift
Normal file
198
apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
let ZWSP = "\u{200B}"
|
||||
|
||||
@MainActor
|
||||
protocol Entry {
|
||||
func item(parent: LabelsEntryView) -> AnyView
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct LabelEntry: Entry {
|
||||
let label: LinkedItemLabel
|
||||
|
||||
func item(parent _: LabelsEntryView) -> AnyView {
|
||||
if let name = label.name, let hex = label.color, let color = Color(hex: hex) {
|
||||
return AnyView(LibraryItemLabelView(text: name, color: color))
|
||||
}
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public struct LabelsEntryView: View {
|
||||
@Binding var searchTerm: String
|
||||
@State var viewModel: LabelsViewModel
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
let entries: [Entry]
|
||||
|
||||
@State private var totalHeight = CGFloat.zero
|
||||
@FocusState private var textFieldFocused: Bool
|
||||
|
||||
public init(
|
||||
searchTerm: Binding<String>,
|
||||
viewModel: LabelsViewModel
|
||||
) {
|
||||
self._searchTerm = searchTerm
|
||||
self.viewModel = viewModel
|
||||
|
||||
self.entries = Array(viewModel.selectedLabels.map { LabelEntry(label: $0) })
|
||||
}
|
||||
|
||||
func onTextSubmit() {
|
||||
let index = searchTerm.index(searchTerm.startIndex, offsetBy: 1)
|
||||
let trimmed = searchTerm.suffix(from: index).lowercased()
|
||||
|
||||
if trimmed.count < 1 {
|
||||
return
|
||||
}
|
||||
|
||||
if let label = viewModel.labels.first(where: { $0.name?.lowercased() == trimmed }) {
|
||||
if !viewModel.selectedLabels.contains(label) {
|
||||
viewModel.selectedLabels.append(label)
|
||||
}
|
||||
|
||||
searchTerm = ZWSP
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
textFieldFocused = true
|
||||
}
|
||||
} else {
|
||||
viewModel.createLabel(
|
||||
dataService: dataService,
|
||||
name: trimmed,
|
||||
color: Gradient.randomColor(str: trimmed, offset: 1),
|
||||
description: nil
|
||||
)
|
||||
searchTerm = ZWSP
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
textFieldFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var deletableTextField: some View {
|
||||
let str = NSAttributedString(
|
||||
string: searchTerm,
|
||||
attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 14)]
|
||||
)
|
||||
// Round it up to avoid jitter when typing
|
||||
let textWidth = max(25.0, Double(Int(str.size().width + 1)))
|
||||
let result = TextField("", text: $searchTerm)
|
||||
.frame(alignment: .topLeading)
|
||||
.frame(height: 25)
|
||||
.frame(width: textWidth)
|
||||
.padding(5)
|
||||
.font(Font.system(size: 14))
|
||||
.multilineTextAlignment(.leading)
|
||||
.onChange(of: searchTerm, perform: { _ in
|
||||
if searchTerm.count >= 64 {
|
||||
searchTerm = String(searchTerm.prefix(64))
|
||||
}
|
||||
if searchTerm.isEmpty {
|
||||
if viewModel.selectedLabels.count > 0 {
|
||||
viewModel.selectedLabels.removeLast()
|
||||
searchTerm = ZWSP
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
textFieldFocused = true
|
||||
}
|
||||
} else {
|
||||
searchTerm = ZWSP
|
||||
}
|
||||
}
|
||||
})
|
||||
.onSubmit {
|
||||
onTextSubmit()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// func onTextDelete() -> Bool { if searchTerm.isEmpty {
|
||||
// if lastSelected {
|
||||
// if viewModel.selectedLabels.count > 0 {
|
||||
// viewModel.selectedLabels.removeLast()
|
||||
// DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
|
||||
// textFieldFocused = true
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// lastSelected = true
|
||||
// }
|
||||
// return true
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
|
||||
public var body: some View {
|
||||
// HStack(spacing: 0) {
|
||||
VStack {
|
||||
GeometryReader { geometry in
|
||||
self.generateLabelsContent(in: geometry)
|
||||
}
|
||||
}.padding(0)
|
||||
.frame(height: totalHeight)
|
||||
.background(Color.extensionPanelBackground)
|
||||
.cornerRadius(8)
|
||||
.onAppear {
|
||||
textFieldFocused = true
|
||||
}
|
||||
.onTapGesture {
|
||||
textFieldFocused = true
|
||||
}
|
||||
.transaction { $0.animation = nil }
|
||||
}
|
||||
|
||||
private func generateLabelsContent(in geom: GeometryProxy) -> some View {
|
||||
var width = CGFloat.zero
|
||||
var height = CGFloat.zero
|
||||
|
||||
return ZStack(alignment: .topLeading) {
|
||||
ForEach(Array(self.entries.enumerated()), id: \.offset) { _, entry in
|
||||
entry.item(parent: self)
|
||||
.padding(5)
|
||||
.alignmentGuide(.leading, computeValue: { dim in
|
||||
if abs(width - dim.width) > geom.size.width {
|
||||
width = 0
|
||||
height -= dim.height
|
||||
}
|
||||
let result = width
|
||||
width -= dim.width
|
||||
return result
|
||||
})
|
||||
.alignmentGuide(.top, computeValue: { _ in
|
||||
let result = height
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
deletableTextField
|
||||
.alignmentGuide(.leading, computeValue: { dim in
|
||||
if abs(width - dim.width) > geom.size.width {
|
||||
width = 0
|
||||
height -= dim.height
|
||||
}
|
||||
let result = width
|
||||
width = 0
|
||||
return result
|
||||
})
|
||||
.alignmentGuide(.top, computeValue: { _ in
|
||||
let result = height
|
||||
height = 0
|
||||
return result
|
||||
}).focused($textFieldFocused)
|
||||
}.background(viewHeightReader($totalHeight))
|
||||
}
|
||||
|
||||
private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
|
||||
GeometryReader { geometry -> Color in
|
||||
let rect = geometry.frame(in: .local)
|
||||
DispatchQueue.main.async {
|
||||
binding.wrappedValue = rect.size.height
|
||||
}
|
||||
return .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import Services
|
|||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor
|
||||
struct LibraryTabView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
|
|
|
|||
|
|
@ -95,13 +95,13 @@ struct LinkedItemMetadataEditView: View {
|
|||
var iOSBody: some View {
|
||||
NavigationView {
|
||||
editForm
|
||||
.navigationTitle("Edit Title and Description")
|
||||
.navigationTitle("Edit Info")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .barLeading) {
|
||||
Button(
|
||||
action: { presentationMode.wrappedValue.dismiss() },
|
||||
label: { Text(LocalText.cancelGeneric).foregroundColor(.appGrayTextContrast) }
|
||||
label: { Text(LocalText.cancelGeneric) }
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
|
|
@ -113,7 +113,7 @@ struct LinkedItemMetadataEditView: View {
|
|||
}
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
},
|
||||
label: { Text(LocalText.genericSave).foregroundColor(.appGrayTextContrast) }
|
||||
label: { Text(LocalText.genericSave).bold() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,17 +18,15 @@ import Views
|
|||
}
|
||||
|
||||
func loadProfileData(dataService: DataService) async {
|
||||
if let currentViewer = dataService.currentViewer {
|
||||
loadProfileCardData(viewer: currentViewer)
|
||||
return
|
||||
if let currentViewer = dataService.currentViewer,
|
||||
let name = currentViewer.name,
|
||||
let username = currentViewer.username
|
||||
{
|
||||
loadProfileCardData(name: name, username: username, profileImageURL: currentViewer.profileImageURL)
|
||||
}
|
||||
|
||||
guard let viewerObjectID = try? await dataService.fetchViewer() else { return }
|
||||
|
||||
await dataService.viewContext.perform {
|
||||
if let viewer = dataService.viewContext.object(with: viewerObjectID) as? Viewer {
|
||||
self.loadProfileCardData(viewer: viewer)
|
||||
}
|
||||
if let viewer = try? await dataService.fetchViewer() {
|
||||
loadProfileCardData(name: viewer.name, username: viewer.username, profileImageURL: viewer.profileImageURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -47,11 +45,11 @@ import Views
|
|||
}
|
||||
}
|
||||
|
||||
private func loadProfileCardData(viewer: Viewer) {
|
||||
private func loadProfileCardData(name: String, username: String, profileImageURL: String?) {
|
||||
profileCardData = ProfileCardData(
|
||||
name: viewer.unwrappedName,
|
||||
username: viewer.unwrappedUsername,
|
||||
imageURL: viewer.profileImageURL.flatMap { URL(string: $0) }
|
||||
name: name,
|
||||
username: username,
|
||||
imageURL: profileImageURL.flatMap { URL(string: $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -159,17 +157,23 @@ struct ProfileView: View {
|
|||
)
|
||||
#endif
|
||||
|
||||
NavigationLink(
|
||||
destination: BasicWebAppView.privacyPolicyWebView(baseURL: dataService.appEnvironment.webAppBaseURL)
|
||||
) {
|
||||
Text(LocalText.privacyPolicyGeneric)
|
||||
}
|
||||
Button(
|
||||
action: {
|
||||
if let url = URL(string: "https://omnivore.app/privacy") {
|
||||
openURL(url)
|
||||
}
|
||||
},
|
||||
label: { Text(LocalText.privacyPolicyGeneric) }
|
||||
)
|
||||
|
||||
NavigationLink(
|
||||
destination: BasicWebAppView.termsConditionsWebView(baseURL: dataService.appEnvironment.webAppBaseURL)
|
||||
) {
|
||||
Text(LocalText.termsAndConditionsGeneric)
|
||||
}
|
||||
Button(
|
||||
action: {
|
||||
if let url = URL(string: "https://omnivore.app/terms") {
|
||||
openURL(url)
|
||||
}
|
||||
},
|
||||
label: { Text(LocalText.termsAndConditionsGeneric) }
|
||||
)
|
||||
}
|
||||
|
||||
Section(footer: Text(viewModel.appVersionString)) {
|
||||
|
|
@ -202,11 +206,11 @@ struct ProfileView: View {
|
|||
|
||||
extension BasicWebAppView {
|
||||
static func privacyPolicyWebView(baseURL: URL) -> BasicWebAppView {
|
||||
omnivoreWebView(path: "/app/privacy", baseURL: baseURL)
|
||||
omnivoreWebView(path: "/privacy", baseURL: baseURL)
|
||||
}
|
||||
|
||||
static func termsConditionsWebView(baseURL: URL) -> BasicWebAppView {
|
||||
omnivoreWebView(path: "/app/terms", baseURL: baseURL)
|
||||
omnivoreWebView(path: "/terms", baseURL: baseURL)
|
||||
}
|
||||
|
||||
private static func omnivoreWebView(path: String, baseURL: URL) -> BasicWebAppView {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,19 @@
|
|||
.task { viewModel.checkPushNotificationsStatus() }
|
||||
}
|
||||
|
||||
private var notificationsText: some View {
|
||||
let markdown = "\(LocalText.notificationsExplainer)\n\n\(LocalText.notificationsTriggerExplainer)"
|
||||
if let notificationsText = try? AttributedString(
|
||||
markdown: markdown,
|
||||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
) {
|
||||
return Text(notificationsText)
|
||||
.accentColor(.blue)
|
||||
}
|
||||
return Text(markdown)
|
||||
.accentColor(.blue)
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
Group {
|
||||
Section {
|
||||
|
|
@ -75,8 +88,7 @@
|
|||
}
|
||||
|
||||
Section {
|
||||
Text("\(LocalText.notificationsExplainer)\n\(LocalText.notificationsTriggerExplainer)")
|
||||
.accentColor(.blue)
|
||||
notificationsText
|
||||
}
|
||||
|
||||
Section {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import Views
|
|||
isLoading = true
|
||||
|
||||
do {
|
||||
subscriptions = try await dataService.subscriptions()
|
||||
subscriptions = try await dataService.subscriptions().filter { $0.status == SubscriptionStatus.active }
|
||||
} catch {
|
||||
hasNetworkError = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public struct RootView: View {
|
|||
if let intercomProvider = intercomProvider {
|
||||
DataService.showIntercomMessenger = intercomProvider.showIntercomMessenger
|
||||
DataService.registerIntercomUser = intercomProvider.registerIntercomUser
|
||||
DataService.setIntercomUserHash = intercomProvider.setIntercomUserHash
|
||||
Authenticator.unregisterIntercomUser = intercomProvider.unregisterIntercomUser
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,15 +43,18 @@ public final class RootViewModel: ObservableObject {
|
|||
public struct IntercomProvider {
|
||||
public init(
|
||||
registerIntercomUser: @escaping (String) -> Void,
|
||||
setIntercomUserHash: @escaping (String) -> Void,
|
||||
unregisterIntercomUser: @escaping () -> Void,
|
||||
showIntercomMessenger: @escaping () -> Void
|
||||
) {
|
||||
self.registerIntercomUser = registerIntercomUser
|
||||
self.setIntercomUserHash = setIntercomUserHash
|
||||
self.unregisterIntercomUser = unregisterIntercomUser
|
||||
self.showIntercomMessenger = showIntercomMessenger
|
||||
}
|
||||
|
||||
public let registerIntercomUser: (String) -> Void
|
||||
public let setIntercomUserHash: (String) -> Void
|
||||
public let unregisterIntercomUser: () -> Void
|
||||
public let showIntercomMessenger: () -> Void
|
||||
}
|
||||
|
|
|
|||
|
|
@ -614,7 +614,7 @@ struct WebReaderContainerView: View {
|
|||
.autohideIn(2)
|
||||
.position(.bottom)
|
||||
.animation(.spring())
|
||||
.closeOnTapOutside(true)
|
||||
.isOpaque(false)
|
||||
}
|
||||
.onReceive(NSNotification.readerSnackBarPublisher) { notification in
|
||||
if let message = notification.userInfo?["message"] as? String {
|
||||
|
|
|
|||
|
|
@ -87,37 +87,37 @@ struct WelcomeView: View {
|
|||
Spacer()
|
||||
}
|
||||
.sheet(isPresented: $showPrivacyModal) {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
showPrivacyModal = false
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "xmark.circle").foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
NavigationView {
|
||||
BasicWebAppView.privacyPolicyWebView(baseURL: dataService.appEnvironment.webAppBaseURL)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(
|
||||
action: {
|
||||
showPrivacyModal = false
|
||||
},
|
||||
label: {
|
||||
Text(LocalText.genericClose)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showTermsModal) {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
showTermsModal = false
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "xmark.circle").foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
NavigationView {
|
||||
BasicWebAppView.termsConditionsWebView(baseURL: dataService.appEnvironment.webAppBaseURL)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(
|
||||
action: {
|
||||
showTermsModal = false
|
||||
},
|
||||
label: {
|
||||
Text(LocalText.genericClose)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAboutPage) {
|
||||
|
|
|
|||
|
|
@ -79,6 +79,21 @@ public extension LinkedItem {
|
|||
(labels?.count ?? 0) > 0
|
||||
}
|
||||
|
||||
var noteHighlight: Highlight? {
|
||||
if let highlights = highlights?.compactMap({ $0 as? Highlight }) {
|
||||
let result = highlights
|
||||
.filter { $0.type == "NOTE" }
|
||||
.sorted(by: { $0.updatedAt ?? Date() < $1.updatedAt ?? Date() })
|
||||
.first
|
||||
return result
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var noteText: String? {
|
||||
noteHighlight?.annotation
|
||||
}
|
||||
|
||||
var isUnread: Bool {
|
||||
readingProgress <= 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import Foundation
|
|||
|
||||
public enum LinkedItemFilter: String, CaseIterable {
|
||||
case inbox
|
||||
case feeds
|
||||
case readlater
|
||||
case newsletters
|
||||
case downloaded
|
||||
case recommended
|
||||
case all
|
||||
case archived
|
||||
|
|
@ -17,8 +19,12 @@ public extension LinkedItemFilter {
|
|||
switch self {
|
||||
case .inbox:
|
||||
return "in:inbox"
|
||||
case .feeds:
|
||||
return "label:RSS"
|
||||
case .readlater:
|
||||
return "in:library"
|
||||
case .downloaded:
|
||||
return ""
|
||||
case .newsletters:
|
||||
return "in:inbox label:Newsletter"
|
||||
case .recommended:
|
||||
|
|
@ -70,12 +76,30 @@ public extension LinkedItemFilter {
|
|||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate, nonRSSPredicate
|
||||
])
|
||||
case .downloaded:
|
||||
// include pdf only
|
||||
let hasHTMLContent = NSPredicate(
|
||||
format: "htmlContent.length > 0"
|
||||
)
|
||||
let isPDFPredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF"
|
||||
)
|
||||
let localPDFURL = NSPredicate(
|
||||
format: "localPDF.length > 0"
|
||||
)
|
||||
let downloadedPDF = NSCompoundPredicate(andPredicateWithSubpredicates: [isPDFPredicate, localPDFURL])
|
||||
return NSCompoundPredicate(orPredicateWithSubpredicates: [hasHTMLContent, downloadedPDF])
|
||||
case .newsletters:
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
let newsletterLabelPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, newsletterLabelPredicate])
|
||||
case .feeds:
|
||||
let feedLabelPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, feedLabelPredicate])
|
||||
case .recommended:
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
let recommendedPredicate = NSPredicate(
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ public struct PageScrapePayload {
|
|||
public enum ContentType {
|
||||
case none
|
||||
case pdf(localUrl: URL)
|
||||
case html(html: String, title: String?, highlightData: HighlightData?)
|
||||
case html(html: String, title: String?, iconURL: URL?, highlightData: HighlightData?)
|
||||
}
|
||||
|
||||
public let url: String
|
||||
|
|
@ -49,9 +49,9 @@ public struct PageScrapePayload {
|
|||
self.contentType = .pdf(localUrl: localUrl)
|
||||
}
|
||||
|
||||
init(url: String, title: String?, html: String, highlightData: HighlightData?) {
|
||||
init(url: String, title: String?, html: String, iconURL: URL?, highlightData: HighlightData?) {
|
||||
self.url = url
|
||||
self.contentType = .html(html: html, title: title, highlightData: highlightData)
|
||||
self.contentType = .html(html: html, title: title, iconURL: iconURL, highlightData: highlightData)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -319,6 +319,11 @@ private extension PageScrapePayload {
|
|||
let html = results?["originalHTML"] as? String
|
||||
let title = results?["title"] as? String
|
||||
let contentType = results?["contentType"] as? String
|
||||
var iconURL: URL?
|
||||
|
||||
if let urlStr = results?["iconURL"] as? String {
|
||||
iconURL = URL(string: urlStr)
|
||||
}
|
||||
|
||||
// If we were not able to capture any HTML, treat this as a URL and
|
||||
// see if the backend can do better.
|
||||
|
|
@ -336,6 +341,7 @@ private extension PageScrapePayload {
|
|||
return PageScrapePayload(url: url,
|
||||
title: title,
|
||||
html: html,
|
||||
iconURL: iconURL,
|
||||
highlightData: HighlightData.make(dict: results))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ let logger = Logger(subsystem: "app.omnivore", category: "data-service")
|
|||
|
||||
public final class DataService: ObservableObject {
|
||||
public static var registerIntercomUser: ((String) -> Void)?
|
||||
public static var setIntercomUserHash: ((String) -> Void)?
|
||||
|
||||
public static var showIntercomMessenger: (() -> Void)?
|
||||
|
||||
public let appEnvironment: AppEnvironment
|
||||
|
|
@ -96,19 +98,6 @@ public final class DataService: ObservableObject {
|
|||
return try? persistentContainer.viewContext.fetch(fetchRequest).first
|
||||
}
|
||||
|
||||
public func username() async -> String? {
|
||||
if let cachedUsername = currentViewer?.username {
|
||||
return cachedUsername
|
||||
}
|
||||
|
||||
if let viewerObjectID = try? await fetchViewer() {
|
||||
let viewer = backgroundContext.object(with: viewerObjectID) as? Viewer
|
||||
return viewer?.unwrappedUsername
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
public func switchAppEnvironment(appEnvironment: AppEnvironment) {
|
||||
do {
|
||||
try ValetKey.appEnvironmentString.setValue(appEnvironment.rawValue)
|
||||
|
|
@ -266,7 +255,7 @@ public final class DataService: ObservableObject {
|
|||
linkedItem.contentReader = "PDF"
|
||||
linkedItem.tempPDFURL = localUrl
|
||||
linkedItem.title = PDFUtils.titleFromPdfFile(pageScrape.url)
|
||||
case let .html(html: html, title: title, highlightData: _):
|
||||
case let .html(html: html, title: title, iconURL: _, highlightData: _):
|
||||
linkedItem.contentReader = "WEB"
|
||||
linkedItem.originalHtml = html
|
||||
linkedItem.title = title ?? PDFUtils.titleFromPdfFile(pageScrape.url)
|
||||
|
|
|
|||
|
|
@ -23024,6 +23024,7 @@ extension Objects {
|
|||
let followersCount: [String: Int]
|
||||
let friendsCount: [String: Int]
|
||||
let id: [String: String]
|
||||
let intercomHash: [String: String]
|
||||
let isFriend: [String: Bool]
|
||||
let isFullUser: [String: Bool]
|
||||
let name: [String: String]
|
||||
|
|
@ -23070,6 +23071,10 @@ extension Objects.User: Decodable {
|
|||
if let value = try container.decode(String?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
}
|
||||
case "intercomHash":
|
||||
if let value = try container.decode(String?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
}
|
||||
case "isFriend":
|
||||
if let value = try container.decode(Bool?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
|
|
@ -23128,6 +23133,7 @@ extension Objects.User: Decodable {
|
|||
followersCount = map["followersCount"]
|
||||
friendsCount = map["friendsCount"]
|
||||
id = map["id"]
|
||||
intercomHash = map["intercomHash"]
|
||||
isFriend = map["isFriend"]
|
||||
isFullUser = map["isFullUser"]
|
||||
name = map["name"]
|
||||
|
|
@ -23206,6 +23212,21 @@ extension Fields where TypeLock == Objects.User {
|
|||
}
|
||||
}
|
||||
|
||||
func intercomHash() throws -> String? {
|
||||
let field = GraphQLField.leaf(
|
||||
name: "intercomHash",
|
||||
arguments: []
|
||||
)
|
||||
select(field)
|
||||
|
||||
switch response {
|
||||
case let .decoding(data):
|
||||
return data.intercomHash[field.alias!]
|
||||
case .mocking:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, deprecated, message: "isFriend has been replaced with viewerIsFollowing")
|
||||
func isFriend() throws -> Bool? {
|
||||
let field = GraphQLField.leaf(
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import Foundation
|
|||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
extension DataService {
|
||||
public func updateItemLabels(itemID: String, labelIDs: [String]) {
|
||||
public extension DataService {
|
||||
func setItemLabels(itemID: String, labels: [InternalLinkedItemLabel]) {
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
|
||||
|
|
@ -13,21 +13,19 @@ extension DataService {
|
|||
linkedItem.removeFromLabels(existingLabels)
|
||||
}
|
||||
|
||||
for labelID in labelIDs {
|
||||
if let labelObject = LinkedItemLabel.lookup(byID: labelID, inContext: self.backgroundContext) {
|
||||
linkedItem.addToLabels(labelObject)
|
||||
}
|
||||
for label in labels {
|
||||
linkedItem.addToLabels(label.asManagedObject(inContext: self.backgroundContext))
|
||||
}
|
||||
|
||||
linkedItem.update(inContext: self.backgroundContext)
|
||||
try? self.backgroundContext.save()
|
||||
|
||||
// Send update to server
|
||||
self.syncLabelUpdates(itemID: itemID, labelIDs: labelIDs)
|
||||
self.syncLabelUpdates(itemID: itemID, labels: labels)
|
||||
}
|
||||
}
|
||||
|
||||
func syncLabelUpdates(itemID: String, labelIDs: [String]) {
|
||||
internal func syncLabelUpdates(itemID: String, labels: [InternalLinkedItemLabel]) {
|
||||
enum MutationResult {
|
||||
case saved(feedItem: [InternalLinkedItemLabel])
|
||||
case error(errorCode: Enums.SetLabelsErrorCode)
|
||||
|
|
@ -40,10 +38,18 @@ extension DataService {
|
|||
)
|
||||
}
|
||||
|
||||
let labelInputs = labels.compactMap { label in
|
||||
InputObjects.CreateLabelInput(
|
||||
color: OptionalArgument(label.color),
|
||||
description: OptionalArgument(label.labelDescription),
|
||||
name: label.name
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.setLabels(
|
||||
input: InputObjects.SetLabelsInput(
|
||||
labelIds: OptionalArgument(labelIDs),
|
||||
labels: OptionalArgument(labelInputs),
|
||||
pageId: itemID
|
||||
),
|
||||
selection: selection
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import SwiftGraphQL
|
|||
import Utils
|
||||
|
||||
public extension DataService {
|
||||
func fetchViewer() async throws -> NSManagedObjectID {
|
||||
@MainActor
|
||||
func fetchViewer() async throws -> ViewerInternal? {
|
||||
let selection = Selection<ViewerInternal, Objects.User> {
|
||||
ViewerInternal(
|
||||
userID: try $0.id(),
|
||||
|
|
@ -15,7 +16,8 @@ public extension DataService {
|
|||
name: try $0.name(),
|
||||
profileImageURL: try $0.profile(
|
||||
selection: .init { try $0.pictureUrl() }
|
||||
)
|
||||
),
|
||||
intercomHash: try $0.intercomHash()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -29,15 +31,24 @@ public extension DataService {
|
|||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(query, to: path, headers: headers) { [weak self] result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
case let .success(payload: payload):
|
||||
if UserDefaults.standard.string(forKey: Keys.userIdKey) == nil {
|
||||
UserDefaults.standard.setValue(payload.data.userID, forKey: Keys.userIdKey)
|
||||
DataService.registerIntercomUser?(payload.data.userID)
|
||||
}
|
||||
|
||||
if let self = self, let viewerID = payload.data.persist(context: self.backgroundContext) {
|
||||
continuation.resume(returning: viewerID)
|
||||
} else {
|
||||
do {
|
||||
if let intercomUserHash = payload.data.intercomHash {
|
||||
DataService.setIntercomUserHash?(intercomUserHash)
|
||||
}
|
||||
|
||||
if let self = self {
|
||||
try payload.data.persist(context: self.backgroundContext)
|
||||
continuation.resume(returning: payload.data)
|
||||
} else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "no self found"))
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "coredata error"))
|
||||
}
|
||||
case .failure:
|
||||
|
|
@ -48,16 +59,15 @@ public extension DataService {
|
|||
}
|
||||
}
|
||||
|
||||
private struct ViewerInternal {
|
||||
let userID: String
|
||||
let username: String
|
||||
let name: String
|
||||
let profileImageURL: String?
|
||||
public struct ViewerInternal {
|
||||
public let userID: String
|
||||
public let username: String
|
||||
public let name: String
|
||||
public let profileImageURL: String?
|
||||
public let intercomHash: String?
|
||||
|
||||
func persist(context: NSManagedObjectContext) -> NSManagedObjectID? {
|
||||
var objectID: NSManagedObjectID?
|
||||
|
||||
context.performAndWait {
|
||||
func persist(context: NSManagedObjectContext) throws {
|
||||
try context.performAndWait {
|
||||
let viewer = Viewer(context: context)
|
||||
viewer.userID = userID
|
||||
viewer.username = username
|
||||
|
|
@ -66,15 +76,13 @@ private struct ViewerInternal {
|
|||
|
||||
do {
|
||||
try context.save()
|
||||
EventTracker.registerUser(userID: userID)
|
||||
logger.debug("Viewer saved succesfully")
|
||||
objectID = viewer.objectID
|
||||
EventTracker.registerUser(userID: viewer.unwrappedUserID)
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to save Viewer: \(error.localizedDescription)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return objectID
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,23 +48,6 @@
|
|||
|
||||
child.didMove(toParent: self)
|
||||
}
|
||||
//
|
||||
// @objc func keyboardWillShow(notification: Notification) {
|
||||
// if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
|
||||
// if self.view.frame.origin.y == 0{
|
||||
// self.view.frame.origin.y -= keyboardSize.height
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// }
|
||||
//
|
||||
// @objc func keyboardWillHide(notification: Notification) {
|
||||
// if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
|
||||
// if self.view.frame.origin.y != 0 {
|
||||
// self.view.frame.origin.y += keyboardSize.height
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
#endif
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@ public extension Color {
|
|||
|
||||
static var thFeatureSeparator: Color { Color("featureSeparator", bundle: .module) }
|
||||
|
||||
static var circleButtonBackground: Color { Color("_circleButtonBackground", bundle: .module) }
|
||||
static var circleButtonForeground: Color { Color("_circleButtonForeground", bundle: .module) }
|
||||
static var extensionBackground: Color { Color("_extensionBackground", bundle: .module) }
|
||||
static var extensionPanelBackground: Color { Color("_extensionPanelBackground", bundle: .module) }
|
||||
static var extensionTextSubtle: Color { Color("_extensionTextSubtle", bundle: .module) }
|
||||
|
||||
static var noteContainer: Color { Color("_noteContainer", bundle: .module) }
|
||||
static var textFieldBackground: Color { Color("_textFieldBackground", bundle: .module) }
|
||||
|
||||
// Apple system UIColor equivalents
|
||||
#if os(iOS)
|
||||
static var systemBackground: Color { Color(.systemBackground) }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xE9",
|
||||
"green" : "0xE8",
|
||||
"red" : "0xE8"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x3E",
|
||||
"green" : "0x3C",
|
||||
"red" : "0x3B"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x83",
|
||||
"green" : "0x81",
|
||||
"red" : "0x81"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xAB",
|
||||
"green" : "0xA5",
|
||||
"red" : "0xA5"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xF6",
|
||||
"green" : "0xF6",
|
||||
"red" : "0xF6"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x20",
|
||||
"green" : "0x20",
|
||||
"red" : "0x20"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0xFF",
|
||||
"red" : "0xFF"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x30",
|
||||
"green" : "0x30",
|
||||
"red" : "0x30"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x89",
|
||||
"green" : "0x89",
|
||||
"red" : "0x89"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x89",
|
||||
"green" : "0x89",
|
||||
"red" : "0x89"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xED",
|
||||
"green" : "0xED",
|
||||
"red" : "0xED"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x2A",
|
||||
"green" : "0x2A",
|
||||
"red" : "0x2A"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0xFF",
|
||||
"red" : "0xFE"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x2E",
|
||||
"green" : "0x2C",
|
||||
"red" : "0x2C"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import Models
|
||||
import SwiftUI
|
||||
import Utils
|
||||
|
||||
public struct FeedCard: View {
|
||||
let viewer: Viewer?
|
||||
let tapHandler: () -> Void
|
||||
@ObservedObject var item: LinkedItem
|
||||
|
||||
public init(item: LinkedItem, viewer: Viewer?, tapHandler: @escaping () -> Void = {}) {
|
||||
self.item = item
|
||||
self.viewer = viewer
|
||||
self.tapHandler = tapHandler
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(item.unwrappedTitle)
|
||||
.font(.appCallout)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(EdgeInsets(top: 0, leading: 0, bottom: 2, trailing: 0))
|
||||
|
||||
if let author = item.author {
|
||||
Text("By \(author)")
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let publisherDisplayName = item.publisherDisplayName {
|
||||
Text(publisherDisplayName)
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(
|
||||
minWidth: 0,
|
||||
maxWidth: .infinity,
|
||||
minHeight: 0,
|
||||
maxHeight: .infinity,
|
||||
alignment: .topLeading
|
||||
)
|
||||
.multilineTextAlignment(.leading)
|
||||
.padding(0)
|
||||
|
||||
Group {
|
||||
if let imageURL = item.imageURL {
|
||||
AsyncImage(url: imageURL) { phase in
|
||||
if let image = phase.image {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.cornerRadius(6)
|
||||
} else {
|
||||
Color.systemBackground
|
||||
.frame(width: 80, height: 80)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if item.hasLabels {
|
||||
// Category Labels
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(item.sortedLabels, id: \.self) {
|
||||
TextChip(feedItemLabel: $0)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}.introspectScrollView { scrollView in
|
||||
#if os(iOS)
|
||||
scrollView.bounces = false
|
||||
#endif
|
||||
}
|
||||
.padding(.top, 0)
|
||||
#if os(macOS)
|
||||
.onTapGesture {
|
||||
tapHandler()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
let recs = Recommendation.notViewers(viewer: viewer, item.recommendations)
|
||||
if recs.count > 0 {
|
||||
let byStr = Recommendation.byline(recs)
|
||||
let inStr = Recommendation.groupsLine(recs)
|
||||
HStack {
|
||||
Image(systemName: "sparkles")
|
||||
Text("Recommended by \(byStr) in \(inStr)")
|
||||
.font(.appCaption)
|
||||
.frame(alignment: .leading)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 0)
|
||||
.padding(.bottom, 8)
|
||||
.frame(
|
||||
minWidth: nil,
|
||||
idealWidth: nil,
|
||||
maxWidth: nil,
|
||||
minHeight: 70,
|
||||
idealHeight: nil,
|
||||
maxHeight: nil,
|
||||
alignment: .topLeading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -54,19 +54,20 @@ struct LabelsFlowLayout: View {
|
|||
return result
|
||||
})
|
||||
}
|
||||
}.background(viewCalculator())
|
||||
}.background(viewHeightReader($totalHeight))
|
||||
}
|
||||
|
||||
private func item(for item: LinkedItemLabel) -> some View {
|
||||
LibraryItemLabelView(text: item.name!, color: Color(hex: item.color!)!)
|
||||
}
|
||||
|
||||
func viewCalculator() -> some View {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.onAppear {
|
||||
let rect = geometry.frame(in: .local)
|
||||
self.totalHeight = rect.size.height
|
||||
private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
|
||||
GeometryReader { geometry -> Color in
|
||||
let rect = geometry.frame(in: .local)
|
||||
DispatchQueue.main.async {
|
||||
binding.wrappedValue = rect.size.height
|
||||
}
|
||||
return .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ public extension View {
|
|||
public struct LibraryItemCard: View {
|
||||
let viewer: Viewer?
|
||||
@ObservedObject var item: LinkedItem
|
||||
@State var noteLineLimit: Int? = 3
|
||||
|
||||
public init(item: LinkedItem, viewer: Viewer?) {
|
||||
self.item = item
|
||||
|
|
@ -64,6 +65,33 @@ public struct LibraryItemCard: View {
|
|||
if item.hasLabels {
|
||||
labels
|
||||
}
|
||||
|
||||
if let note = item.noteText {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
avatarImage
|
||||
.frame(width: 20, height: 20)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.leading, 10)
|
||||
|
||||
Text(note)
|
||||
.font(Font.system(size: 12))
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(noteLineLimit)
|
||||
.frame(minHeight: 20)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.trailing, 10)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(alignment: .topLeading)
|
||||
.background(Color.noteContainer)
|
||||
.cornerRadius(5)
|
||||
.allowsHitTesting(noteLineLimit != nil)
|
||||
.onTapGesture {
|
||||
noteLineLimit = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(5)
|
||||
.padding(.top, 10)
|
||||
|
|
@ -79,6 +107,16 @@ public struct LibraryItemCard: View {
|
|||
Int(item.readingProgress) > 0
|
||||
}
|
||||
|
||||
var avatarImage: some View {
|
||||
ZStack(alignment: .center) {
|
||||
Circle()
|
||||
.foregroundColor(Color.appCtaYellow)
|
||||
Text((viewer?.name ?? "O").prefix(1))
|
||||
.font(Font.system(size: 10))
|
||||
.foregroundColor(Color.black)
|
||||
}
|
||||
}
|
||||
|
||||
var readIndicator: some View {
|
||||
HStack {
|
||||
Circle()
|
||||
|
|
@ -281,3 +319,18 @@ public struct LibraryItemCard: View {
|
|||
LabelsFlowLayout(labels: nonFlairLabels)
|
||||
}
|
||||
}
|
||||
|
||||
struct CircleCheckboxToggleStyle: ToggleStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
Button(action: {
|
||||
configuration.isOn.toggle()
|
||||
}, label: {
|
||||
HStack {
|
||||
Image(systemName: configuration.isOn ? "checkmark.circle" : "circle")
|
||||
.font(Font.system(size: 18))
|
||||
.foregroundColor(configuration.isOn ? Color.blue : Color.appGrayTextContrast)
|
||||
}
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
public struct SearchBar: View {
|
||||
@Binding var searchTerm: String
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
public init(
|
||||
searchTerm: Binding<String>
|
||||
) {
|
||||
self._searchTerm = searchTerm
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
TextField("Search", text: $searchTerm)
|
||||
.frame(height: 36)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.leading, 28)
|
||||
.padding(.trailing, 28)
|
||||
.focused($isFocused)
|
||||
.overlay(
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.resizable()
|
||||
.frame(width: 14, height: 14)
|
||||
.foregroundColor(.appGrayText)
|
||||
.padding(.leading, 8)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
)
|
||||
|
||||
if isFocused {
|
||||
Button(
|
||||
action: {
|
||||
self.isFocused = false
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "multiply.circle.fill")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
)
|
||||
.padding(.trailing, 8)
|
||||
.transition(.move(edge: .trailing))
|
||||
}
|
||||
}
|
||||
.background(Color.appButtonBackground)
|
||||
.cornerRadius(8)
|
||||
.frame(height: 36)
|
||||
.onChange(of: isFocused) { isFocused in
|
||||
if !isFocused {
|
||||
searchTerm = ""
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
isFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import Utils
|
|||
public struct SyncStatusIcon: View {
|
||||
let status: ServerSyncStatus
|
||||
|
||||
init(status: ServerSyncStatus) {
|
||||
public init(status: ServerSyncStatus) {
|
||||
self.status = status
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ struct MainApp: App {
|
|||
RootView(
|
||||
intercomProvider: AppKeys.sharedInstance?.intercom != nil ? IntercomProvider(
|
||||
registerIntercomUser: { Intercom.registerUser(withUserId: $0) },
|
||||
setIntercomUserHash: { Intercom.setUserHash($0) },
|
||||
unregisterIntercomUser: Intercom.logout,
|
||||
showIntercomMessenger: Intercom.presentMessenger
|
||||
) : nil
|
||||
|
|
|
|||
|
|
@ -2,10 +2,16 @@ var ShareExtension = function() {};
|
|||
|
||||
const iconURL = () => {
|
||||
try {
|
||||
const previewImage = document.querySelector("meta[property='og:image'], meta[name='twitter:image']").content
|
||||
if (previewImage) { return previewImage }
|
||||
const previewImage = document.querySelector("meta[property='og:image'], meta[name='twitter:image']")
|
||||
if (previewImage && previewImage.getAttribute("content")) { return previewImage.getAttribute("content") }
|
||||
|
||||
return document.querySelector("link[rel='apple-touch-icon'], link[rel='shortcut icon'], link[rel='icon']").href
|
||||
const appleImage = document.querySelector("link[rel='apple-touch-icon'], link[rel='shortcut icon'], link[rel='icon']")
|
||||
if (appleImage && appleImage.getAttribute("href")) { return appleImage.getAttribute("href") }
|
||||
|
||||
const href = new URL(document.location.href)
|
||||
href.pathname = '/favicon.ico'
|
||||
|
||||
return href.toString()
|
||||
} catch {}
|
||||
return undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,60 @@
|
|||
import App
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
@objc(ShareExtensionViewController)
|
||||
final class ShareExtensionViewController: UIViewController {
|
||||
let labelsViewModel = LabelsViewModel()
|
||||
let viewModel = ShareExtensionViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .clear
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: Notification.Name("ShowAddNoteSheet"),
|
||||
object: nil,
|
||||
queue: OperationQueue.main
|
||||
) { _ in
|
||||
self.openSheet(AnyView(AddNoteSheet(viewModel: self.viewModel)))
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: Notification.Name("ShowEditLabelsSheet"),
|
||||
object: nil,
|
||||
queue: OperationQueue.main
|
||||
) { _ in
|
||||
self.openSheet(AnyView(EditLabelsSheet(viewModel: self.viewModel, labelsViewModel: self.labelsViewModel)))
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: Notification.Name("ShowEditInfoSheet"),
|
||||
object: nil,
|
||||
queue: OperationQueue.main
|
||||
) { _ in
|
||||
self.openSheet(AnyView(EditInfoSheet(viewModel: self.viewModel)))
|
||||
}
|
||||
|
||||
embed(
|
||||
childViewController: UIViewController.makeShareExtensionController(extensionContext: extensionContext),
|
||||
heightRatio: 0.75
|
||||
childViewController: UIViewController.makeShareExtensionController(
|
||||
viewModel: viewModel,
|
||||
labelsViewModel: labelsViewModel,
|
||||
extensionContext: extensionContext
|
||||
),
|
||||
heightRatio: 0.60
|
||||
)
|
||||
}
|
||||
|
||||
func openSheet(_ rootView: AnyView) {
|
||||
let hostingController = UIHostingController(rootView: rootView)
|
||||
|
||||
present(hostingController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
#elseif os(macOS)
|
||||
|
|
|
|||
|
|
@ -44,35 +44,35 @@ export function CardMenu(props: CardMenuProps): JSX.Element {
|
|||
onSelect={() => {
|
||||
props.actionHandler('set-labels')
|
||||
}}
|
||||
title="Set Labels"
|
||||
title="Set labels"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => {
|
||||
props.actionHandler('open-notebook')
|
||||
}}
|
||||
title="Open Notebook"
|
||||
title="Open notebook"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => props.actionHandler('showOriginal')}
|
||||
title="Open Original"
|
||||
title="Open original"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => props.actionHandler('editTitle')}
|
||||
title="Edit Metadata"
|
||||
title="Edit metadata"
|
||||
/>
|
||||
{props.item.readingProgressPercent < 98 ? (
|
||||
<DropdownOption
|
||||
onSelect={() => {
|
||||
props.actionHandler('mark-read')
|
||||
}}
|
||||
title="Mark Read"
|
||||
title="Mark read"
|
||||
/>
|
||||
) : (
|
||||
<DropdownOption
|
||||
onSelect={() => {
|
||||
props.actionHandler('mark-unread')
|
||||
}}
|
||||
title="Mark Unread"
|
||||
title="Mark unread"
|
||||
/>
|
||||
)}
|
||||
<DropdownOption
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ function BarContent(props: HighlightBarProps): JSX.Element {
|
|||
<Button
|
||||
key={`color-${color}`}
|
||||
style="highlightBarIcon"
|
||||
title={`Create Highlight (${color})`}
|
||||
title={`Create highlight (${color})`}
|
||||
onClick={() => {
|
||||
if (!props.isNewHighlight && props.highlightColor != color) {
|
||||
props.handleButtonClick('updateColor', color)
|
||||
|
|
@ -151,7 +151,7 @@ function BarContent(props: HighlightBarProps): JSX.Element {
|
|||
{!props.isNewHighlight && (
|
||||
<>
|
||||
<Button
|
||||
title={`Set Labels`}
|
||||
title={`Set labels`}
|
||||
style="highlightBarIcon"
|
||||
onClick={() => props.handleButtonClick('setHighlightLabels')}
|
||||
onMouseEnter={() => {
|
||||
|
|
@ -174,7 +174,7 @@ function BarContent(props: HighlightBarProps): JSX.Element {
|
|||
)}
|
||||
|
||||
<Button
|
||||
title={props.isNewHighlight ? `Create Highlight w/note` : 'Add Note'}
|
||||
title={props.isNewHighlight ? `Create highlight w/note` : 'Add note'}
|
||||
style="highlightBarIcon"
|
||||
onClick={() => props.handleButtonClick('comment')}
|
||||
onMouseEnter={() => {
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export const LibraryHoverActions = (props: LibraryHoverActionsProps) => {
|
|||
}}
|
||||
>
|
||||
<Button
|
||||
title="View Notebook (t)"
|
||||
title="View notebook (t)"
|
||||
style="hoverActionIcon"
|
||||
onClick={(event) => {
|
||||
props.handleAction('open-notebook')
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@ export function ReaderDropdownMenu(props: DropdownMenuProps): JSX.Element {
|
|||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => props.articleActionHandler('setLabels')}
|
||||
title="Edit Labels (l)"
|
||||
title="Edit labels (l)"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => props.articleActionHandler('showEditModal')}
|
||||
title="Edit Info (i)"
|
||||
title="Edit info (i)"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => props.articleActionHandler('delete')}
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ export function UploadModal(props: UploadModalProps): JSX.Element {
|
|||
>
|
||||
<VStack distribution="start">
|
||||
<ModalTitleBar
|
||||
title="Upload File"
|
||||
title="Upload file"
|
||||
onOpenChange={props.onOpenChange}
|
||||
/>
|
||||
<Dropzone
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ export function AddBulkLabelsModal(
|
|||
<VStack distribution="start" css={{ height: '100%' }}>
|
||||
<SpanBox css={{ pt: '0px', px: '16px', width: '100%' }}>
|
||||
<ModalTitleBar
|
||||
title="Add Labels to selected items"
|
||||
title="Add labels to selected items"
|
||||
onOpenChange={props.onOpenChange}
|
||||
/>
|
||||
</SpanBox>
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export function ArticleActionsMenu(
|
|||
</Button>
|
||||
|
||||
<Button
|
||||
title="View Notebook (t)"
|
||||
title="View notebook (t)"
|
||||
style="articleActionIcon"
|
||||
onClick={() => props.articleActionHandler('showHighlights')}
|
||||
css={{
|
||||
|
|
@ -127,7 +127,7 @@ export function ArticleActionsMenu(
|
|||
</Button>
|
||||
|
||||
<Button
|
||||
title="Edit Info (i)"
|
||||
title="Edit info (i)"
|
||||
style="articleActionIcon"
|
||||
onClick={() => props.articleActionHandler('showEditModal')}
|
||||
css={{
|
||||
|
|
|
|||
|
|
@ -137,13 +137,13 @@ export function NotebookModal(props: NotebookModalProps): JSX.Element {
|
|||
onSelect={() => {
|
||||
exportHighlights()
|
||||
}}
|
||||
title="Export Notebook"
|
||||
title="Export notebook"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => {
|
||||
setShowConfirmDeleteNote(true)
|
||||
}}
|
||||
title="Delete Article Note"
|
||||
title="Delete article note"
|
||||
/>
|
||||
</Dropdown>
|
||||
<CloseButton close={handleClose} />
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export function VerticalArticleActionsMenu(
|
|||
</Button>
|
||||
|
||||
<Button
|
||||
title="Open Notebook (t)"
|
||||
title="Open notebook (t)"
|
||||
style="articleActionIcon"
|
||||
onClick={() => props.articleActionHandler('showHighlights')}
|
||||
css={{
|
||||
|
|
@ -70,7 +70,7 @@ export function VerticalArticleActionsMenu(
|
|||
</Button>
|
||||
|
||||
<Button
|
||||
title="Edit Info (i)"
|
||||
title="Edit info (i)"
|
||||
style="articleActionIcon"
|
||||
onClick={() => props.articleActionHandler('showEditModal')}
|
||||
css={{
|
||||
|
|
@ -142,7 +142,7 @@ export function VerticalArticleActionsMenu(
|
|||
</Button>
|
||||
)}
|
||||
<Button
|
||||
title="Display Settings (d)"
|
||||
title="Display settings (d)"
|
||||
style="articleActionIcon"
|
||||
onClick={() => props.articleActionHandler('editDisplaySettings')}
|
||||
css={{
|
||||
|
|
|
|||
|
|
@ -584,5 +584,5 @@ function EditButton(props: EditButtonProps): JSX.Element {
|
|||
{props.title}
|
||||
</SpanBox>
|
||||
</Link>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ function ControlButtonBox(props: ReaderHeaderProps): JSX.Element {
|
|||
}}
|
||||
>
|
||||
<Button
|
||||
title="Reader Preferences (d)"
|
||||
title="Reader preferences (d)"
|
||||
style="articleActionIcon"
|
||||
onClick={() => {
|
||||
props.showDisplaySettingsModal(true)
|
||||
|
|
|
|||
Loading…
Reference in a new issue