Merge pull request #3037 from omnivore-app/feat/ios-extension-cleanup

iOS Extension and notes cleanup
This commit is contained in:
Jackson Harper 2023-11-03 15:08:23 +08:00 committed by GitHub
commit 356a32727a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1421 additions and 838 deletions

View file

@ -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

View file

@ -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
}
}
}
}

View file

@ -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()
}))
}
}
}

View file

@ -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()
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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:

View file

@ -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)
}
}
}

View file

@ -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)
}

View file

@ -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) }
}
}

View file

@ -11,6 +11,7 @@ import SwiftUI
import Models
import Views
@MainActor
struct LabelsMasonaryView: View {
var onLabelTap: (LinkedItemLabel, TextChip) -> Void

View file

@ -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) {

View 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
}
}
}

View file

@ -12,6 +12,7 @@ import Services
import SwiftUI
import Views
@MainActor
struct LibraryTabView: View {
@EnvironmentObject var dataService: DataService

View file

@ -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() }
)
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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) {

View file

@ -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
}

View file

@ -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(

View file

@ -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))
}

View file

@ -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)

View file

@ -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(

View file

@ -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

View file

@ -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
}
}

View file

@ -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

View file

@ -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) }

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
)
}
}

View file

@ -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
}
}
}

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -13,7 +13,7 @@ import Utils
public struct SyncStatusIcon: View {
let status: ServerSyncStatus
init(status: ServerSyncStatus) {
public init(status: ServerSyncStatus) {
self.status = status
}

View file

@ -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

View file

@ -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
}

View file

@ -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)

View file

@ -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

View file

@ -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={() => {

View file

@ -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')

View file

@ -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')}

View file

@ -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

View file

@ -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>

View file

@ -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={{

View file

@ -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} />

View file

@ -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={{

View file

@ -584,5 +584,5 @@ function EditButton(props: EditButtonProps): JSX.Element {
{props.title}
</SpanBox>
</Link>
);
)
}

View file

@ -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)