diff --git a/apple/Omnivore.xcodeproj/project.pbxproj b/apple/Omnivore.xcodeproj/project.pbxproj index 3141c8f72..0a4d64fc6 100644 --- a/apple/Omnivore.xcodeproj/project.pbxproj +++ b/apple/Omnivore.xcodeproj/project.pbxproj @@ -1156,7 +1156,7 @@ "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 11.3; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.UnitTests-MacApp"; @@ -1182,7 +1182,7 @@ "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 11.3; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.UnitTests-MacApp"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1200,7 +1200,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = QJF2XZ86HB; INFOPLIST_FILE = InfoPlists/iOSUITests.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.5; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1225,7 +1225,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = QJF2XZ86HB; INFOPLIST_FILE = InfoPlists/iOSUITests.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.5; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1260,7 +1260,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 1.4.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -1291,7 +1291,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 1.4.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac"; @@ -1316,7 +1316,7 @@ "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 11.3; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.UITests-Mac"; @@ -1341,7 +1341,7 @@ "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 11.3; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.UITests-Mac"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1373,7 +1373,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 1.4.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -1407,7 +1407,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 1.4.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; @@ -1458,7 +1458,7 @@ DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = InfoPlists/Omnivore.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1572,7 +1572,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 1.4.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -1610,7 +1610,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 1.4.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( @@ -1674,8 +1674,8 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; - MACOSX_DEPLOYMENT_TARGET = 10.15; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_NAME = "$(TARGET_NAME)"; VALIDATE_PRODUCT = YES; @@ -1690,7 +1690,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QJF2XZ86HB; INFOPLIST_FILE = InfoPlists/ShareExtension.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1745,7 +1745,7 @@ DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = InfoPlists/Omnivore.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1772,7 +1772,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QJF2XZ86HB; INFOPLIST_FILE = InfoPlists/ShareExtension.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1843,8 +1843,8 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; - MACOSX_DEPLOYMENT_TARGET = 10.15; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/apple/OmnivoreKit/Package.swift b/apple/OmnivoreKit/Package.swift index 33eb916f0..35e3008dd 100644 --- a/apple/OmnivoreKit/Package.swift +++ b/apple/OmnivoreKit/Package.swift @@ -6,8 +6,8 @@ let package = Package( name: "OmnivoreKit", defaultLocalization: "en", platforms: [ - .iOS(.v14), - .macOS(.v11) + .iOS(.v15), + .macOS(.v12) ], products: [ .library(name: "App", targets: ["App"]), @@ -17,7 +17,7 @@ let package = Package( .library(name: "Utils", targets: ["Utils"]) ], dependencies: [ - .package(name: "AppAuth", url: "https://github.com/openid/AppAuth-iOS.git", .upToNextMajor(from: "1.4.0")), + .package(url: "https://github.com/openid/AppAuth-iOS.git", .upToNextMajor(from: "1.4.0")), .package(url: "https://github.com/Square/Valet", from: "4.1.2"), .package(url: "https://github.com/maticzav/swift-graphql", from: "2.3.1"), .package(url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.4") @@ -37,7 +37,7 @@ let package = Package( .target( name: "Services", dependencies: [ - "AppAuth", + .product(name: "AppAuth", package: "AppAuth-iOS"), "Valet", .product(name: "SwiftGraphQL", package: "swift-graphql"), "Models", diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 7c5e60d82..46720c206 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -14,63 +14,43 @@ import Views var body: some View { Group { - if #available(iOS 15.0, *) { - HomeFeedView( - prefersListLayout: $prefersListLayout, - viewModel: viewModel - ) - .refreshable { - viewModel.loadItems(dataService: dataService, isRefresh: true) + HomeFeedView( + prefersListLayout: $prefersListLayout, + viewModel: viewModel + ) + .refreshable { + viewModel.loadItems(dataService: dataService, isRefresh: true) + } + .searchable( + text: $viewModel.searchTerm, + placement: .navigationBarDrawer + ) { + if viewModel.searchTerm.isEmpty { + Text("Inbox").searchCompletion("in:inbox ") + Text("All").searchCompletion("in:all ") + Text("Archived").searchCompletion("in:archive ") + Text("Files").searchCompletion("type:file ") } - .searchable( - text: $viewModel.searchQuery, - placement: .sidebar - ) { - if viewModel.searchQuery.isEmpty { - Text("Inbox").searchCompletion("in:inbox ") - Text("All").searchCompletion("in:all ") - Text("Archived").searchCompletion("in:archive ") - Text("Files").searchCompletion("type:file ") - } - } - .onChange(of: viewModel.searchQuery) { _ in - // Maybe we should debounce this, but - // it feels like it works ok without - viewModel.loadItems(dataService: dataService, isRefresh: true) - } - .onSubmit(of: .search) { - viewModel.loadItems(dataService: dataService, isRefresh: true) - } - .sheet(item: $viewModel.itemUnderLabelEdit) { item in - ApplyLabelsView(item: item) { labels in - viewModel.updateLabels(itemID: item.id, labels: labels) - } - } - } else { - HomeFeedView( - prefersListLayout: $prefersListLayout, - viewModel: viewModel - ) - .sheet(item: $viewModel.itemUnderLabelEdit) { item in - ApplyLabelsView(item: item) { labels in - viewModel.updateLabels(itemID: item.id, labels: labels) - } - } - .toolbar { - ToolbarItem { - if viewModel.isLoading { - Button(action: {}, label: { ProgressView() }) - } else { - Button( - action: { viewModel.loadItems(dataService: dataService, isRefresh: true) }, - label: { Label("Refresh Feed", systemImage: "arrow.clockwise") } - ) - } - } + } + .onChange(of: viewModel.searchTerm) { _ in + // Maybe we should debounce this, but + // it feels like it works ok without + viewModel.loadItems(dataService: dataService, isRefresh: true) + } + .onChange(of: viewModel.selectedLabels) { _ in + viewModel.loadItems(dataService: dataService, isRefresh: true) + } + .onSubmit(of: .search) { + viewModel.loadItems(dataService: dataService, isRefresh: true) + } + .sheet(item: $viewModel.itemUnderLabelEdit) { item in + ApplyLabelsView(mode: .item(item)) { labels in + viewModel.updateLabels(itemID: item.id, labels: labels) } } } .navigationTitle("Home") + .navigationBarTitleDisplayMode(.inline) .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in // Don't refresh the list if the user is currently reading an article if viewModel.selectedLinkItem == nil { @@ -99,26 +79,44 @@ import Views } } .onChange(of: viewModel.selectedLinkItem) { _ in - viewModel.commitProgressUpdates() + viewModel.commitItemUpdates() } } } struct HomeFeedView: View { @EnvironmentObject var dataService: DataService - @Binding var prefersListLayout: Bool - + @State private var showLabelsSheet = false @ObservedObject var viewModel: HomeFeedViewModel var body: some View { - if prefersListLayout { - HomeFeedListView(prefersListLayout: $prefersListLayout, viewModel: viewModel) - } else { - HomeFeedGridView(viewModel: viewModel) - .toolbar { - ToolbarItem { - if #available(iOS 15.0, *) { + VStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + TextChipButton.makeAddLabelButton { + showLabelsSheet = true + } + ForEach(viewModel.selectedLabels, id: \.self) { label in + TextChipButton.makeRemovableLabelButton(feedItemLabel: label) { + viewModel.selectedLabels.removeAll { $0.id == label.id } + } + } + Spacer() + } + .padding(.horizontal) + .sheet(isPresented: $showLabelsSheet) { + ApplyLabelsView(mode: .list(viewModel.selectedLabels)) { labels in + viewModel.selectedLabels = labels + } + } + } + if prefersListLayout { + HomeFeedListView(prefersListLayout: $prefersListLayout, viewModel: viewModel) + } else { + HomeFeedGridView(viewModel: viewModel) + .toolbar { + ToolbarItem { Button("", action: {}) .disabled(true) .overlay { @@ -126,28 +124,19 @@ import Views ProgressView() } } - } else { - if viewModel.isLoading { - Button(action: {}, label: { ProgressView() }) - } else { + } + ToolbarItem { + if UIDevice.isIPad { Button( - action: { viewModel.loadItems(dataService: dataService, isRefresh: true) }, - label: { Label("Refresh Feed", systemImage: "arrow.clockwise") } + action: { prefersListLayout.toggle() }, + label: { + Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet") + } ) } } } - ToolbarItem { - if UIDevice.isIPad { - Button( - action: { prefersListLayout.toggle() }, - label: { - Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet") - } - ) - } - } - } + } } } } @@ -165,11 +154,15 @@ import Views List { Section { ForEach(viewModel.items) { item in - let link = FeedCardNavigationLink( + FeedCardNavigationLink( item: item, viewModel: viewModel ) .contextMenu { + Button( + action: { viewModel.itemUnderLabelEdit = item }, + label: { Label("Edit Labels", systemImage: "tag") } + ) Button(action: { withAnimation(.linear(duration: 0.4)) { viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: !item.isArchived) @@ -196,61 +189,56 @@ import Views } } } - if #available(iOS 15.0, *) { - link - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - if !item.isArchived { - Button { - withAnimation(.linear(duration: 0.4)) { - viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true) - } - } label: { - Label("Archive", systemImage: "archivebox") - }.tint(.green) - } else { - Button { - withAnimation(.linear(duration: 0.4)) { - viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false) - } - } label: { - Label("Unarchive", systemImage: "tray.and.arrow.down.fill") - }.tint(.indigo) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + if !item.isArchived { + Button { + withAnimation(.linear(duration: 0.4)) { + viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true) + } + } label: { + Label("Archive", systemImage: "archivebox") + }.tint(.green) + } else { + Button { + withAnimation(.linear(duration: 0.4)) { + viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false) + } + } label: { + Label("Unarchive", systemImage: "tray.and.arrow.down.fill") + }.tint(.indigo) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button( + role: .destructive, + action: { + itemToRemove = item + confirmationShown = true + }, + label: { + Image(systemName: "trash") + } + ) + }.alert("Are you sure?", isPresented: $confirmationShown) { + Button("Remove Link", role: .destructive) { + if let itemToRemove = itemToRemove { + withAnimation { + viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id) } } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button( - role: .destructive, - action: { - itemToRemove = item - confirmationShown = true - }, - label: { - Image(systemName: "trash") - } - ) - }.alert("Are you sure?", isPresented: $confirmationShown) { - Button("Remove Link", role: .destructive) { - if let itemToRemove = itemToRemove { - withAnimation { - viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id) - } - } - self.itemToRemove = nil - } - Button("Cancel", role: .cancel) { self.itemToRemove = nil } - } - .swipeActions(edge: .leading, allowsFullSwipe: true) { - if FeatureFlag.enableSnooze { - Button { - viewModel.itemToSnooze = item - viewModel.snoozePresented = true - } label: { - Label { Text("Snooze") } icon: { Image.moon } - }.tint(.appYellow48) - } - } - } else { - link + self.itemToRemove = nil + } + Button("Cancel", role: .cancel) { self.itemToRemove = nil } + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if FeatureFlag.enableSnooze { + Button { + viewModel.itemToSnooze = item + viewModel.snoozePresented = true + } label: { + Label { Text("Snooze") } icon: { Image.moon } + }.tint(.appYellow48) + } } } } @@ -303,27 +291,22 @@ import Views ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 325), spacing: 24)], spacing: 24) { ForEach(viewModel.items) { item in - let link = GridCardNavigationLink( + GridCardNavigationLink( item: item, actionHandler: { contextMenuActionHandler(item: item, action: $0) }, isContextMenuOpen: $isContextMenuOpen, viewModel: viewModel ) - if #available(iOS 15.0, *) { - link - .alert("Are you sure?", isPresented: $confirmationShown) { - Button("Remove Link", role: .destructive) { - if let itemToRemove = itemToRemove { - withAnimation { - viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id) - } - } - self.itemToRemove = nil + .alert("Are you sure?", isPresented: $confirmationShown) { + Button("Remove Link", role: .destructive) { + if let itemToRemove = itemToRemove { + withAnimation { + viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id) } - Button("Cancel", role: .cancel) { self.itemToRemove = nil } } - } else { - link + self.itemToRemove = nil + } + Button("Cancel", role: .cancel) { self.itemToRemove = nil } } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift index 91cad42e8..3ca413ff5 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift @@ -15,15 +15,6 @@ import Views @ObservedObject var viewModel: HomeFeedViewModel var body: some View { - if #available(macOS 12.0, *) { - innerBody - } else { - innerBodyMac11 - } - } - - @available(macOS 12.0, *) - var innerBody: some View { List { Section { ForEach(viewModel.items) { item in @@ -77,17 +68,17 @@ import Views .listStyle(PlainListStyle()) .navigationTitle("Home") .searchable( - text: $viewModel.searchQuery, + text: $viewModel.searchTerm, placement: .toolbar ) { - if viewModel.searchQuery.isEmpty { + if viewModel.searchTerm.isEmpty { Text("Inbox").searchCompletion("in:inbox ") Text("All").searchCompletion("in:all ") Text("Archived").searchCompletion("in:archive ") Text("Files").searchCompletion("type:file ") } } - .onChange(of: viewModel.searchQuery) { _ in + .onChange(of: viewModel.searchTerm) { _ in // Maybe we should debounce this, but // it feels like it works ok without viewModel.loadItems(dataService: dataService, isRefresh: true) @@ -118,40 +109,6 @@ import Views } } } - - var innerBodyMac11: some View { - List { - Section { - ForEach(viewModel.items) { item in - FeedCardNavigationLink( - item: item, - viewModel: viewModel - ) - } - } - - if viewModel.isLoading { - LoadingSection() - } - } - .listStyle(PlainListStyle()) - .navigationTitle("Home") - .toolbar { - ToolbarItem { - Button( - action: { - viewModel.loadItems(dataService: dataService, isRefresh: true) - }, - label: { Label("Refresh Feed", systemImage: "arrow.clockwise") } - ) - } - } - .onAppear { - if viewModel.items.isEmpty { - viewModel.loadItems(dataService: dataService, isRefresh: true) - } - } - } } #endif diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 7a9af7d98..4ee67a777 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -11,11 +11,15 @@ final class HomeFeedViewModel: ObservableObject { /// Track progress updates to be committed when user navigates back to grid view var uncommittedReadingProgressUpdates = [String: Double]() + /// Track label updates to be committed when user navigates back to grid view + var uncommittedLabelUpdates = [String: [FeedItemLabel]]() + @Published var items = [FeedItem]() @Published var isLoading = false @Published var showPushNotificationPrimer = false @Published var itemUnderLabelEdit: FeedItem? - @Published var searchQuery = "" + @Published var searchTerm = "" + @Published var selectedLabels = [FeedItemLabel]() @Published var snoozePresented = false @Published var itemToSnooze: FeedItem? @Published var selectedLinkItem: FeedItem? @@ -68,7 +72,7 @@ final class HomeFeedViewModel: ObservableObject { dataService.libraryItemsPublisher( limit: 10, sortDescending: true, - searchQuery: searchQuery.isEmpty ? nil : searchQuery, + searchQuery: searchQuery, cursor: isRefresh ? nil : cursor ) .sink( @@ -172,14 +176,18 @@ final class HomeFeedViewModel: ObservableObject { .store(in: &subscriptions) } - /// Update `FeedItem`s with the cached reading progress values so it can animate when the + /// Update `FeedItem`s with the cached reading progress and label values so it can animate when the /// user navigates back to the grid view (and also avoid mutations of the grid items /// that can cause the `NavigationView` to pop. - func commitProgressUpdates() { + func commitItemUpdates() { for (key, value) in uncommittedReadingProgressUpdates { updateProgress(itemID: key, progress: value) } + for (key, value) in uncommittedLabelUpdates { + updateLabels(itemID: key, labels: value) + } uncommittedReadingProgressUpdates = [:] + uncommittedLabelUpdates = [:] } private func updateProgress(itemID: String, progress: Double) { @@ -190,9 +198,31 @@ final class HomeFeedViewModel: ObservableObject { } func updateLabels(itemID: String, labels: [FeedItemLabel]) { + // If item is being being displayed then delay the state update of labels until + // user is no longer reading the item. + if selectedLinkItem != nil { + uncommittedLabelUpdates[itemID] = labels + return + } + guard let item = items.first(where: { $0.id == itemID }) else { return } if let index = items.firstIndex(of: item) { items[index].labels = labels } } + + private var searchQuery: String? { + if searchTerm.isEmpty, selectedLabels.isEmpty { + return nil + } + + var query = searchTerm + + if !selectedLabels.isEmpty { + query.append(" label:") + query.append(selectedLabels.map(\.name).joined(separator: ",")) + } + + return query + } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift index 1c1348c94..5c81b1b70 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift @@ -4,7 +4,30 @@ import SwiftUI import Views struct ApplyLabelsView: View { - let item: FeedItem + enum Mode { + case item(FeedItem) + case list([FeedItemLabel]) + + var navTitle: String { + switch self { + case .item: + return "Assign Labels" + case .list: + return "Apply Label Filters" + } + } + + var confirmButtonText: String { + switch self { + case .item: + return "Save" + case .list: + return "Apply" + } + } + } + + let mode: Mode let commitLabelChanges: ([FeedItemLabel]) -> Void @EnvironmentObject var dataService: DataService @@ -15,10 +38,10 @@ struct ApplyLabelsView: View { var innerBody: some View { List { Section(header: Text("Assigned Labels")) { - if viewModel.selectedLabelsForItemInContext.isEmpty { + if viewModel.selectedLabels.isEmpty { Text("No labels are currently assigned.") } - ForEach(viewModel.selectedLabelsForItemInContext.applySearchFilter(labelSearchFilter), id: \.self) { label in + ForEach(viewModel.selectedLabels.applySearchFilter(labelSearchFilter), id: \.self) { label in HStack { TextChip(feedItemLabel: label) Spacer() @@ -28,13 +51,13 @@ struct ApplyLabelsView: View { viewModel.removeLabelFromItem(label) } }, - label: { Image(systemName: "trash").foregroundColor(.appGrayTextContrast) } + label: { Image(systemName: "xmark.circle").foregroundColor(.appGrayTextContrast) } ) } } } Section(header: Text("Available Labels")) { - ForEach(viewModel.unselectedLabelsForItemInContext.applySearchFilter(labelSearchFilter), id: \.self) { label in + ForEach(viewModel.unselectedLabels.applySearchFilter(labelSearchFilter), id: \.self) { label in HStack { TextChip(feedItemLabel: label) Spacer() @@ -63,7 +86,7 @@ struct ApplyLabelsView: View { .disabled(viewModel.isLoading) } } - .navigationTitle("Assign Labels") + .navigationTitle(mode.navTitle) #if os(iOS) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -76,12 +99,18 @@ struct ApplyLabelsView: View { ToolbarItem(placement: .navigationBarTrailing) { Button( action: { - viewModel.saveItemLabelChanges(itemID: item.id, dataService: dataService) { labels in - commitLabelChanges(labels) + switch mode { + case let .item(feedItem): + viewModel.saveItemLabelChanges(itemID: feedItem.id, dataService: dataService) { labels in + commitLabelChanges(labels) + presentationMode.wrappedValue.dismiss() + } + case .list: + commitLabelChanges(viewModel.selectedLabels) presentationMode.wrappedValue.dismiss() } }, - label: { Text("Save").foregroundColor(.appGrayTextContrast) } + label: { Text(mode.confirmButtonText).foregroundColor(.appGrayTextContrast) } ) } } @@ -97,22 +126,23 @@ struct ApplyLabelsView: View { EmptyView() } else { #if os(iOS) - if #available(iOS 15.0, *) { - innerBody - .searchable( - text: $labelSearchFilter, - placement: .navigationBarDrawer(displayMode: .always) - ) - } else { - innerBody - } + innerBody + .searchable( + text: $labelSearchFilter, + placement: .navigationBarDrawer(displayMode: .always) + ) #else innerBody #endif } } .onAppear { - viewModel.loadLabels(dataService: dataService, item: item) + switch mode { + case let .item(feedItem): + viewModel.loadLabels(dataService: dataService, item: feedItem) + case let .list(labels): + viewModel.loadLabels(dataService: dataService, initiallySelectedLabels: labels) + } } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift index 627305438..c86db2087 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift @@ -2,6 +2,7 @@ import Combine import Models import Services import SwiftUI +import Utils import Views struct LabelsView: View { @@ -15,25 +16,20 @@ struct LabelsView: View { var body: some View { Group { #if os(iOS) - if #available(iOS 15.0, *) { - Form { - innerBody - .alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) { - Button("Remove Link", role: .destructive) { - if let labelID = labelToRemoveID { - withAnimation { - viewModel.deleteLabel(dataService: dataService, labelID: labelID) - } + Form { + innerBody + .alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) { + Button("Delete Label", role: .destructive) { + if let labelID = labelToRemoveID { + withAnimation { + viewModel.deleteLabel(dataService: dataService, labelID: labelID) } - self.labelToRemoveID = nil } - Button("Cancel", role: .cancel) { self.labelToRemoveID = nil } + self.labelToRemoveID = nil } - } - } else { - Form { innerBody } + Button("Cancel", role: .cancel) { self.labelToRemoveID = nil } + } } - #elseif os(macOS) List { innerBody @@ -92,49 +88,141 @@ struct CreateLabelView: View { @State private var newLabelName = "" @State private var newLabelColor = Color.clear + var shouldDisableCreateButton: Bool { + viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear + } + + let rows = [ + GridItem(.fixed(60)), + GridItem(.fixed(60)), + GridItem(.fixed(70)) + ] + + let swatches: [Color] = { + let webSwatches = webSwatchHexes.map { Color(hex: $0) ?? .clear } + var additionalSwatches = swatchHexes.map { Color(hex: $0) ?? .clear }.shuffled() + let firstSwatch = additionalSwatches.remove(at: 0) + return [firstSwatch] + webSwatches + additionalSwatches + }() + var body: some View { NavigationView { - VStack(spacing: 16) { + VStack { + HStack { + if !newLabelName.isEmpty, newLabelColor != .clear { + TextChip(text: newLabelName, color: newLabelColor) + } else { + Text("Assign a name and color.").font(.appBody) + } + Spacer() + } + .padding(.bottom, 8) + TextField("Label Name", text: $newLabelName) #if os(iOS) .keyboardType(.alphabet) #endif .textFieldStyle(StandardTextFieldStyle()) - ColorPicker( - newLabelColor == .clear ? "Select Color" : newLabelColor.description, - selection: $newLabelColor - ) - Button( - action: { - viewModel.createLabel( - dataService: dataService, - name: newLabelName, - color: newLabelColor, - description: nil - ) - }, - label: { Text("Create") } - ) - .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) - .disabled(viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear) + + ScrollView(.horizontal, showsIndicators: false) { + LazyHGrid(rows: rows, alignment: .top, spacing: 20) { + ForEach(swatches, id: \.self) { swatch in + ZStack { + Circle() + .fill(swatch) + .frame(width: 50, height: 50) + .onTapGesture { + newLabelColor = swatch + } + .padding(10) + + if newLabelColor == swatch { + Circle() + .stroke(swatch, lineWidth: 5) + .frame(width: 60, height: 60) + } + } + } + } + } Spacer() } .padding() .toolbar { - ToolbarItem(placement: .automatic) { + ToolbarItem(placement: .barLeading) { Button( action: { viewModel.showCreateEmailModal = false }, - label: { - Image(systemName: "xmark") - .foregroundColor(.appGrayTextContrast) - } + label: { Text("Cancel").foregroundColor(.appGrayTextContrast) } ) } + ToolbarItem(placement: .barTrailing) { + Button( + action: { + viewModel.createLabel( + dataService: dataService, + name: newLabelName, + color: newLabelColor, + description: nil + ) + }, + label: { Text("Create").foregroundColor(.appGrayTextContrast) } + ) + .opacity(shouldDisableCreateButton ? 0.2 : 1) + .disabled(shouldDisableCreateButton) + } } .navigationTitle("Create New Label") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif + .onAppear { + newLabelColor = swatches.first ?? .clear + } } } } + +private let webSwatchHexes = [ + "#FF5D99", + "#7CFF7B", + "#FFD234", + "#7BE4FF", + "#CE88EF", + "#EF8C43" +] + +private let swatchHexes = [ + "#fff034", + "#efff34", + "#d1ff34", + "#b2ff34", + "#94ff34", + "#75ff34", + "#57ff34", + "#38ff34", + "#34ff4e", + "#34ff6d", + "#34ff8b", + "#34ffa9", + "#34ffc8", + "#34ffe6", + "#34f9ff", + "#34dbff", + "#34bcff", + "#349eff", + "#347fff", + "#3461ff", + "#3443ff", + "#4434ff", + "#6234ff", + "#8134ff", + "#9f34ff", + "#be34ff", + "#dc34ff", + "#fb34ff", + "#ff34e5", + "#ff34c7", + "#ff34a8", + "#ff348a", + "#ff346b" +] diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift index b38109c72..35159e8ca 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -7,14 +7,19 @@ import Views final class LabelsViewModel: ObservableObject { private var hasLoadedInitialLabels = false @Published var isLoading = false - @Published var selectedLabelsForItemInContext = [FeedItemLabel]() - @Published var unselectedLabelsForItemInContext = [FeedItemLabel]() + @Published var selectedLabels = [FeedItemLabel]() + @Published var unselectedLabels = [FeedItemLabel]() @Published var labels = [FeedItemLabel]() @Published var showCreateEmailModal = false var subscriptions = Set() - func loadLabels(dataService: DataService, item: FeedItem?) { + /// Loads initial set of labels when a edit labels list is displayed + /// - Parameters: + /// - dataService: `DataService` reference + /// - item: Optional `FeedItem` for applying labels to a single item + /// - initiallySelectedLabels: Optional `[FeedItemLabel]` for filtering a list of items + func loadLabels(dataService: DataService, item: FeedItem? = nil, initiallySelectedLabels: [FeedItemLabel]? = nil) { guard !hasLoadedInitialLabels else { return } isLoading = true @@ -25,11 +30,17 @@ final class LabelsViewModel: ObservableObject { self?.labels = allLabels self?.hasLoadedInitialLabels = true if let item = item { - self?.selectedLabelsForItemInContext = item.labels - self?.unselectedLabelsForItemInContext = allLabels.filter { label in + self?.selectedLabels = item.labels + self?.unselectedLabels = allLabels.filter { label in !item.labels.contains(where: { $0.id == label.id }) } } + if let initiallySelectedLabels = initiallySelectedLabels { + self?.selectedLabels = initiallySelectedLabels + self?.unselectedLabels = allLabels.filter { label in + !initiallySelectedLabels.contains(where: { $0.id == label.id }) + } + } } ) .store(in: &subscriptions) @@ -49,7 +60,7 @@ final class LabelsViewModel: ObservableObject { receiveValue: { [weak self] result in self?.isLoading = false self?.labels.insert(result, at: 0) - self?.unselectedLabelsForItemInContext.insert(result, at: 0) + self?.unselectedLabels.insert(result, at: 0) self?.showCreateEmailModal = false } ) @@ -73,7 +84,7 @@ final class LabelsViewModel: ObservableObject { func saveItemLabelChanges(itemID: String, dataService: DataService, onComplete: @escaping ([FeedItemLabel]) -> Void) { isLoading = true - dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabelsForItemInContext.map(\.id)).sink( + dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabels.map(\.id)).sink( receiveCompletion: { [weak self] _ in self?.isLoading = false }, @@ -83,12 +94,12 @@ final class LabelsViewModel: ObservableObject { } func addLabelToItem(_ label: FeedItemLabel) { - selectedLabelsForItemInContext.insert(label, at: 0) - unselectedLabelsForItemInContext.removeAll { $0.id == label.id } + selectedLabels.insert(label, at: 0) + unselectedLabels.removeAll { $0.id == label.id } } func removeLabelFromItem(_ label: FeedItemLabel) { - unselectedLabelsForItemInContext.insert(label, at: 0) - selectedLabelsForItemInContext.removeAll { $0.id == label.id } + unselectedLabels.insert(label, at: 0) + selectedLabels.removeAll { $0.id == label.id } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift index 2d83208f3..d267389e3 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift @@ -157,39 +157,6 @@ struct LinkItemDetailView: View { #endif } - var navBariOS14: some View { - HStack(alignment: .center) { - Button( - action: { self.presentationMode.wrappedValue.dismiss() }, - label: { - Image(systemName: "chevron.backward") - .font(.appTitleTwo) - .foregroundColor(.appGrayTextContrast) - .padding(.horizontal) - } - ) - .scaleEffect(navBarVisibilityRatio) - Spacer() - Button( - action: { showFontSizePopover.toggle() }, - label: { - Image(systemName: "textformat.size") - .font(.appTitleTwo) - } - ) - .padding(.horizontal) - .scaleEffect(navBarVisibilityRatio) - } - .frame(height: readerViewNavBarHeight * navBarVisibilityRatio) - .opacity(navBarVisibilityRatio) - .background(Color.systemBackground) - .onTapGesture { - showFontSizePopover = false - } - } - - @available(macOS 12.0, *) - @available(iOS 15.0, *) var navBar: some View { HStack(alignment: .center) { Button( @@ -286,47 +253,25 @@ struct LinkItemDetailView: View { } ) } - if #available(iOS 15.0, *) { - VStack(spacing: 0) { - navBar - Spacer() - } - .navigationBarHidden(true) - } else { - VStack(spacing: 0) { - navBariOS14 - Spacer() - } - .navigationBarHidden(true) - } - } - - } else { - if #available(iOS 15.0, *) { VStack(spacing: 0) { navBar Spacer() } - .onAppear { - viewModel.loadWebAppWrapper( - dataService: dataService, - rawAuthCookie: authenticator.omnivoreAuthCookieString - ) - } - .navigationBarHidden(true) - } else { - VStack(spacing: 0) { - navBariOS14 - Spacer() - } - .onAppear { - viewModel.loadWebAppWrapper( - dataService: dataService, - rawAuthCookie: authenticator.omnivoreAuthCookieString - ) - } .navigationBarHidden(true) } + + } else { + VStack(spacing: 0) { + navBar + Spacer() + } + .onAppear { + viewModel.loadWebAppWrapper( + dataService: dataService, + rawAuthCookie: authenticator.omnivoreAuthCookieString + ) + } + .navigationBarHidden(true) } } #endif diff --git a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift index aa8829a26..5121c2186 100644 --- a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift @@ -51,10 +51,8 @@ public struct PrimaryContentView: View { .introspectSplitViewController { $0.preferredSplitBehavior = .tile $0.preferredPrimaryColumnWidth = 200 - if #available(iOS 14.5, *) { - $0.presentsWithGesture = false - $0.displayModeButtonVisibility = .always - } + $0.presentsWithGesture = false + $0.displayModeButtonVisibility = .always } } #endif diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 4759c527b..d0c8cf42d 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -74,39 +74,6 @@ import WebKit } } - var navBariOS14: some View { - HStack(alignment: .center) { - Button( - action: { self.presentationMode.wrappedValue.dismiss() }, - label: { - Image(systemName: "chevron.backward") - .font(.appTitleTwo) - .foregroundColor(.appGrayTextContrast) - .padding(.horizontal) - } - ) - .scaleEffect(navBarVisibilityRatio) - Spacer() - Button( - action: { showFontSizePopover.toggle() }, - label: { - Image(systemName: "textformat.size") - .font(.appTitleTwo) - } - ) - .padding(.horizontal) - .scaleEffect(navBarVisibilityRatio) - } - .frame(height: readerViewNavBarHeight * navBarVisibilityRatio) - .opacity(navBarVisibilityRatio) - .background(Color.systemBackground) - .onTapGesture { - showFontSizePopover = false - } - } - - @available(macOS 12.0, *) - @available(iOS 15.0, *) var navBar: some View { HStack(alignment: .center) { Button( @@ -132,6 +99,10 @@ import WebKit Menu( content: { Group { + Button( + action: { homeFeedViewModel.itemUnderLabelEdit = item }, + label: { Label("Edit Labels", systemImage: "tag") } + ) Button( action: { homeFeedViewModel.setLinkArchived( @@ -260,19 +231,11 @@ import WebKit } ) } - if #available(iOS 15.0, *) { - VStack(spacing: 0) { - navBar - Spacer() - } - .navigationBarHidden(true) - } else { - VStack(spacing: 0) { - navBariOS14 - Spacer() - } - .navigationBarHidden(true) + VStack(spacing: 0) { + navBar + Spacer() } + .navigationBarHidden(true) }.onDisappear { // Clear the shared webview content when exiting diff --git a/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift b/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift index d2f1955a3..bdaebab00 100644 --- a/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift +++ b/apple/OmnivoreKit/Sources/Models/PageScrapePayload.swift @@ -45,7 +45,7 @@ public enum PageScraper { } var pageScrapePayload: PageScrapePayload? - let propertyListKey = String(kUTTypePropertyList) + let propertyListKey = UTType.propertyList.identifier let group = DispatchGroup() @@ -76,7 +76,7 @@ public enum PageScraper { extensionContext: NSExtensionContext?, completion: @escaping (Result) -> Void ) { - let urlKey = String(kUTTypeURL) + let urlKey = UTType.url.identifier // First look for a URL type let urlFound = extensionContext?.inputItems.first { inputItem in @@ -108,7 +108,7 @@ public enum PageScraper { return } - let textKey = String(kUTTypeText) + let textKey = UTType.text.identifier // We didn't find a URL type, so check for a string that contains a URL let textUrlFound = extensionContext?.inputItems.first { inputItem in let itemProvider = (inputItem as? NSExtensionItem)?.attachments?.first(where: { attachment in @@ -150,8 +150,8 @@ public enum PageScraper { extensionContext: NSExtensionContext?, completion: @escaping (Result) -> Void ) { - let urlKey = String(kUTTypeURL) - let textKey = String(kUTTypeUTF16PlainText) + let urlKey = UTType.url.identifier + let textKey = UTType.utf16PlainText.identifier let inputItem = extensionContext?.inputItems.first as? NSExtensionItem let itemProvider = inputItem?.attachments?.first(where: { attachment in attachment.hasItemConformingToTypeIdentifier(urlKey) diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index 6f50d6b8b..162b121e0 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -5177,7 +5177,6 @@ extension Objects { struct Highlight { let __typename: TypeName = .highlight let annotation: [String: String] - let article: [String: Objects.Article] let createdAt: [String: DateTime] let createdByMe: [String: Bool] let id: [String: String] @@ -5214,10 +5213,6 @@ extension Objects.Highlight: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } - case "article": - if let value = try container.decode(Objects.Article?.self, forKey: codingKey) { - map.set(key: field, hash: alias, value: value as Any) - } case "createdAt": if let value = try container.decode(DateTime?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -5281,7 +5276,6 @@ extension Objects.Highlight: Decodable { } annotation = map["annotation"] - article = map["article"] createdAt = map["createdAt"] createdByMe = map["createdByMe"] id = map["id"] @@ -5354,25 +5348,6 @@ extension Fields where TypeLock == Objects.Highlight { } } - func article(selection: Selection) throws -> Type { - let field = GraphQLField.composite( - name: "article", - arguments: [], - selection: selection.selection - ) - select(field) - - switch response { - case let .decoding(data): - if let data = data.article[field.alias!] { - return try selection.decode(data: data) - } - throw HttpError.badpayload - case .mocking: - return selection.mock() - } - } - func quote() throws -> String { let field = GraphQLField.leaf( name: "quote", @@ -11260,6 +11235,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeleteLabelError = Selection } +extension Objects { + struct UpdateLabelSuccess { + let __typename: TypeName = .updateLabelSuccess + let label: [String: Objects.Label] + + enum TypeName: String, Codable { + case updateLabelSuccess = "UpdateLabelSuccess" + } + } +} + +extension Objects.UpdateLabelSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "label": + if let value = try container.decode(Objects.Label?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + label = map["label"] + } +} + +extension Fields where TypeLock == Objects.UpdateLabelSuccess { + func label(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "label", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.label[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdateLabelSuccess = Selection +} + +extension Objects { + struct UpdateLabelError { + let __typename: TypeName = .updateLabelError + let errorCodes: [String: [Enums.UpdateLabelErrorCode]] + + enum TypeName: String, Codable { + case updateLabelError = "UpdateLabelError" + } + } +} + +extension Objects.UpdateLabelError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.UpdateLabelErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.UpdateLabelError { + func errorCodes() throws -> [Enums.UpdateLabelErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdateLabelError = Selection +} + extension Objects { struct SignupSuccess { let __typename: TypeName = .signupSuccess @@ -11522,6 +11628,910 @@ extension Selection where TypeLock == Never, Type == Never { typealias SetLabelsError = Selection } +extension Objects { + struct GenerateApiKeySuccess { + let __typename: TypeName = .generateApiKeySuccess + let apiKey: [String: String] + + enum TypeName: String, Codable { + case generateApiKeySuccess = "GenerateApiKeySuccess" + } + } +} + +extension Objects.GenerateApiKeySuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "apiKey": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + apiKey = map["apiKey"] + } +} + +extension Fields where TypeLock == Objects.GenerateApiKeySuccess { + func apiKey() throws -> String { + let field = GraphQLField.leaf( + name: "apiKey", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.apiKey[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GenerateApiKeySuccess = Selection +} + +extension Objects { + struct GenerateApiKeyError { + let __typename: TypeName = .generateApiKeyError + let errorCodes: [String: [Enums.GenerateApiKeyErrorCode]] + + enum TypeName: String, Codable { + case generateApiKeyError = "GenerateApiKeyError" + } + } +} + +extension Objects.GenerateApiKeyError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.GenerateApiKeyErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.GenerateApiKeyError { + func errorCodes() throws -> [Enums.GenerateApiKeyErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GenerateApiKeyError = Selection +} + +extension Objects { + struct SearchItem { + let __typename: TypeName = .searchItem + let annotation: [String: String] + let author: [String: String] + let contentReader: [String: Enums.ContentReader] + let createdAt: [String: DateTime] + let description: [String: String] + let id: [String: String] + let image: [String: String] + let isArchived: [String: Bool] + let labels: [String: [Objects.Label]] + let originalArticleUrl: [String: String] + let ownedByViewer: [String: Bool] + let pageId: [String: String] + let pageType: [String: Enums.PageType] + let publishedAt: [String: DateTime] + let quote: [String: String] + let readingProgressAnchorIndex: [String: Int] + let readingProgressPercent: [String: Double] + let shortId: [String: String] + let slug: [String: String] + let title: [String: String] + let uploadFileId: [String: String] + let url: [String: String] + + enum TypeName: String, Codable { + case searchItem = "SearchItem" + } + } +} + +extension Objects.SearchItem: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "annotation": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "author": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "contentReader": + if let value = try container.decode(Enums.ContentReader?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "createdAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "description": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "image": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "isArchived": + if let value = try container.decode(Bool?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "labels": + if let value = try container.decode([Objects.Label]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "originalArticleUrl": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "ownedByViewer": + if let value = try container.decode(Bool?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageType": + if let value = try container.decode(Enums.PageType?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "publishedAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "quote": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "readingProgressAnchorIndex": + if let value = try container.decode(Int?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "readingProgressPercent": + if let value = try container.decode(Double?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "shortId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "slug": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "title": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "uploadFileId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "url": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + annotation = map["annotation"] + author = map["author"] + contentReader = map["contentReader"] + createdAt = map["createdAt"] + description = map["description"] + id = map["id"] + image = map["image"] + isArchived = map["isArchived"] + labels = map["labels"] + originalArticleUrl = map["originalArticleUrl"] + ownedByViewer = map["ownedByViewer"] + pageId = map["pageId"] + pageType = map["pageType"] + publishedAt = map["publishedAt"] + quote = map["quote"] + readingProgressAnchorIndex = map["readingProgressAnchorIndex"] + readingProgressPercent = map["readingProgressPercent"] + shortId = map["shortId"] + slug = map["slug"] + title = map["title"] + uploadFileId = map["uploadFileId"] + url = map["url"] + } +} + +extension Fields where TypeLock == Objects.SearchItem { + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func title() throws -> String { + let field = GraphQLField.leaf( + name: "title", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.title[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func slug() throws -> String { + let field = GraphQLField.leaf( + name: "slug", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.slug[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func url() throws -> String { + let field = GraphQLField.leaf( + name: "url", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.url[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func pageType() throws -> Enums.PageType { + let field = GraphQLField.leaf( + name: "pageType", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.pageType[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Enums.PageType.allCases.first! + } + } + + func contentReader() throws -> Enums.ContentReader { + let field = GraphQLField.leaf( + name: "contentReader", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.contentReader[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Enums.ContentReader.allCases.first! + } + } + + func createdAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "createdAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.createdAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } + + func isArchived() throws -> Bool { + let field = GraphQLField.leaf( + name: "isArchived", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.isArchived[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Bool.mockValue + } + } + + func readingProgressPercent() throws -> Double? { + let field = GraphQLField.leaf( + name: "readingProgressPercent", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.readingProgressPercent[field.alias!] + case .mocking: + return nil + } + } + + func readingProgressAnchorIndex() throws -> Int? { + let field = GraphQLField.leaf( + name: "readingProgressAnchorIndex", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.readingProgressAnchorIndex[field.alias!] + case .mocking: + return nil + } + } + + func author() throws -> String? { + let field = GraphQLField.leaf( + name: "author", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.author[field.alias!] + case .mocking: + return nil + } + } + + func image() throws -> String? { + let field = GraphQLField.leaf( + name: "image", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.image[field.alias!] + case .mocking: + return nil + } + } + + func description() throws -> String? { + let field = GraphQLField.leaf( + name: "description", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.description[field.alias!] + case .mocking: + return nil + } + } + + func publishedAt() throws -> DateTime? { + let field = GraphQLField.leaf( + name: "publishedAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.publishedAt[field.alias!] + case .mocking: + return nil + } + } + + func ownedByViewer() throws -> Bool? { + let field = GraphQLField.leaf( + name: "ownedByViewer", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.ownedByViewer[field.alias!] + case .mocking: + return nil + } + } + + func originalArticleUrl() throws -> String? { + let field = GraphQLField.leaf( + name: "originalArticleUrl", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.originalArticleUrl[field.alias!] + case .mocking: + return nil + } + } + + func uploadFileId() throws -> String? { + let field = GraphQLField.leaf( + name: "uploadFileId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.uploadFileId[field.alias!] + case .mocking: + return nil + } + } + + func pageId() throws -> String? { + let field = GraphQLField.leaf( + name: "pageId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.pageId[field.alias!] + case .mocking: + return nil + } + } + + func shortId() throws -> String? { + let field = GraphQLField.leaf( + name: "shortId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.shortId[field.alias!] + case .mocking: + return nil + } + } + + func quote() throws -> String? { + let field = GraphQLField.leaf( + name: "quote", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.quote[field.alias!] + case .mocking: + return nil + } + } + + func annotation() throws -> String? { + let field = GraphQLField.leaf( + name: "annotation", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.annotation[field.alias!] + case .mocking: + return nil + } + } + + func labels(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "labels", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.labels[field.alias!]) + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SearchItem = Selection +} + +extension Objects { + struct SearchItemEdge { + let __typename: TypeName = .searchItemEdge + let cursor: [String: String] + let node: [String: Objects.SearchItem] + + enum TypeName: String, Codable { + case searchItemEdge = "SearchItemEdge" + } + } +} + +extension Objects.SearchItemEdge: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "cursor": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "node": + if let value = try container.decode(Objects.SearchItem?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + cursor = map["cursor"] + node = map["node"] + } +} + +extension Fields where TypeLock == Objects.SearchItemEdge { + func cursor() throws -> String { + let field = GraphQLField.leaf( + name: "cursor", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.cursor[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func node(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "node", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.node[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SearchItemEdge = Selection +} + +extension Objects { + struct SearchSuccess { + let __typename: TypeName = .searchSuccess + let edges: [String: [Objects.SearchItemEdge]] + let pageInfo: [String: Objects.PageInfo] + + enum TypeName: String, Codable { + case searchSuccess = "SearchSuccess" + } + } +} + +extension Objects.SearchSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "edges": + if let value = try container.decode([Objects.SearchItemEdge]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageInfo": + if let value = try container.decode(Objects.PageInfo?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + edges = map["edges"] + pageInfo = map["pageInfo"] + } +} + +extension Fields where TypeLock == Objects.SearchSuccess { + func edges(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "edges", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.edges[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + + func pageInfo(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "pageInfo", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.pageInfo[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SearchSuccess = Selection +} + +extension Objects { + struct SearchError { + let __typename: TypeName = .searchError + let errorCodes: [String: [Enums.SearchErrorCode]] + + enum TypeName: String, Codable { + case searchError = "SearchError" + } + } +} + +extension Objects.SearchError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.SearchErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.SearchError { + func errorCodes() throws -> [Enums.SearchErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SearchError = Selection +} + extension Objects { struct Mutation { let __typename: TypeName = .mutation @@ -11539,6 +12549,7 @@ extension Objects { let deleteNewsletterEmail: [String: Unions.DeleteNewsletterEmailResult] let deleteReaction: [String: Unions.DeleteReactionResult] let deleteReminder: [String: Unions.DeleteReminderResult] + let generateApiKey: [String: Unions.GenerateApiKeyResult] let googleLogin: [String: Unions.LoginResult] let googleSignup: [String: Unions.GoogleSignupResult] let logOut: [String: Unions.LogOutResult] @@ -11560,6 +12571,7 @@ extension Objects { let signup: [String: Unions.SignupResult] let updateHighlight: [String: Unions.UpdateHighlightResult] let updateHighlightReply: [String: Unions.UpdateHighlightReplyResult] + let updateLabel: [String: Unions.UpdateLabelResult] let updateLinkShareInfo: [String: Unions.UpdateLinkShareInfoResult] let updateReminder: [String: Unions.UpdateReminderResult] let updateSharedComment: [String: Unions.UpdateSharedCommentResult] @@ -11641,6 +12653,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.DeleteReminderResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "generateApiKey": + if let value = try container.decode(Unions.GenerateApiKeyResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "googleLogin": if let value = try container.decode(Unions.LoginResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11725,6 +12741,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.UpdateHighlightReplyResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "updateLabel": + if let value = try container.decode(Unions.UpdateLabelResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "updateLinkShareInfo": if let value = try container.decode(Unions.UpdateLinkShareInfoResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11773,6 +12793,7 @@ extension Objects.Mutation: Decodable { deleteNewsletterEmail = map["deleteNewsletterEmail"] deleteReaction = map["deleteReaction"] deleteReminder = map["deleteReminder"] + generateApiKey = map["generateApiKey"] googleLogin = map["googleLogin"] googleSignup = map["googleSignup"] logOut = map["logOut"] @@ -11794,6 +12815,7 @@ extension Objects.Mutation: Decodable { signup = map["signup"] updateHighlight = map["updateHighlight"] updateHighlightReply = map["updateHighlightReply"] + updateLabel = map["updateLabel"] updateLinkShareInfo = map["updateLinkShareInfo"] updateReminder = map["updateReminder"] updateSharedComment = map["updateSharedComment"] @@ -12507,6 +13529,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func updateLabel(input: InputObjects.UpdateLabelInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "updateLabel", + arguments: [Argument(name: "input", type: "UpdateLabelInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.updateLabel[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func deleteLabel(id: String, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "deleteLabel", @@ -12582,6 +13623,25 @@ extension Fields where TypeLock == Objects.Mutation { return selection.mock() } } + + func generateApiKey(scope: OptionalArgument = .absent(), selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "generateApiKey", + arguments: [Argument(name: "scope", type: "String", value: scope)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.generateApiKey[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } } extension Selection where TypeLock == Never, Type == Never { @@ -12603,6 +13663,7 @@ extension Objects { let me: [String: Objects.User] let newsletterEmails: [String: Unions.NewsletterEmailsResult] let reminder: [String: Unions.ReminderResult] + let search: [String: Unions.SearchResult] let sharedArticle: [String: Unions.SharedArticleResult] let user: [String: Unions.UserResult] let users: [String: Unions.UsersResult] @@ -12674,6 +13735,10 @@ extension Objects.Query: Decodable { if let value = try container.decode(Unions.ReminderResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "search": + if let value = try container.decode(Unions.SearchResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "sharedArticle": if let value = try container.decode(Unions.SharedArticleResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -12712,6 +13777,7 @@ extension Objects.Query: Decodable { me = map["me"] newsletterEmails = map["newsletterEmails"] reminder = map["reminder"] + search = map["search"] sharedArticle = map["sharedArticle"] user = map["user"] users = map["users"] @@ -13015,6 +14081,25 @@ extension Fields where TypeLock == Objects.Query { return selection.mock() } } + + func search(after: OptionalArgument = .absent(), first: OptionalArgument = .absent(), query: OptionalArgument = .absent(), selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "search", + arguments: [Argument(name: "after", type: "String", value: after), Argument(name: "first", type: "Int", value: first), Argument(name: "query", type: "String", value: query)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.search[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } } extension Selection where TypeLock == Never, Type == Never { @@ -16658,6 +17743,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeleteLabelResult = Selection } +extension Unions { + struct UpdateLabelResult { + let __typename: TypeName + let errorCodes: [String: [Enums.UpdateLabelErrorCode]] + let label: [String: Objects.Label] + + enum TypeName: String, Codable { + case updateLabelSuccess = "UpdateLabelSuccess" + case updateLabelError = "UpdateLabelError" + } + } +} + +extension Unions.UpdateLabelResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.UpdateLabelErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "label": + if let value = try container.decode(Objects.Label?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + label = map["label"] + } +} + +extension Fields where TypeLock == Unions.UpdateLabelResult { + func on(updateLabelSuccess: Selection, updateLabelError: Selection) throws -> Type { + select([GraphQLField.fragment(type: "UpdateLabelSuccess", selection: updateLabelSuccess.selection), GraphQLField.fragment(type: "UpdateLabelError", selection: updateLabelError.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .updateLabelSuccess: + let data = Objects.UpdateLabelSuccess(label: data.label) + return try updateLabelSuccess.decode(data: data) + case .updateLabelError: + let data = Objects.UpdateLabelError(errorCodes: data.errorCodes) + return try updateLabelError.decode(data: data) + } + case .mocking: + return updateLabelSuccess.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdateLabelResult = Selection +} + extension Unions { struct SignupResult { let __typename: TypeName @@ -16806,6 +17965,160 @@ extension Selection where TypeLock == Never, Type == Never { typealias SetLabelsResult = Selection } +extension Unions { + struct GenerateApiKeyResult { + let __typename: TypeName + let apiKey: [String: String] + let errorCodes: [String: [Enums.GenerateApiKeyErrorCode]] + + enum TypeName: String, Codable { + case generateApiKeySuccess = "GenerateApiKeySuccess" + case generateApiKeyError = "GenerateApiKeyError" + } + } +} + +extension Unions.GenerateApiKeyResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "apiKey": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "errorCodes": + if let value = try container.decode([Enums.GenerateApiKeyErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + apiKey = map["apiKey"] + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Unions.GenerateApiKeyResult { + func on(generateApiKeySuccess: Selection, generateApiKeyError: Selection) throws -> Type { + select([GraphQLField.fragment(type: "GenerateApiKeySuccess", selection: generateApiKeySuccess.selection), GraphQLField.fragment(type: "GenerateApiKeyError", selection: generateApiKeyError.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .generateApiKeySuccess: + let data = Objects.GenerateApiKeySuccess(apiKey: data.apiKey) + return try generateApiKeySuccess.decode(data: data) + case .generateApiKeyError: + let data = Objects.GenerateApiKeyError(errorCodes: data.errorCodes) + return try generateApiKeyError.decode(data: data) + } + case .mocking: + return generateApiKeySuccess.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GenerateApiKeyResult = Selection +} + +extension Unions { + struct SearchResult { + let __typename: TypeName + let edges: [String: [Objects.SearchItemEdge]] + let errorCodes: [String: [Enums.SearchErrorCode]] + let pageInfo: [String: Objects.PageInfo] + + enum TypeName: String, Codable { + case searchSuccess = "SearchSuccess" + case searchError = "SearchError" + } + } +} + +extension Unions.SearchResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "edges": + if let value = try container.decode([Objects.SearchItemEdge]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "errorCodes": + if let value = try container.decode([Enums.SearchErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageInfo": + if let value = try container.decode(Objects.PageInfo?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + edges = map["edges"] + errorCodes = map["errorCodes"] + pageInfo = map["pageInfo"] + } +} + +extension Fields where TypeLock == Unions.SearchResult { + func on(searchSuccess: Selection, searchError: Selection) throws -> Type { + select([GraphQLField.fragment(type: "SearchSuccess", selection: searchSuccess.selection), GraphQLField.fragment(type: "SearchError", selection: searchError.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .searchSuccess: + let data = Objects.SearchSuccess(edges: data.edges, pageInfo: data.pageInfo) + return try searchSuccess.decode(data: data) + case .searchError: + let data = Objects.SearchError(errorCodes: data.errorCodes) + return try searchError.decode(data: data) + } + case .mocking: + return searchSuccess.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SearchResult = Selection +} + // MARK: - Enums enum Enums {} @@ -16957,6 +18270,8 @@ extension Enums { case website = "WEBSITE" + case highlights = "HIGHLIGHTS" + case unknown = "UNKNOWN" } } @@ -17421,6 +18736,19 @@ extension Enums { } } +extension Enums { + /// UpdateLabelErrorCode + enum UpdateLabelErrorCode: String, CaseIterable, Codable { + case unauthorized = "UNAUTHORIZED" + + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case forbidden = "FORBIDDEN" + } +} + extension Enums { /// SetLabelsErrorCode enum SetLabelsErrorCode: String, CaseIterable, Codable { @@ -17432,6 +18760,20 @@ extension Enums { } } +extension Enums { + /// GenerateApiKeyErrorCode + enum GenerateApiKeyErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + } +} + +extension Enums { + /// SearchErrorCode + enum SearchErrorCode: String, CaseIterable, Codable { + case unauthorized = "UNAUTHORIZED" + } +} + // MARK: - Input Objects enum InputObjects {} @@ -18293,6 +19635,33 @@ extension InputObjects { } } +extension InputObjects { + struct UpdateLabelInput: Encodable, Hashable { + var labelId: String + + var color: String + + var description: OptionalArgument = .absent() + + var name: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(labelId, forKey: .labelId) + try container.encode(color, forKey: .color) + if description.hasValue { try container.encode(description, forKey: .description) } + try container.encode(name, forKey: .name) + } + + enum CodingKeys: String, CodingKey { + case labelId + case color + case description + case name + } + } +} + extension InputObjects { struct LoginInput: Encodable, Hashable { var password: String @@ -18349,18 +19718,18 @@ extension InputObjects { extension InputObjects { struct SetLabelsInput: Encodable, Hashable { - var linkId: String + var pageId: String var labelIds: [String] func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(linkId, forKey: .linkId) + try container.encode(pageId, forKey: .pageId) try container.encode(labelIds, forKey: .labelIds) } enum CodingKeys: String, CodingKey { - case linkId + case pageId case labelIds } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift index fce103d6a..0809b693a 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift @@ -20,7 +20,7 @@ public extension DataService { let mutation = Selection.Mutation { try $0.setLabels( input: InputObjects.SetLabelsInput( - linkId: itemID, + pageId: itemID, labelIds: labelIDs ), selection: selection diff --git a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift index 03c20e65d..f3ea2e262 100644 --- a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift +++ b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift @@ -22,18 +22,14 @@ public extension Color { static var systemBackground: Color { Color(.systemBackground) } static var systemPlaceholder: Color { Color(.placeholderText) } static var secondarySystemGroupedBackground: Color { Color(.secondarySystemGroupedBackground) } - static var systemLabel: Color { - if #available(iOS 15.0, *) { - return Color(uiColor: .label) - } else { - return Color.appGrayTextContrast - } - } + static var systemGray6: Color { Color(.systemGray6) } + static var systemLabel: Color { Color(uiColor: .label) } #elseif os(macOS) static var systemBackground: Color { Color(.windowBackgroundColor) } static var systemPlaceholder: Color { Color(.placeholderTextColor) } static var systemLabel: Color { Color(.labelColor) } + static var systemGray6: Color { Color(NSColor.systemGray) } // Just for compilation. secondarySystemGroupedBackground shouldn't be used on macOS static var secondarySystemGroupedBackground: Color { Color(.windowBackgroundColor) } diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift index 55fef0e3b..692e430ad 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift @@ -68,21 +68,9 @@ public struct GridCard: View { VStack(alignment: .leading, spacing: 16) { // Progress Bar Group { - // Remove os check when dropping macOS 11 - #if os(iOS) - if #available(iOS 15.0, *) { - ProgressView(value: min(abs(item.readingProgress) / 100, 1)) - .tint(.appYellow48) - .frame(maxWidth: .infinity, alignment: .leading) - } else { - Color.clear - .contentShape(Rectangle()) - .frame(maxWidth: .infinity, alignment: .leading) - } - #else - ProgressView(value: max(abs(item.readingProgress) / 100, 1)) - .frame(maxWidth: .infinity, alignment: .leading) - #endif + ProgressView(value: min(abs(item.readingProgress) / 100, 1)) + .tint(.appYellow48) + .frame(maxWidth: .infinity, alignment: .leading) } .onTapGesture { tapHandler() } diff --git a/apple/OmnivoreKit/Sources/Views/FormSheetWrapper.swift b/apple/OmnivoreKit/Sources/Views/FormSheetWrapper.swift index a998cf4d3..85fc0d33c 100644 --- a/apple/OmnivoreKit/Sources/Views/FormSheetWrapper.swift +++ b/apple/OmnivoreKit/Sources/Views/FormSheetWrapper.swift @@ -30,14 +30,13 @@ import SwiftUI let controller = UIHostingController(rootView: content()) if controller.traitCollection.userInterfaceIdiom == .phone { - if #available(iOS 15, *) { - if let sheet = controller.sheetPresentationController { - sheet.preferredCornerRadius = 16 - sheet.prefersGrabberVisible = false - sheet.detents = [.medium()] - sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true - } + if let sheet = controller.sheetPresentationController { + sheet.preferredCornerRadius = 16 + sheet.prefersGrabberVisible = false + sheet.detents = [.medium()] + sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true } + controller.modalPresentationStyle = .pageSheet } else { controller.view.sizeToFit() diff --git a/apple/OmnivoreKit/Sources/Views/PushNotifications/PushNotificationPrimerView.swift b/apple/OmnivoreKit/Sources/Views/PushNotifications/PushNotificationPrimerView.swift index b2a005338..6d2f80366 100644 --- a/apple/OmnivoreKit/Sources/Views/PushNotifications/PushNotificationPrimerView.swift +++ b/apple/OmnivoreKit/Sources/Views/PushNotifications/PushNotificationPrimerView.swift @@ -14,7 +14,7 @@ import SwiftUI self.denyAction = denyAction } - var content: some View { + public var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { Image @@ -45,14 +45,7 @@ import SwiftUI .foregroundColor(Color.appTextDefault) .cornerRadius(8) .frame(maxWidth: min(UIScreen.main.bounds.width - 20, 320)) - } - - public var body: some View { - if #available(iOS 15.0, *) { - content.interactiveDismissDisabled() - } else { - content - } + .interactiveDismissDisabled() } } diff --git a/apple/OmnivoreKit/Sources/Views/TextChip.swift b/apple/OmnivoreKit/Sources/Views/TextChip.swift index cb55573e8..c07ce3463 100644 --- a/apple/OmnivoreKit/Sources/Views/TextChip.swift +++ b/apple/OmnivoreKit/Sources/Views/TextChip.swift @@ -30,3 +30,84 @@ public struct TextChip: View { .cornerRadius(cornerRadius) } } + +public struct TextChipButton: View { + public static func makeAddLabelButton(onTap: @escaping () -> Void) -> TextChipButton { + TextChipButton(title: "Labels", color: .systemGray6, actionType: .show, onTap: onTap) + } + + public static func makeShowOptionsButton(title: String, onTap: @escaping () -> Void) -> TextChipButton { + TextChipButton(title: title, color: .appButtonBackground, actionType: .add, onTap: onTap) + } + + public static func makeRemovableLabelButton( + feedItemLabel: FeedItemLabel, + onTap: @escaping () -> Void + ) -> TextChipButton { + TextChipButton( + title: feedItemLabel.name, + color: Color(hex: feedItemLabel.color) ?? .appButtonBackground, + actionType: .remove, + onTap: onTap + ) + } + + public enum ActionType { + case remove + case add + case show + + var systemIconName: String { + switch self { + case .remove: + return "xmark" + case .add: + return "plus" + case .show: + return "chevron.down" + } + } + } + + init(title: String, color: Color, actionType: ActionType, onTap: @escaping () -> Void) { + self.text = title + self.color = color + self.onTap = onTap + self.actionType = actionType + self.foregroundColor = { + if actionType == .show { + return .appGrayText + } + return color.isDark ? .white : .black + }() + } + + let text: String + let color: Color + let onTap: () -> Void + let actionType: ActionType + let cornerRadius = 20.0 + let foregroundColor: Color + + public var body: some View { + Button(action: onTap) { + VStack(spacing: 0) { + HStack { + Text(text) + .padding(.leading, 3) + Image(systemName: actionType.systemIconName) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .font(.appFootnote) + .foregroundColor(foregroundColor) + .lineLimit(1) + .background(color) + .cornerRadius(cornerRadius) + + Color.clear.contentShape(Rectangle()).frame(height: 15) + } + .contentShape(Rectangle()) + } + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Utils/ToolbarItemPlacementExtension.swift b/apple/OmnivoreKit/Sources/Views/Utils/ToolbarItemPlacementExtension.swift new file mode 100644 index 000000000..f4800a5d1 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Utils/ToolbarItemPlacementExtension.swift @@ -0,0 +1,19 @@ +import SwiftUI + +public extension ToolbarItemPlacement { + static var barLeading: ToolbarItemPlacement { + #if os(iOS) + .navigationBarLeading + #else + .automatic + #endif + } + + static var barTrailing: ToolbarItemPlacement { + #if os(iOS) + .navigationBarTrailing + #else + .automatic + #endif + } +} diff --git a/apple/swiftgraphql.yml b/apple/swiftgraphql.yml index 9a2b8bd2b..dd96fa3fc 100644 --- a/apple/swiftgraphql.yml +++ b/apple/swiftgraphql.yml @@ -4,10 +4,12 @@ scalars: SanitizedString_undefined_15: String SanitizedString_undefined_40: String SanitizedString_undefined_50: String + SanitizedString_undefined_64: String SanitizedString_undefined_95: String + SanitizedString_undefined_100: String SanitizedString_undefined_300: String SanitizedString_undefined_400: String SanitizedString_undefined_2000: String SanitizedString_undefined_4000: String SanitizedString_undefined_8000: String - SanitizedString_undefined_undefined: String \ No newline at end of file + SanitizedString_undefined_undefined: String diff --git a/packages/api/src/apollo.ts b/packages/api/src/apollo.ts index fa869a3c3..53c94ec24 100644 --- a/packages/api/src/apollo.ts +++ b/packages/api/src/apollo.ts @@ -115,6 +115,7 @@ export function makeApolloServer(): ApolloServer { schema: schema, context: contextFunc, formatError: (err) => { + console.log('server error', err) Sentry.captureException(err) // hide error messages from frontend on prod return new Error('Unexpected server error') diff --git a/packages/api/src/datalayer/pubsub.ts b/packages/api/src/datalayer/pubsub.ts index 690e9376b..e4f397acd 100644 --- a/packages/api/src/datalayer/pubsub.ts +++ b/packages/api/src/datalayer/pubsub.ts @@ -2,7 +2,6 @@ import { PubSub } from '@google-cloud/pubsub' import { env } from '../env' import { ReportType } from '../generated/graphql' import express from 'express' -import { Page } from '../elastic/types' export const createPubSubClient = (): PubsubClient => { const client = new PubSub() @@ -37,17 +36,35 @@ export const createPubSubClient = (): PubsubClient => { Buffer.from(JSON.stringify({ userId, email, name, username })) ) }, - pageUpdated: (page: Partial, userId: string): Promise => { + entityCreated: ( + type: EntityType, + data: T, + userId: string + ): Promise => { return publish( - 'pageUpdated', - Buffer.from(JSON.stringify({ ...page, userId })) + 'entityCreated', + Buffer.from(JSON.stringify({ type, userId, ...data })) ) }, - pageCreated: (page: Page): Promise => { - return publish('pageCreated', Buffer.from(JSON.stringify(page))) + entityUpdated: ( + type: EntityType, + data: T, + userId: string + ): Promise => { + return publish( + 'entityUpdated', + Buffer.from(JSON.stringify({ type, userId, ...data })) + ) }, - pageDeleted: (id: string, userId: string): Promise => { - return publish('pageDeleted', Buffer.from(JSON.stringify({ id, userId }))) + entityDeleted: ( + type: EntityType, + id: string, + userId: string + ): Promise => { + return publish( + 'entityDeleted', + Buffer.from(JSON.stringify({ type, id, userId })) + ) }, reportSubmitted: ( submitterId: string, @@ -65,6 +82,12 @@ export const createPubSubClient = (): PubsubClient => { } } +export enum EntityType { + PAGE = 'page', + HIGHLIGHT = 'highlight', + LABEL = 'label', +} + export interface PubsubClient { userCreated: ( userId: string, @@ -72,9 +95,9 @@ export interface PubsubClient { name: string, username: string ) => Promise - pageCreated: (page: Page) => Promise - pageUpdated: (page: Partial, userId: string) => Promise - pageDeleted: (id: string, userId: string) => Promise + entityCreated: (type: EntityType, data: T, userId: string) => Promise + entityUpdated: (type: EntityType, data: T, userId: string) => Promise + entityDeleted: (type: EntityType, id: string, userId: string) => Promise reportSubmitted( submitterId: string | undefined, itemUrl: string, diff --git a/packages/api/src/datalayer/reminders/model.ts b/packages/api/src/datalayer/reminders/model.ts index d10333e47..0bfad531d 100644 --- a/packages/api/src/datalayer/reminders/model.ts +++ b/packages/api/src/datalayer/reminders/model.ts @@ -32,6 +32,7 @@ export interface ReminderData { createdAt: Date updatedAt?: Date remindAt: Date + elasticPageId?: string } export const keys = [ @@ -46,6 +47,7 @@ export const keys = [ 'status', 'createdAt', 'updatedAt', + 'elasticPageId', ] as const export const defaultedKeys = ['id', 'updatedAt', 'status'] as const diff --git a/packages/api/src/directives.ts b/packages/api/src/directives.ts index fd54cbb66..435166539 100644 --- a/packages/api/src/directives.ts +++ b/packages/api/src/directives.ts @@ -1,32 +1,41 @@ -import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils' +import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils' import { GraphQLNonNull, GraphQLScalarType, GraphQLSchema } from 'graphql' import { SanitizedString } from './scalars' export const sanitizeDirectiveTransformer = (schema: GraphQLSchema) => { return mapSchema(schema, { [MapperKind.FIELD]: (fieldConfig) => { - const sanitizeDirective = getDirective(schema, fieldConfig, 'sanitize') - if (!sanitizeDirective || sanitizeDirective.length < 1) { + const sanitizeDirective = getDirective( + schema, + fieldConfig, + 'sanitize' + )?.[0] + if (!sanitizeDirective) { return fieldConfig } - const maxLength = sanitizeDirective[0].maxLength as number | undefined - const allowedTags = sanitizeDirective[0].allowedTags as - | string[] - | undefined + const maxLength = sanitizeDirective.maxLength as number | undefined + const allowedTags = sanitizeDirective.allowedTags as string[] | undefined + const pattern = sanitizeDirective.pattern as string | undefined if ( fieldConfig.type instanceof GraphQLNonNull && fieldConfig.type.ofType instanceof GraphQLScalarType ) { fieldConfig.type = new GraphQLNonNull( - new SanitizedString(fieldConfig.type.ofType, allowedTags, maxLength) + new SanitizedString( + fieldConfig.type.ofType, + allowedTags, + maxLength, + pattern + ) ) } else if (fieldConfig.type instanceof GraphQLScalarType) { fieldConfig.type = new SanitizedString( fieldConfig.type, allowedTags, - maxLength + maxLength, + pattern ) } else { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions diff --git a/packages/api/src/elastic/highlights.ts b/packages/api/src/elastic/highlights.ts index 5a499237e..8277f7b9b 100644 --- a/packages/api/src/elastic/highlights.ts +++ b/packages/api/src/elastic/highlights.ts @@ -8,6 +8,7 @@ import { import { ResponseError } from '@elastic/elasticsearch/lib/errors' import { client, INDEX_ALIAS } from './index' import { SortBy, SortOrder, SortParams } from '../utils/search' +import { EntityType } from '../datalayer/pubsub' export const addHighlightToPage = async ( id: string, @@ -35,7 +36,15 @@ export const addHighlightToPage = async ( retry_on_conflict: 3, }) - return body.result === 'updated' + if (body.result !== 'updated') return false + + await ctx.pubsub.entityCreated( + EntityType.HIGHLIGHT, + highlight, + ctx.uid + ) + + return true } catch (e) { if ( e instanceof ResponseError && @@ -125,7 +134,11 @@ export const deleteHighlight = async ( refresh: ctx.refresh, }) - return !!body.updated + if (body.updated === 0) return false + + await ctx.pubsub.entityDeleted(EntityType.HIGHLIGHT, highlightId, ctx.uid) + + return true } catch (e) { console.error('failed to delete a highlight in elastic', e) @@ -266,7 +279,15 @@ export const updateHighlight = async ( refresh: ctx.refresh, }) - return !!body.updated + if (body.updated === 0) return false + + await ctx.pubsub.entityUpdated( + EntityType.HIGHLIGHT, + highlight, + ctx.uid + ) + + return true } catch (e) { if ( e instanceof ResponseError && diff --git a/packages/api/src/elastic/labels.ts b/packages/api/src/elastic/labels.ts index ec1ac5188..69af5365e 100644 --- a/packages/api/src/elastic/labels.ts +++ b/packages/api/src/elastic/labels.ts @@ -1,15 +1,16 @@ import { Label, PageContext } from './types' import { client, INDEX_ALIAS } from './index' +import { EntityType } from '../datalayer/pubsub' export const addLabelInPage = async ( - id: string, + pageId: string, label: Label, ctx: PageContext ): Promise => { try { const { body } = await client.update({ index: INDEX_ALIAS, - id, + id: pageId, body: { script: { source: `if (ctx._source.labels == null) { @@ -27,9 +28,53 @@ export const addLabelInPage = async ( retry_on_conflict: 3, }) - return body.result === 'updated' + if (body.result !== 'updated') return false + + await ctx.pubsub.entityCreated