diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift index 222ca0388..2437a21ed 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift @@ -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 diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift index 1602d0421..61814a5ad 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift @@ -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 + } } } } diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/AddNoteSheet.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/AddNoteSheet.swift new file mode 100644 index 000000000..6e9ff533d --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/AddNoteSheet.swift @@ -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() + })) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditInfoSheet.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditInfoSheet.swift new file mode 100644 index 000000000..45dea726b --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditInfoSheet.swift @@ -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() + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift new file mode 100644 index 000000000..3435aec05 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift @@ -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) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift index 2004f9600..db48203e2 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift @@ -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) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift index 37865d853..ad02df3d8 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift @@ -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 { diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedDisplayText.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedDisplayText.swift index eb57e0d4f..ebd765dc0 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedDisplayText.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedDisplayText.swift @@ -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: diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index dba244997..3078e3e44 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -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) } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index c39745961..1f34b82c9 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -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) } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift index ac264390e..b259cdbf0 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift @@ -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) } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsMasonaryView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsMasonaryView.swift index 65c53faa6..19cfef672 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsMasonaryView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsMasonaryView.swift @@ -11,6 +11,7 @@ import SwiftUI import Models import Views +@MainActor struct LabelsMasonaryView: View { var onLabelTap: (LinkedItemLabel, TextChip) -> Void diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift index 50b11ca6a..98b5dd8e2 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -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() + @Published var selectedLabels = [LinkedItemLabel]() @Published var unselectedLabels = Set() @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) { diff --git a/apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift b/apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift new file mode 100644 index 000000000..7a5141799 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift @@ -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, + 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) -> some View { + GeometryReader { geometry -> Color in + let rect = geometry.frame(in: .local) + DispatchQueue.main.async { + binding.wrappedValue = rect.size.height + } + return .clear + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift index 9e0739417..763bef4f6 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift @@ -12,6 +12,7 @@ import Services import SwiftUI import Views +@MainActor struct LibraryTabView: View { @EnvironmentObject var dataService: DataService diff --git a/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift b/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift index d7f81aef7..6258fee2f 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LinkedItemMetadataEditView.swift @@ -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() } ) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index e47f3682e..7f8a331b2 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -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 { diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationSettingsView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationSettingsView.swift index a0419ce83..60fb512f9 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationSettingsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/PushNotificationSettingsView.swift @@ -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 { diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift index bc381d013..00adc5c4a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift @@ -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 } diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift index abff913d1..c48252a58 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift @@ -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 } diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift index 24796a89f..2865d1887 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootViewModel.swift @@ -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 } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 040cc4f7c..942ea7cd2 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -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 { diff --git a/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift b/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift index bba4802ba..433cbabed 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift @@ -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) { diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift index b7c77bc5b..ab370b541 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift @@ -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 } diff --git a/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift b/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift index b9e819a32..686d5aab5 100644 --- a/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift +++ b/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift @@ -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( diff --git a/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift b/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift index 28004ce07..401a6a1ad 100644 --- a/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift +++ b/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift @@ -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)) } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift index 5e3fe80f1..880a88a44 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift @@ -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) diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index 32e46f8f0..e5df26be6 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -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( diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift index 41ad8b18f..8536e9d97 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift @@ -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 diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift index 28f8668d2..78117f8a0 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift @@ -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( 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 } } diff --git a/apple/OmnivoreKit/Sources/Utils/UIViewControllerExtensions.swift b/apple/OmnivoreKit/Sources/Utils/UIViewControllerExtensions.swift index 9b8fcf751..dbb9f2397 100644 --- a/apple/OmnivoreKit/Sources/Utils/UIViewControllerExtensions.swift +++ b/apple/OmnivoreKit/Sources/Utils/UIViewControllerExtensions.swift @@ -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 diff --git a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift index 5fffcf5ac..941dce537 100644 --- a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift +++ b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift @@ -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) } diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_circleButtonBackground.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_circleButtonBackground.colorset/Contents.json new file mode 100644 index 000000000..0488699a4 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_circleButtonBackground.colorset/Contents.json @@ -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 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_circleButtonForeground.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_circleButtonForeground.colorset/Contents.json new file mode 100644 index 000000000..2d6f53df7 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_circleButtonForeground.colorset/Contents.json @@ -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 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionBackground.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionBackground.colorset/Contents.json new file mode 100644 index 000000000..b5472bb11 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionBackground.colorset/Contents.json @@ -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 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionPanelBackground.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionPanelBackground.colorset/Contents.json new file mode 100644 index 000000000..d5f1a80a3 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionPanelBackground.colorset/Contents.json @@ -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 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionTextSubtle.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionTextSubtle.colorset/Contents.json new file mode 100644 index 000000000..e3b0ba879 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_extensionTextSubtle.colorset/Contents.json @@ -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 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_noteContainer.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_noteContainer.colorset/Contents.json new file mode 100644 index 000000000..f42ff6844 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_noteContainer.colorset/Contents.json @@ -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 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_textFieldBackground.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_textFieldBackground.colorset/Contents.json new file mode 100644 index 000000000..127e33e80 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_textFieldBackground.colorset/Contents.json @@ -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 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift deleted file mode 100644 index 9b5573ad8..000000000 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift +++ /dev/null @@ -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 - ) - } -} diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/LabelsFlowLayout.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/LabelsFlowLayout.swift index 424641247..633febdca 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/LabelsFlowLayout.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/LabelsFlowLayout.swift @@ -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) -> some View { + GeometryReader { geometry -> Color in + let rect = geometry.frame(in: .local) + DispatchQueue.main.async { + binding.wrappedValue = rect.size.height } + return .clear } } } diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift index 83350cd70..e718126cb 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift @@ -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) + } +} diff --git a/apple/OmnivoreKit/Sources/Views/SearchBar.swift b/apple/OmnivoreKit/Sources/Views/SearchBar.swift deleted file mode 100644 index e11c75ce5..000000000 --- a/apple/OmnivoreKit/Sources/Views/SearchBar.swift +++ /dev/null @@ -1,59 +0,0 @@ -import SwiftUI - -public struct SearchBar: View { - @Binding var searchTerm: String - @FocusState private var isFocused: Bool - - public init( - searchTerm: Binding - ) { - 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 - } - } -} diff --git a/apple/OmnivoreKit/Sources/Views/SyncingIcon.swift b/apple/OmnivoreKit/Sources/Views/SyncingIcon.swift index de0dda106..64e2432ba 100644 --- a/apple/OmnivoreKit/Sources/Views/SyncingIcon.swift +++ b/apple/OmnivoreKit/Sources/Views/SyncingIcon.swift @@ -13,7 +13,7 @@ import Utils public struct SyncStatusIcon: View { let status: ServerSyncStatus - init(status: ServerSyncStatus) { + public init(status: ServerSyncStatus) { self.status = status } diff --git a/apple/Sources/MainApp.swift b/apple/Sources/MainApp.swift index a5dc0dc0e..8f7151a84 100644 --- a/apple/Sources/MainApp.swift +++ b/apple/Sources/MainApp.swift @@ -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 diff --git a/apple/Sources/ShareExtension/ShareExtension.js b/apple/Sources/ShareExtension/ShareExtension.js index 4581ece82..dbbdab9c9 100644 --- a/apple/Sources/ShareExtension/ShareExtension.js +++ b/apple/Sources/ShareExtension/ShareExtension.js @@ -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 } diff --git a/apple/Sources/ShareExtension/ShareExtensionViewController.swift b/apple/Sources/ShareExtension/ShareExtensionViewController.swift index c35430eb3..73c8f6242 100644 --- a/apple/Sources/ShareExtension/ShareExtensionViewController.swift +++ b/apple/Sources/ShareExtension/ShareExtensionViewController.swift @@ -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) diff --git a/packages/web/components/patterns/CardMenu.tsx b/packages/web/components/patterns/CardMenu.tsx index 27b1207b4..3f190f15c 100644 --- a/packages/web/components/patterns/CardMenu.tsx +++ b/packages/web/components/patterns/CardMenu.tsx @@ -44,35 +44,35 @@ export function CardMenu(props: CardMenuProps): JSX.Element { onSelect={() => { props.actionHandler('set-labels') }} - title="Set Labels" + title="Set labels" /> { props.actionHandler('open-notebook') }} - title="Open Notebook" + title="Open notebook" /> props.actionHandler('showOriginal')} - title="Open Original" + title="Open original" /> props.actionHandler('editTitle')} - title="Edit Metadata" + title="Edit metadata" /> {props.item.readingProgressPercent < 98 ? ( { props.actionHandler('mark-read') }} - title="Mark Read" + title="Mark read" /> ) : ( { props.actionHandler('mark-unread') }} - title="Mark Unread" + title="Mark unread" /> )} { if (!props.isNewHighlight && props.highlightColor != color) { props.handleButtonClick('updateColor', color) @@ -151,7 +151,7 @@ function BarContent(props: HighlightBarProps): JSX.Element { {!props.isNewHighlight && ( <> )}