diff --git a/apple/Omnivore.xcodeproj/project.pbxproj b/apple/Omnivore.xcodeproj/project.pbxproj index 3df71e308..0722253ee 100644 --- a/apple/Omnivore.xcodeproj/project.pbxproj +++ b/apple/Omnivore.xcodeproj/project.pbxproj @@ -1307,7 +1307,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist; @@ -1317,7 +1317,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.18.0; + MARKETING_VERSION = 1.22.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac"; @@ -1339,7 +1339,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist; @@ -1349,7 +1349,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.18.0; + MARKETING_VERSION = 1.22.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1421,7 +1421,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; @@ -1432,7 +1432,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.18.0; + MARKETING_VERSION = 1.22.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; @@ -1455,7 +1455,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; @@ -1466,7 +1466,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.18.0; + MARKETING_VERSION = 1.22.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1521,7 +1521,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.21.0; + MARKETING_VERSION = 1.22.0; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = Omnivore; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1600,7 +1600,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.21.0; + MARKETING_VERSION = 1.22.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( @@ -1639,7 +1639,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.21.0; + MARKETING_VERSION = 1.22.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( "-framework", @@ -1665,7 +1665,7 @@ CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1678,7 +1678,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.18.0; + MARKETING_VERSION = 1.22.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( @@ -1704,7 +1704,7 @@ CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1717,7 +1717,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.18.0; + MARKETING_VERSION = 1.22.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( "-framework", @@ -1804,7 +1804,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.21.0; + MARKETING_VERSION = 1.22.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1859,7 +1859,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.21.0; + MARKETING_VERSION = 1.22.0; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = Omnivore; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1888,7 +1888,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.21.0; + MARKETING_VERSION = 1.22.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ExpandedPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ExpandedPlayer.swift index cd6271ef5..1a8e7fa09 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ExpandedPlayer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ExpandedPlayer.swift @@ -355,42 +355,6 @@ } } - var changeVoiceView: some View { - NavigationView { - VStack { - List { - ForEach(audioController.voiceList ?? [], id: \.key.self) { voice in - Button(action: { - audioController.currentVoice = voice.key - self.showVoiceSheet = false - }, label: { - HStack { - Text(voice.name) - - Spacer() - - if voice.selected { - Image(systemName: "checkmark") - } - } - .contentShape(Rectangle()) - }).buttonStyle(PlainButtonStyle()) - } - } - .padding(.top, 32) - .listStyle(.plain) - Spacer() - } - .navigationBarTitle("Voice") - .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }, label: { - Image(systemName: "chevron.backward") - .font(.appNavbarIcon) - .tint(.appGrayTextContrast) - })) - } - } - var scrubbing: Bool { switch audioController.scrubState { case .scrubStarted: diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index e73aa03f8..ffa30831d 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -97,6 +97,10 @@ struct ProfileView: View { NavigationLink(destination: SubscriptionsView()) { Text("Subscriptions") } + + NavigationLink(destination: GroupsView()) { + Text("Recommendation Groups") + } } #if os(iOS) diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift new file mode 100644 index 000000000..30356f083 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupView.swift @@ -0,0 +1,146 @@ +import Models +import Services +import SwiftUI +import Views + +@MainActor final class RecommendationsGroupViewModel: ObservableObject { + @Published var isLoading = false + @Published var networkError = true + @Published var recommendationGroup: InternalRecommendationGroup + + init(recommendationGroup: InternalRecommendationGroup) { + self.recommendationGroup = recommendationGroup + } + + var nonAdmins: [InternalUserProfile] { + recommendationGroup.members.filter { member in + !recommendationGroup.admins.contains(where: { member.id == $0.id }) + } + } +} + +private struct SmallUserCard: View { + let data: ProfileCardData + var size: CGFloat + + public init(data: ProfileCardData, size: CGFloat = 36) { + self.data = data + self.size = size + } + + public var body: some View { + HStack(alignment: .center, spacing: 16) { + Group { + AsyncImage( + url: data.imageURL, + content: { $0.resizable() }, + placeholder: { + Image(systemName: "person.crop.circle") + .resizable() + .foregroundColor(.appGrayTextContrast) + } + ) + } + .padding(.leading, 0) + .aspectRatio(contentMode: .fill) + .frame(width: size, height: size, alignment: .center) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(data.name) + .font(.appBody) + .foregroundColor(.appGrayTextContrast) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + Text("@\(data.username)") + .font(.appCaption) + .foregroundColor(.appGrayText) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.leading) + .padding(0) + } + .frame(maxWidth: .infinity) + } +} + +struct RecommendationGroupView: View { + @EnvironmentObject var dataService: DataService + @StateObject var viewModel: RecommendationsGroupViewModel + + @State var presentShareSheet = false + + var body: some View { + Group { + #if os(iOS) + Form { + innerBody + } + #elseif os(macOS) + List { + innerBody + } + .listStyle(InsetListStyle()) + #endif + } + } + + private var shareView: some View { + if let shareLink = URL(string: viewModel.recommendationGroup.inviteUrl) { + return AnyView(ShareSheet(activityItems: [shareLink])) + } else { + return AnyView(Text("Error copying invite URL")) + } + } + + private var adminsSection: some View { + Section("Admins") { + ForEach(viewModel.recommendationGroup.admins) { admin in + SmallUserCard(data: ProfileCardData( + name: admin.name, + username: admin.username, + imageURL: admin.profileImageURL != nil ? URL(string: admin.profileImageURL!) : nil + )) + } + } + } + + private var membersSection: some View { + Section("Members") { + ForEach(viewModel.nonAdmins) { member in + SmallUserCard(data: ProfileCardData( + name: member.name, + username: member.username, + imageURL: member.profileImageURL != nil ? URL(string: member.profileImageURL!) : nil + )) + } + } + } + + private var innerBody: some View { + Group { + Section("Name") { + Text(viewModel.recommendationGroup.name) + } + + Section("Invite Link") { + Button(action: { + presentShareSheet = true + }, label: { + Text("[\(viewModel.recommendationGroup.inviteUrl)](\(viewModel.recommendationGroup.inviteUrl))") + .font(.appCaption) + }) + } + + adminsSection + membersSection + } + .formSheet(isPresented: $presentShareSheet) { + shareView + } + .navigationTitle(viewModel.recommendationGroup.name) + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupsView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupsView.swift new file mode 100644 index 000000000..570072b41 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/RecommendationGroupsView.swift @@ -0,0 +1,145 @@ +import Models +import Services +import SwiftUI +import Views + +@MainActor final class RecommendationsGroupsViewModel: ObservableObject { + @Published var isLoading = false + @Published var isCreating = false + @Published var networkError = true + @Published var recommendationGroups = [InternalRecommendationGroup]() + + @Published var showCreateSheet = false + + @Published var showCreateError = false + @Published var createGroupError: String? + + func loadGroups(dataService: DataService) async { + isLoading = true + + do { + recommendationGroups = try await dataService.recommendationGroups() + } catch { + networkError = true + } + + isLoading = false + } + + func createGroup(dataService: DataService, name: String) async { + isCreating = true + + if let group = try? await dataService.createRecommendationGroup(name: name) { + print("CREATED GROUP: ", group) + await loadGroups(dataService: dataService) + showCreateSheet = false + } else { + createGroupError = "Error creating group" + showCreateError = true + } + + isCreating = false + } +} + +struct CreateRecommendationGroupView: View { + @State var name = "" + @EnvironmentObject var dataService: DataService + @StateObject var viewModel = RecommendationsGroupsViewModel() + + var nextButton: some View { + if viewModel.isCreating { + return AnyView(ProgressView()) + } else { + return AnyView(Button(action: { + Task { + await viewModel.createGroup(dataService: dataService, name: self.name) + } + }, label: { + Text("Next") + }) + .disabled(name.isEmpty) + ) + } + } + + var body: some View { + NavigationView { + Form { + TextField("Name", text: $name, prompt: Text("Group Name")) + } + .alert(isPresented: $viewModel.showCreateError) { + Alert( + title: Text(viewModel.createGroupError ?? "Error creating group"), + dismissButton: .cancel(Text("Ok")) { + viewModel.createGroupError = nil + viewModel.showCreateError = false + } + ) + } + } + .navigationViewStyle(.stack) + .navigationTitle("Create Group") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(leading: + Button(action: { + viewModel.showCreateSheet = false + }, label: { Text("Cancel") }), + trailing: nextButton) + } +} + +struct GroupsView: View { + @EnvironmentObject var dataService: DataService + @StateObject var viewModel = RecommendationsGroupsViewModel() + + var body: some View { + Group { + #if os(iOS) + Form { + innerBody + } + #elseif os(macOS) + List { + innerBody + } + .listStyle(InsetListStyle()) + #endif + } + .sheet(isPresented: $viewModel.showCreateSheet) { + NavigationView { + CreateRecommendationGroupView(viewModel: self.viewModel) + } + } + .task { await viewModel.loadGroups(dataService: dataService) } + } + + private var innerBody: some View { + Group { + Section { + Button( + action: { viewModel.showCreateSheet = true }, + label: { + HStack { + Image(systemName: "plus.circle.fill").foregroundColor(.green) + Text("Create a new group") + Spacer() + } + } + ) + .disabled(viewModel.isLoading) + } + + Section(header: Text("Your recommendation groups")) { + ForEach(viewModel.recommendationGroups) { recommendationGroup in + NavigationLink( + destination: RecommendationGroupView(viewModel: RecommendationsGroupViewModel(recommendationGroup: recommendationGroup)) + ) { + Text(recommendationGroup.name) + } + } + } + } + .navigationTitle("Recommendation Groups") + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift index 4164550fb..bc60ce80e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift @@ -6,7 +6,7 @@ import Utils import Views @MainActor final class CreateProfileViewModel: ObservableObject { - private(set) var initialUserProfile = UserProfile(username: "", name: "", bio: nil) + private(set) var initialUserProfile = NewUserProfile(username: "", name: "", bio: nil) var isConfigured = false var hasSuggestedProfile: Bool { @@ -31,7 +31,7 @@ import Views init() {} func submitProfile(name: String, bio: String, authenticator: Authenticator) { - let profileOrError = UserProfile.make( + let profileOrError = NewUserProfile.make( username: potentialUsername, name: name, bio: bio.isEmpty ? nil : bio @@ -77,7 +77,7 @@ import Views } } - func submitProfile(userProfile: UserProfile, authenticator: Authenticator) async { + func submitProfile(userProfile: NewUserProfile, authenticator: Authenticator) async { do { try await authenticator.createAccount(userProfile: userProfile) } catch { @@ -87,7 +87,7 @@ import Views } } - func configure(profile: UserProfile, dataService: DataService) { + func configure(profile: NewUserProfile, dataService: DataService) { guard !isConfigured else { return } isConfigured = true @@ -104,7 +104,7 @@ import Views } struct CreateProfileView: View { - private let initialUserProfile: UserProfile + private let initialUserProfile: NewUserProfile @State private var isConfigured = false @Environment(\.horizontalSizeClass) var horizontalSizeClass @EnvironmentObject var authenticator: Authenticator @@ -114,7 +114,7 @@ struct CreateProfileView: View { @State private var name = "" @State private var bio = "" - init(userProfile: UserProfile) { + init(userProfile: NewUserProfile) { self.initialUserProfile = userProfile } diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift index 3bb561ac5..3eda742f1 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift @@ -9,7 +9,7 @@ import Views init() {} - func submitProfile(userProfile: UserProfile, authenticator: Authenticator) async { + func submitProfile(userProfile: NewUserProfile, authenticator: Authenticator) async { do { try await authenticator.createAccount(userProfile: userProfile) } catch { @@ -23,7 +23,7 @@ import Views struct NewAppleSignupView: View { @EnvironmentObject var authenticator: Authenticator @StateObject private var viewModel = NewAppleSignupViewModel() - let userProfile: UserProfile + let userProfile: NewUserProfile let showProfileEditView: () -> Void var body: some View { diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift index 8bd5754ab..db6250caf 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift @@ -7,8 +7,8 @@ import Views @MainActor final class RegistrationViewModel: ObservableObject { enum RegistrationState { - case createProfile(userProfile: UserProfile) - case newAppleSignUp(userProfile: UserProfile) + case createProfile(userProfile: NewUserProfile) + case newAppleSignUp(userProfile: NewUserProfile) } @Published var loginError: LoginError? @@ -65,7 +65,7 @@ import Views case let .loginError(error): loginError = error case .newOmnivoreUser: - registrationState = .createProfile(userProfile: UserProfile(username: "", name: "")) + registrationState = .createProfile(userProfile: NewUserProfile(username: "", name: "")) case .existingOmnivoreUser: break } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift new file mode 100644 index 000000000..20d737d03 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/RecommendToView.swift @@ -0,0 +1,119 @@ +import Models +import Services +import SwiftUI +import Views + +@MainActor final class RecommendToViewModel: ObservableObject { + @Published var isLoading = false + @Published var networkError = true + @Published var recommendationGroups = [InternalRecommendationGroup]() + @Published var selectedGroups = [String]() + @Published var isRunning = false + @Published var showError = false + @Published var note: String? + + let pageID: String + + init(pageID: String) { + self.pageID = pageID + } + + func loadGroups(dataService: DataService) async { + isLoading = true + + do { + recommendationGroups = try await dataService.recommendationGroups() + } catch { + print("ERROR fetching recommendationGroups: ", error) + networkError = true + } + + isLoading = false + } + + func recommend(dataService: DataService) async { + isRunning = true + + do { + try await dataService.recommendPage(pageID: pageID, groupIDs: selectedGroups, note: note) + } catch { + showError = true + } + + isRunning = false + } +} + +struct RecommendToView: View { + var dataService: DataService + @StateObject var viewModel: RecommendToViewModel + @Environment(\.dismiss) private var dismiss + + var nextButton: some View { + if viewModel.isRunning { + return AnyView(ProgressView()) + } else { + return AnyView(Button(action: { + Task { + await viewModel.recommend(dataService: dataService) + Snackbar.show(message: "Recommendation sent") + dismiss() + } + }, label: { + Text("Next") + }) + .disabled(viewModel.selectedGroups.isEmpty) + ) + } + } + + var body: some View { + VStack { + List { + Section("Select groups to recommend to") { + ForEach(viewModel.recommendationGroups) { group in + HStack { + Text(group.name) + + Spacer() + + if viewModel.selectedGroups.contains(group.id) { + Image(systemName: "checkmark") + } + } + .contentShape(Rectangle()) + .onTapGesture { + let idx = viewModel.selectedGroups.firstIndex(of: group.id) + if let idx = idx { + viewModel.selectedGroups.remove(at: idx) + } else { + viewModel.selectedGroups.append(group.id) + } + } + } + } + } + .listStyle(.grouped) + + Spacer() + } + .alert(isPresented: $viewModel.showError) { + Alert( + title: Text("Error recommending this page"), + dismissButton: .cancel(Text("Ok")) { + viewModel.showError = false + } + ) + } + .navigationBarTitle("Recommend") + .navigationBarTitleDisplayMode(.inline) + .navigationViewStyle(.stack) + .navigationBarItems(leading: Button(action: { + dismiss() + }, label: { Text("Cancel") }), + trailing: nextButton) + .task { + await viewModel.loadGroups(dataService: dataService) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 748246a6a..408da9ef7 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -29,6 +29,7 @@ struct WebReaderContainerView: View { @State private var bottomBarOpacity = 0.0 @State private var errorAlertMessage: String? @State private var showErrorAlertMessage = false + @State private var showRecommendSheet = false @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController @@ -150,8 +151,8 @@ struct WebReaderContainerView: View { }).frame(width: 48, height: 48) Divider().opacity(0.8) - Button(action: share, label: { - Image(systemName: "square.and.arrow.up") + Button(action: recommend, label: { + Image(systemName: "sparkles") }).frame(width: 48, height: 48) // TODO: We don't have a single note function yet @@ -219,6 +220,12 @@ struct WebReaderContainerView: View { action: delete, label: { Label("Delete", systemImage: "trash") } ) + Button( + action: { + showRecommendSheet = true + }, + label: { Label("Recommend", systemImage: "sparkles") } + ) } } @@ -351,6 +358,16 @@ struct WebReaderContainerView: View { showErrorAlertMessage = false }) } + .formSheet(isPresented: $showRecommendSheet) { + NavigationView { + RecommendToView( + dataService: dataService, + viewModel: RecommendToViewModel(pageID: item.unwrappedID) + ) + }.onDisappear { + showRecommendSheet = false + } + } .sheet(isPresented: $showHighlightAnnotationModal) { HighlightAnnotationSheet( annotation: $annotation, @@ -440,6 +457,10 @@ struct WebReaderContainerView: View { Snackbar.show(message: !item.isArchived ? "Link archived" : "Link moved to Inbox") } + func recommend() { + showRecommendSheet = true + } + func share() { shareActionID = UUID() } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift index 99e9952d3..cffc3a7df 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift @@ -62,7 +62,7 @@ extension WebReaderCoordinator: WKNavigationDelegate { // swiftlint:disable:next line_length func webView(_: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if navigationAction.navigationType == .linkActivated { - if let linkURL = navigationAction.request.url { + if let linkURL = navigationAction.request.url, !linkURL.isFileURL { linkHandler(linkURL) } decisionHandler(.cancel) diff --git a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index ace07414f..deb3afd4f 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -55,6 +55,7 @@ + @@ -91,6 +92,28 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Models/CoreData/StorageProvider.swift b/apple/OmnivoreKit/Sources/Models/CoreData/StorageProvider.swift index 6198ec852..12a31526a 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/StorageProvider.swift +++ b/apple/OmnivoreKit/Sources/Models/CoreData/StorageProvider.swift @@ -21,6 +21,7 @@ public class PersistentContainer: NSPersistentContainer { #endif let appGroupContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) let appGroupContainerURL = appGroupContainer?.appendingPathComponent("store.sqlite") + print("starting with sqlite container", appGroupContainerURL) container.persistentStoreDescriptions.first!.url = appGroupContainerURL diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/NewUserProfile.swift b/apple/OmnivoreKit/Sources/Models/DataModels/NewUserProfile.swift new file mode 100644 index 000000000..a13c52bcb --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/DataModels/NewUserProfile.swift @@ -0,0 +1,52 @@ +import Foundation +import Utils + +public struct NewUserProfile: Codable { + public let username: String + public let name: String + public let bio: String? + + public init( + username: String, + name: String, + bio: String? = nil + ) { + self.username = username + self.name = name + self.bio = bio + } +} + +public extension NewUserProfile { + static func make( + username: String, + name: String, + bio: String? + ) -> Either { + let userProfile = NewUserProfile( + username: username, + name: name, + bio: bio + ) + + if let errorMessage = userProfile.validationErrorMessage { + return .right(errorMessage) + } else { + return .left(userProfile) + } + } + + private var validationErrorMessage: String? { + if name.isEmpty { + return "The name field is missing." + } + + return nil + } +} + +public extension Viewer { + var unwrappedUsername: String { username ?? "" } + var unwrappedName: String { name ?? "" } + var unwrappedUserID: String { userID ?? "" } +} diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/Recommendation.swift b/apple/OmnivoreKit/Sources/Models/DataModels/Recommendation.swift new file mode 100644 index 000000000..279a74268 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/DataModels/Recommendation.swift @@ -0,0 +1,21 @@ +import CoreData +import Foundation + +public extension Recommendation { + var unwrappedID: String { id ?? "" } + + static func lookup(byID recommendationID: String, inContext context: NSManagedObjectContext) -> Recommendation? { + let fetchRequest: NSFetchRequest = Recommendation.fetchRequest() + fetchRequest.predicate = NSPredicate( + format: "id == %@", recommendationID + ) + + var recommendation: Recommendation? + + context.performAndWait { + recommendation = (try? context.fetch(fetchRequest))?.first + } + + return recommendation + } +} diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/UserProfile.swift b/apple/OmnivoreKit/Sources/Models/DataModels/UserProfile.swift index 18e0a3da1..7c655577f 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/UserProfile.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/UserProfile.swift @@ -1,52 +1,19 @@ +import CoreData import Foundation -import Utils - -public struct UserProfile: Codable { - public let username: String - public let name: String - public let bio: String? - - public init( - username: String, - name: String, - bio: String? = nil - ) { - self.username = username - self.name = name - self.bio = bio - } -} public extension UserProfile { - static func make( - username: String, - name: String, - bio: String? - ) -> Either { - let userProfile = UserProfile( - username: username, - name: name, - bio: bio + static func lookup(byID userID: String, inContext context: NSManagedObjectContext) -> UserProfile? { + let fetchRequest: NSFetchRequest = UserProfile.fetchRequest() + fetchRequest.predicate = NSPredicate( + format: "%K == %@", #keyPath(UserProfile.userID), userID ) - if let errorMessage = userProfile.validationErrorMessage { - return .right(errorMessage) - } else { - return .left(userProfile) - } - } + var result: UserProfile? - private var validationErrorMessage: String? { - if name.isEmpty { - return "The name field is missing." + context.performAndWait { + result = (try? context.fetch(fetchRequest))?.first } - return nil + return result } } - -public extension Viewer { - var unwrappedUsername: String { username ?? "" } - var unwrappedName: String { name ?? "" } - var unwrappedUserID: String { userID ?? "" } -} diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift b/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift index 73e31dbb7..1db37f136 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift @@ -5,12 +5,12 @@ public extension Authenticator { func createPendingAccountUsingApple( token: String, name: PersonNameComponents? - ) async throws -> UserProfile { + ) async throws -> NewUserProfile { let params = CreatePendingAccountParams(token: token, provider: .apple, fullName: name) return try await createPendingAccount(params: params) } - func createAccount(userProfile: UserProfile) async throws { + func createAccount(userProfile: NewUserProfile) async throws { let params = CreateAccountParams( pendingUserToken: pendingUserToken ?? "", userProfile: userProfile @@ -34,7 +34,7 @@ public extension Authenticator { } extension Authenticator { - func createPendingAccount(params: CreatePendingAccountParams) async throws -> UserProfile { + func createPendingAccount(params: CreatePendingAccountParams) async throws -> NewUserProfile { do { let encodedParams = (try? JSONEncoder().encode(params)) ?? Data() let pendingUserAuthPayload = try await networker.createPendingUser(params: encodedParams) diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift b/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift index 454e06b1e..550389c56 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift @@ -48,12 +48,12 @@ struct EmailAuthPayload: Decodable { struct CreateAccountParams: Encodable { let pendingUserToken: String - let userProfile: UserProfile + let userProfile: NewUserProfile } struct PendingUserAuthPayload: Decodable { let pendingUserToken: String - let pendingUserProfile: UserProfile + let pendingUserProfile: NewUserProfile } struct PendingEmailVerificationAuthPayload: Decodable { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift b/apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift index 4de282df4..b1b620be8 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/ContentLoading.swift @@ -48,6 +48,7 @@ extension DataService { do { objectID = try await persistArticleContent(articleProps: fetchResult) } catch { + print("caught article content error: ", error) var message = "unknown error" let basicError = (error as? BasicError) ?? BasicError.message(messageText: "unknown error") if case let BasicError.message(messageText) = basicError { @@ -230,7 +231,7 @@ extension DataService { } catch { // We don't propogate these errors, we just let it pass through so // the user can attempt to fetch content again. - print("Error syncUnsyncedArticleContent") + print("Error syncUnsyncedArticleContent", error) } } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index 2b4d961e2..22963144d 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -684,6 +684,7 @@ extension Objects { let readAt: [String: DateTime] let readingProgressAnchorIndex: [String: Int] let readingProgressPercent: [String: Double] + let recommendations: [String: [Objects.Recommendation]] let savedAt: [String: DateTime] let savedByViewer: [String: Bool] let shareInfo: [String: Objects.LinkShareInfo] @@ -806,6 +807,10 @@ extension Objects.Article: Decodable { if let value = try container.decode(Double?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "recommendations": + if let value = try container.decode([Objects.Recommendation]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "savedAt": if let value = try container.decode(DateTime?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -898,6 +903,7 @@ extension Objects.Article: Decodable { readAt = map["readAt"] readingProgressAnchorIndex = map["readingProgressAnchorIndex"] readingProgressPercent = map["readingProgressPercent"] + recommendations = map["recommendations"] savedAt = map["savedAt"] savedByViewer = map["savedByViewer"] shareInfo = map["shareInfo"] @@ -1276,6 +1282,22 @@ extension Fields where TypeLock == Objects.Article { } } + func recommendations(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "recommendations", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.recommendations[field.alias!]) + case .mocking: + return selection.mock() + } + } + func savedAt() throws -> DateTime { let field = GraphQLField.leaf( name: "savedAt", @@ -2596,6 +2618,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias CreateArticleSuccess = Selection } +extension Objects { + struct CreateGroupError { + let __typename: TypeName = .createGroupError + let errorCodes: [String: [Enums.CreateGroupErrorCode]] + + enum TypeName: String, Codable { + case createGroupError = "CreateGroupError" + } + } +} + +extension Objects.CreateGroupError: 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.CreateGroupErrorCode]?.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.CreateGroupError { + func errorCodes() throws -> [Enums.CreateGroupErrorCode] { + 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 CreateGroupError = Selection +} + +extension Objects { + struct CreateGroupSuccess { + let __typename: TypeName = .createGroupSuccess + let group: [String: Objects.RecommendationGroup] + + enum TypeName: String, Codable { + case createGroupSuccess = "CreateGroupSuccess" + } + } +} + +extension Objects.CreateGroupSuccess: 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 "group": + if let value = try container.decode(Objects.RecommendationGroup?.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)." + ) + ) + } + } + + group = map["group"] + } +} + +extension Fields where TypeLock == Objects.CreateGroupSuccess { + func group(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "group", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.group[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias CreateGroupSuccess = Selection +} + extension Objects { struct CreateHighlightError { let __typename: TypeName = .createHighlightError @@ -3512,6 +3665,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeleteAccountSuccess = Selection } +extension Objects { + struct DeleteFilterError { + let __typename: TypeName = .deleteFilterError + let errorCodes: [String: [Enums.DeleteFilterErrorCode]] + + enum TypeName: String, Codable { + case deleteFilterError = "DeleteFilterError" + } + } +} + +extension Objects.DeleteFilterError: 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.DeleteFilterErrorCode]?.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.DeleteFilterError { + func errorCodes() throws -> [Enums.DeleteFilterErrorCode] { + 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 DeleteFilterError = Selection +} + +extension Objects { + struct DeleteFilterSuccess { + let __typename: TypeName = .deleteFilterSuccess + let filter: [String: Objects.Filter] + + enum TypeName: String, Codable { + case deleteFilterSuccess = "DeleteFilterSuccess" + } + } +} + +extension Objects.DeleteFilterSuccess: 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 "filter": + if let value = try container.decode(Objects.Filter?.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)." + ) + ) + } + } + + filter = map["filter"] + } +} + +extension Fields where TypeLock == Objects.DeleteFilterSuccess { + func filter(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "filter", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.filter[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteFilterSuccess = Selection +} + extension Objects { struct DeleteHighlightError { let __typename: TypeName = .deleteHighlightError @@ -5654,6 +5938,343 @@ extension Selection where TypeLock == Never, Type == Never { typealias FeedArticlesSuccess = Selection } +extension Objects { + struct Filter { + let __typename: TypeName = .filter + let createdAt: [String: DateTime] + let description: [String: String] + let filter: [String: String] + let id: [String: String] + let name: [String: String] + let position: [String: Int] + let updatedAt: [String: DateTime] + + enum TypeName: String, Codable { + case filter = "Filter" + } + } +} + +extension Objects.Filter: 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 "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 "filter": + 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 "name": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "position": + if let value = try container.decode(Int?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "updatedAt": + if let value = try container.decode(DateTime?.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)." + ) + ) + } + } + + createdAt = map["createdAt"] + description = map["description"] + filter = map["filter"] + id = map["id"] + name = map["name"] + position = map["position"] + updatedAt = map["updatedAt"] + } +} + +extension Fields where TypeLock == Objects.Filter { + 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 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 filter() throws -> String { + let field = GraphQLField.leaf( + name: "filter", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.filter[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + 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 name() throws -> String { + let field = GraphQLField.leaf( + name: "name", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.name[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func position() throws -> Int { + let field = GraphQLField.leaf( + name: "position", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.position[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Int.mockValue + } + } + + func updatedAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "updatedAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.updatedAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias Filter = Selection +} + +extension Objects { + struct FiltersError { + let __typename: TypeName = .filtersError + let errorCodes: [String: [Enums.FiltersErrorCode]] + + enum TypeName: String, Codable { + case filtersError = "FiltersError" + } + } +} + +extension Objects.FiltersError: 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.FiltersErrorCode]?.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.FiltersError { + func errorCodes() throws -> [Enums.FiltersErrorCode] { + 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 FiltersError = Selection +} + +extension Objects { + struct FiltersSuccess { + let __typename: TypeName = .filtersSuccess + let filters: [String: [Objects.Filter]] + + enum TypeName: String, Codable { + case filtersSuccess = "FiltersSuccess" + } + } +} + +extension Objects.FiltersSuccess: 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 "filters": + if let value = try container.decode([Objects.Filter]?.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)." + ) + ) + } + } + + filters = map["filters"] + } +} + +extension Fields where TypeLock == Objects.FiltersSuccess { + func filters(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "filters", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.filters[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias FiltersSuccess = Selection +} + extension Objects { struct GenerateApiKeyError { let __typename: TypeName = .generateApiKeyError @@ -6306,6 +6927,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias GoogleSignupSuccess = Selection } +extension Objects { + struct GroupsError { + let __typename: TypeName = .groupsError + let errorCodes: [String: [Enums.GroupsErrorCode]] + + enum TypeName: String, Codable { + case groupsError = "GroupsError" + } + } +} + +extension Objects.GroupsError: 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.GroupsErrorCode]?.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.GroupsError { + func errorCodes() throws -> [Enums.GroupsErrorCode] { + 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 GroupsError = Selection +} + +extension Objects { + struct GroupsSuccess { + let __typename: TypeName = .groupsSuccess + let groups: [String: [Objects.RecommendationGroup]] + + enum TypeName: String, Codable { + case groupsSuccess = "GroupsSuccess" + } + } +} + +extension Objects.GroupsSuccess: 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 "groups": + if let value = try container.decode([Objects.RecommendationGroup]?.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)." + ) + ) + } + } + + groups = map["groups"] + } +} + +extension Fields where TypeLock == Objects.GroupsSuccess { + func groups(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "groups", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.groups[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GroupsSuccess = Selection +} + extension Objects { struct Highlight { let __typename: TypeName = .highlight @@ -7306,6 +8058,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias IntegrationsSuccess = Selection } +extension Objects { + struct JoinGroupError { + let __typename: TypeName = .joinGroupError + let errorCodes: [String: [Enums.JoinGroupErrorCode]] + + enum TypeName: String, Codable { + case joinGroupError = "JoinGroupError" + } + } +} + +extension Objects.JoinGroupError: 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.JoinGroupErrorCode]?.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.JoinGroupError { + func errorCodes() throws -> [Enums.JoinGroupErrorCode] { + 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 JoinGroupError = Selection +} + +extension Objects { + struct JoinGroupSuccess { + let __typename: TypeName = .joinGroupSuccess + let group: [String: Objects.RecommendationGroup] + + enum TypeName: String, Codable { + case joinGroupSuccess = "JoinGroupSuccess" + } + } +} + +extension Objects.JoinGroupSuccess: 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 "group": + if let value = try container.decode(Objects.RecommendationGroup?.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)." + ) + ) + } + } + + group = map["group"] + } +} + +extension Fields where TypeLock == Objects.JoinGroupSuccess { + func group(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "group", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.group[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias JoinGroupSuccess = Selection +} + extension Objects { struct Label { let __typename: TypeName = .label @@ -8498,6 +9381,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias MergeHighlightSuccess = Selection } +extension Objects { + struct MoveFilterError { + let __typename: TypeName = .moveFilterError + let errorCodes: [String: [Enums.MoveFilterErrorCode]] + + enum TypeName: String, Codable { + case moveFilterError = "MoveFilterError" + } + } +} + +extension Objects.MoveFilterError: 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.MoveFilterErrorCode]?.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.MoveFilterError { + func errorCodes() throws -> [Enums.MoveFilterErrorCode] { + 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 MoveFilterError = Selection +} + +extension Objects { + struct MoveFilterSuccess { + let __typename: TypeName = .moveFilterSuccess + let filter: [String: Objects.Filter] + + enum TypeName: String, Codable { + case moveFilterSuccess = "MoveFilterSuccess" + } + } +} + +extension Objects.MoveFilterSuccess: 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 "filter": + if let value = try container.decode(Objects.Filter?.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)." + ) + ) + } + } + + filter = map["filter"] + } +} + +extension Fields where TypeLock == Objects.MoveFilterSuccess { + func filter(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "filter", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.filter[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias MoveFilterSuccess = Selection +} + extension Objects { struct MoveLabelError { let __typename: TypeName = .moveLabelError @@ -8635,6 +9649,7 @@ extension Objects { let addPopularRead: [String: Unions.AddPopularReadResult] let createArticle: [String: Unions.CreateArticleResult] let createArticleSavingRequest: [String: Unions.CreateArticleSavingRequestResult] + let createGroup: [String: Unions.CreateGroupResult] let createHighlight: [String: Unions.CreateHighlightResult] let createHighlightReply: [String: Unions.CreateHighlightReplyResult] let createLabel: [String: Unions.CreateLabelResult] @@ -8642,6 +9657,7 @@ extension Objects { let createReaction: [String: Unions.CreateReactionResult] let createReminder: [String: Unions.CreateReminderResult] let deleteAccount: [String: Unions.DeleteAccountResult] + let deleteFilter: [String: Unions.DeleteFilterResult] let deleteHighlight: [String: Unions.DeleteHighlightResult] let deleteHighlightReply: [String: Unions.DeleteHighlightReplyResult] let deleteIntegration: [String: Unions.DeleteIntegrationResult] @@ -8654,14 +9670,18 @@ extension Objects { let generateApiKey: [String: Unions.GenerateApiKeyResult] let googleLogin: [String: Unions.LoginResult] let googleSignup: [String: Unions.GoogleSignupResult] + let joinGroup: [String: Unions.JoinGroupResult] let logOut: [String: Unions.LogOutResult] let mergeHighlight: [String: Unions.MergeHighlightResult] + let moveFilter: [String: Unions.MoveFilterResult] let moveLabel: [String: Unions.MoveLabelResult] let optInFeature: [String: Unions.OptInFeatureResult] + let recommend: [String: Unions.RecommendResult] let reportItem: [String: Objects.ReportItemResult] let revokeApiKey: [String: Unions.RevokeApiKeyResult] let saveArticleReadingProgress: [String: Unions.SaveArticleReadingProgressResult] let saveFile: [String: Unions.SaveResult] + let saveFilter: [String: Unions.SaveFilterResult] let savePage: [String: Unions.SaveResult] let saveUrl: [String: Unions.SaveResult] let setBookmarkArticle: [String: Unions.SetBookmarkArticleResult] @@ -8719,6 +9739,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.CreateArticleSavingRequestResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "createGroup": + if let value = try container.decode(Unions.CreateGroupResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "createHighlight": if let value = try container.decode(Unions.CreateHighlightResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -8747,6 +9771,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.DeleteAccountResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "deleteFilter": + if let value = try container.decode(Unions.DeleteFilterResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "deleteHighlight": if let value = try container.decode(Unions.DeleteHighlightResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -8795,6 +9823,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.GoogleSignupResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "joinGroup": + if let value = try container.decode(Unions.JoinGroupResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "logOut": if let value = try container.decode(Unions.LogOutResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -8803,6 +9835,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.MergeHighlightResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "moveFilter": + if let value = try container.decode(Unions.MoveFilterResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "moveLabel": if let value = try container.decode(Unions.MoveLabelResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -8811,6 +9847,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.OptInFeatureResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "recommend": + if let value = try container.decode(Unions.RecommendResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "reportItem": if let value = try container.decode(Objects.ReportItemResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -8827,6 +9867,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.SaveResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "saveFilter": + if let value = try container.decode(Unions.SaveFilterResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "savePage": if let value = try container.decode(Unions.SaveResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -8944,6 +9988,7 @@ extension Objects.Mutation: Decodable { addPopularRead = map["addPopularRead"] createArticle = map["createArticle"] createArticleSavingRequest = map["createArticleSavingRequest"] + createGroup = map["createGroup"] createHighlight = map["createHighlight"] createHighlightReply = map["createHighlightReply"] createLabel = map["createLabel"] @@ -8951,6 +9996,7 @@ extension Objects.Mutation: Decodable { createReaction = map["createReaction"] createReminder = map["createReminder"] deleteAccount = map["deleteAccount"] + deleteFilter = map["deleteFilter"] deleteHighlight = map["deleteHighlight"] deleteHighlightReply = map["deleteHighlightReply"] deleteIntegration = map["deleteIntegration"] @@ -8963,14 +10009,18 @@ extension Objects.Mutation: Decodable { generateApiKey = map["generateApiKey"] googleLogin = map["googleLogin"] googleSignup = map["googleSignup"] + joinGroup = map["joinGroup"] logOut = map["logOut"] mergeHighlight = map["mergeHighlight"] + moveFilter = map["moveFilter"] moveLabel = map["moveLabel"] optInFeature = map["optInFeature"] + recommend = map["recommend"] reportItem = map["reportItem"] revokeApiKey = map["revokeApiKey"] saveArticleReadingProgress = map["saveArticleReadingProgress"] saveFile = map["saveFile"] + saveFilter = map["saveFilter"] savePage = map["savePage"] saveUrl = map["saveUrl"] setBookmarkArticle = map["setBookmarkArticle"] @@ -9058,6 +10108,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func createGroup(input: InputObjects.CreateGroupInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "createGroup", + arguments: [Argument(name: "input", type: "CreateGroupInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.createGroup[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func createHighlight(input: InputObjects.CreateHighlightInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "createHighlight", @@ -9191,6 +10260,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func deleteFilter(id: String, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "deleteFilter", + arguments: [Argument(name: "id", type: "ID!", value: id)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.deleteFilter[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func deleteHighlight(highlightId: String, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "deleteHighlight", @@ -9419,6 +10507,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func joinGroup(inviteCode: String, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "joinGroup", + arguments: [Argument(name: "inviteCode", type: "String!", value: inviteCode)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.joinGroup[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func logOut(selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "logOut", @@ -9457,6 +10564,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func moveFilter(input: InputObjects.MoveFilterInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "moveFilter", + arguments: [Argument(name: "input", type: "MoveFilterInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.moveFilter[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func moveLabel(input: InputObjects.MoveLabelInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "moveLabel", @@ -9495,6 +10621,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func recommend(input: InputObjects.RecommendInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "recommend", + arguments: [Argument(name: "input", type: "RecommendInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.recommend[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func reportItem(input: InputObjects.ReportItemInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "reportItem", @@ -9571,6 +10716,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func saveFilter(input: InputObjects.SaveFilterInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "saveFilter", + arguments: [Argument(name: "input", type: "SaveFilterInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.saveFilter[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func savePage(input: InputObjects.SavePageInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "savePage", @@ -11126,9 +12290,11 @@ extension Objects { let articles: [String: Unions.ArticlesResult] let deviceTokens: [String: Unions.DeviceTokensResult] let feedArticles: [String: Unions.FeedArticlesResult] + let filters: [String: Unions.FiltersResult] let getFollowers: [String: Unions.GetFollowersResult] let getFollowing: [String: Unions.GetFollowingResult] let getUserPersonalization: [String: Unions.GetUserPersonalizationResult] + let groups: [String: Unions.GroupsResult] let hello: [String: String] let integrations: [String: Unions.IntegrationsResult] let labels: [String: Unions.LabelsResult] @@ -11191,6 +12357,10 @@ extension Objects.Query: Decodable { if let value = try container.decode(Unions.FeedArticlesResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "filters": + if let value = try container.decode(Unions.FiltersResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "getFollowers": if let value = try container.decode(Unions.GetFollowersResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11203,6 +12373,10 @@ extension Objects.Query: Decodable { if let value = try container.decode(Unions.GetUserPersonalizationResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "groups": + if let value = try container.decode(Unions.GroupsResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "hello": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11295,9 +12469,11 @@ extension Objects.Query: Decodable { articles = map["articles"] deviceTokens = map["deviceTokens"] feedArticles = map["feedArticles"] + filters = map["filters"] getFollowers = map["getFollowers"] getFollowing = map["getFollowing"] getUserPersonalization = map["getUserPersonalization"] + groups = map["groups"] hello = map["hello"] integrations = map["integrations"] labels = map["labels"] @@ -11435,6 +12611,25 @@ extension Fields where TypeLock == Objects.Query { } } + func filters(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "filters", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.filters[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func getFollowers(userId: OptionalArgument = .absent(), selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "getFollowers", @@ -11492,6 +12687,25 @@ extension Fields where TypeLock == Objects.Query { } } + func groups(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "groups", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.groups[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func hello() throws -> String? { let field = GraphQLField.leaf( name: "hello", @@ -12384,6 +13598,637 @@ extension Selection where TypeLock == Never, Type == Never { typealias RecentSearchesSuccess = Selection } +extension Objects { + struct RecommendError { + let __typename: TypeName = .recommendError + let errorCodes: [String: [Enums.RecommendErrorCode]] + + enum TypeName: String, Codable { + case recommendError = "RecommendError" + } + } +} + +extension Objects.RecommendError: 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.RecommendErrorCode]?.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.RecommendError { + func errorCodes() throws -> [Enums.RecommendErrorCode] { + 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 RecommendError = Selection +} + +extension Objects { + struct RecommendSuccess { + let __typename: TypeName = .recommendSuccess + let taskNames: [String: [String]] + + enum TypeName: String, Codable { + case recommendSuccess = "RecommendSuccess" + } + } +} + +extension Objects.RecommendSuccess: 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 "taskNames": + 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)." + ) + ) + } + } + + taskNames = map["taskNames"] + } +} + +extension Fields where TypeLock == Objects.RecommendSuccess { + func taskNames() throws -> [String] { + let field = GraphQLField.leaf( + name: "taskNames", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.taskNames[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias RecommendSuccess = Selection +} + +extension Objects { + struct Recommendation { + let __typename: TypeName = .recommendation + let id: [String: String] + let name: [String: String] + let note: [String: String] + let recommendedAt: [String: DateTime] + let user: [String: Objects.RecommendingUser] + + enum TypeName: String, Codable { + case recommendation = "Recommendation" + } + } +} + +extension Objects.Recommendation: 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 "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "name": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "note": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "recommendedAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "user": + if let value = try container.decode(Objects.RecommendingUser?.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)." + ) + ) + } + } + + id = map["id"] + name = map["name"] + note = map["note"] + recommendedAt = map["recommendedAt"] + user = map["user"] + } +} + +extension Fields where TypeLock == Objects.Recommendation { + 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 name() throws -> String { + let field = GraphQLField.leaf( + name: "name", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.name[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func note() throws -> String? { + let field = GraphQLField.leaf( + name: "note", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.note[field.alias!] + case .mocking: + return nil + } + } + + func recommendedAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "recommendedAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.recommendedAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } + + func user(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "user", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.user[field.alias!]) + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias Recommendation = Selection +} + +extension Objects { + struct RecommendationGroup { + let __typename: TypeName = .recommendationGroup + let admins: [String: [Objects.User]] + let createdAt: [String: DateTime] + let id: [String: String] + let inviteUrl: [String: String] + let members: [String: [Objects.User]] + let name: [String: String] + let updatedAt: [String: DateTime] + + enum TypeName: String, Codable { + case recommendationGroup = "RecommendationGroup" + } + } +} + +extension Objects.RecommendationGroup: 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 "admins": + if let value = try container.decode([Objects.User]?.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 "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "inviteUrl": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "members": + if let value = try container.decode([Objects.User]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "name": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "updatedAt": + if let value = try container.decode(DateTime?.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)." + ) + ) + } + } + + admins = map["admins"] + createdAt = map["createdAt"] + id = map["id"] + inviteUrl = map["inviteUrl"] + members = map["members"] + name = map["name"] + updatedAt = map["updatedAt"] + } +} + +extension Fields where TypeLock == Objects.RecommendationGroup { + func admins(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "admins", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.admins[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + + 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 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 inviteUrl() throws -> String { + let field = GraphQLField.leaf( + name: "inviteUrl", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.inviteUrl[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func members(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "members", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.members[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + + func name() throws -> String { + let field = GraphQLField.leaf( + name: "name", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.name[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func updatedAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "updatedAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.updatedAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias RecommendationGroup = Selection +} + +extension Objects { + struct RecommendingUser { + let __typename: TypeName = .recommendingUser + let name: [String: String] + let profileImageUrl: [String: String] + let userId: [String: String] + let username: [String: String] + + enum TypeName: String, Codable { + case recommendingUser = "RecommendingUser" + } + } +} + +extension Objects.RecommendingUser: 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 "name": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "profileImageUrl": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "userId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "username": + 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)." + ) + ) + } + } + + name = map["name"] + profileImageUrl = map["profileImageUrl"] + userId = map["userId"] + username = map["username"] + } +} + +extension Fields where TypeLock == Objects.RecommendingUser { + func name() throws -> String { + let field = GraphQLField.leaf( + name: "name", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.name[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func profileImageUrl() throws -> String? { + let field = GraphQLField.leaf( + name: "profileImageURL", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.profileImageUrl[field.alias!] + case .mocking: + return nil + } + } + + func userId() throws -> String { + let field = GraphQLField.leaf( + name: "userId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.userId[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func username() throws -> String { + let field = GraphQLField.leaf( + name: "username", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.username[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias RecommendingUser = Selection +} + extension Objects { struct Reminder { let __typename: TypeName = .reminder @@ -13495,6 +15340,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias SaveError = Selection } +extension Objects { + struct SaveFilterError { + let __typename: TypeName = .saveFilterError + let errorCodes: [String: [Enums.SaveFilterErrorCode]] + + enum TypeName: String, Codable { + case saveFilterError = "SaveFilterError" + } + } +} + +extension Objects.SaveFilterError: 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.SaveFilterErrorCode]?.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.SaveFilterError { + func errorCodes() throws -> [Enums.SaveFilterErrorCode] { + 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 SaveFilterError = Selection +} + +extension Objects { + struct SaveFilterSuccess { + let __typename: TypeName = .saveFilterSuccess + let filter: [String: Objects.Filter] + + enum TypeName: String, Codable { + case saveFilterSuccess = "SaveFilterSuccess" + } + } +} + +extension Objects.SaveFilterSuccess: 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 "filter": + if let value = try container.decode(Objects.Filter?.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)." + ) + ) + } + } + + filter = map["filter"] + } +} + +extension Fields where TypeLock == Objects.SaveFilterSuccess { + func filter(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "filter", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.filter[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SaveFilterSuccess = Selection +} + extension Objects { struct SaveSuccess { let __typename: TypeName = .saveSuccess @@ -13672,6 +15648,7 @@ extension Objects { let readAt: [String: DateTime] let readingProgressAnchorIndex: [String: Int] let readingProgressPercent: [String: Double] + let recommendations: [String: [Objects.Recommendation]] let savedAt: [String: DateTime] let shortId: [String: String] let siteIcon: [String: String] @@ -13784,6 +15761,10 @@ extension Objects.SearchItem: Decodable { if let value = try container.decode(Double?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "recommendations": + if let value = try container.decode([Objects.Recommendation]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "savedAt": if let value = try container.decode(DateTime?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -13866,6 +15847,7 @@ extension Objects.SearchItem: Decodable { readAt = map["readAt"] readingProgressAnchorIndex = map["readingProgressAnchorIndex"] readingProgressPercent = map["readingProgressPercent"] + recommendations = map["recommendations"] savedAt = map["savedAt"] shortId = map["shortId"] siteIcon = map["siteIcon"] @@ -14206,6 +16188,22 @@ extension Fields where TypeLock == Objects.SearchItem { } } + func recommendations(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "recommendations", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.recommendations[field.alias!]) + case .mocking: + return selection.mock() + } + } + func savedAt() throws -> DateTime { let field = GraphQLField.leaf( name: "savedAt", @@ -20940,6 +22938,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias CreateArticleSavingRequestResult = Selection } +extension Unions { + struct CreateGroupResult { + let __typename: TypeName + let errorCodes: [String: [Enums.CreateGroupErrorCode]] + let group: [String: Objects.RecommendationGroup] + + enum TypeName: String, Codable { + case createGroupError = "CreateGroupError" + case createGroupSuccess = "CreateGroupSuccess" + } + } +} + +extension Unions.CreateGroupResult: 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.CreateGroupErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "group": + if let value = try container.decode(Objects.RecommendationGroup?.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"] + group = map["group"] + } +} + +extension Fields where TypeLock == Unions.CreateGroupResult { + func on(createGroupError: Selection, createGroupSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "CreateGroupError", selection: createGroupError.selection), GraphQLField.fragment(type: "CreateGroupSuccess", selection: createGroupSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .createGroupError: + let data = Objects.CreateGroupError(errorCodes: data.errorCodes) + return try createGroupError.decode(data: data) + case .createGroupSuccess: + let data = Objects.CreateGroupSuccess(group: data.group) + return try createGroupSuccess.decode(data: data) + } + case .mocking: + return createGroupError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias CreateGroupResult = Selection +} + extension Unions { struct CreateHighlightReplyResult { let __typename: TypeName @@ -21458,6 +23530,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeleteAccountResult = Selection } +extension Unions { + struct DeleteFilterResult { + let __typename: TypeName + let errorCodes: [String: [Enums.DeleteFilterErrorCode]] + let filter: [String: Objects.Filter] + + enum TypeName: String, Codable { + case deleteFilterError = "DeleteFilterError" + case deleteFilterSuccess = "DeleteFilterSuccess" + } + } +} + +extension Unions.DeleteFilterResult: 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.DeleteFilterErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "filter": + if let value = try container.decode(Objects.Filter?.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"] + filter = map["filter"] + } +} + +extension Fields where TypeLock == Unions.DeleteFilterResult { + func on(deleteFilterError: Selection, deleteFilterSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "DeleteFilterError", selection: deleteFilterError.selection), GraphQLField.fragment(type: "DeleteFilterSuccess", selection: deleteFilterSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .deleteFilterError: + let data = Objects.DeleteFilterError(errorCodes: data.errorCodes) + return try deleteFilterError.decode(data: data) + case .deleteFilterSuccess: + let data = Objects.DeleteFilterSuccess(filter: data.filter) + return try deleteFilterSuccess.decode(data: data) + } + case .mocking: + return deleteFilterError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteFilterResult = Selection +} + extension Unions { struct DeleteHighlightReplyResult { let __typename: TypeName @@ -22278,6 +24424,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias FeedArticlesResult = Selection } +extension Unions { + struct FiltersResult { + let __typename: TypeName + let errorCodes: [String: [Enums.FiltersErrorCode]] + let filters: [String: [Objects.Filter]] + + enum TypeName: String, Codable { + case filtersError = "FiltersError" + case filtersSuccess = "FiltersSuccess" + } + } +} + +extension Unions.FiltersResult: 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.FiltersErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "filters": + if let value = try container.decode([Objects.Filter]?.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"] + filters = map["filters"] + } +} + +extension Fields where TypeLock == Unions.FiltersResult { + func on(filtersError: Selection, filtersSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "FiltersError", selection: filtersError.selection), GraphQLField.fragment(type: "FiltersSuccess", selection: filtersSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .filtersError: + let data = Objects.FiltersError(errorCodes: data.errorCodes) + return try filtersError.decode(data: data) + case .filtersSuccess: + let data = Objects.FiltersSuccess(filters: data.filters) + return try filtersSuccess.decode(data: data) + } + case .mocking: + return filtersError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias FiltersResult = Selection +} + extension Unions { struct GenerateApiKeyResult { let __typename: TypeName @@ -22648,6 +24868,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias GoogleSignupResult = Selection } +extension Unions { + struct GroupsResult { + let __typename: TypeName + let errorCodes: [String: [Enums.GroupsErrorCode]] + let groups: [String: [Objects.RecommendationGroup]] + + enum TypeName: String, Codable { + case groupsError = "GroupsError" + case groupsSuccess = "GroupsSuccess" + } + } +} + +extension Unions.GroupsResult: 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.GroupsErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "groups": + if let value = try container.decode([Objects.RecommendationGroup]?.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"] + groups = map["groups"] + } +} + +extension Fields where TypeLock == Unions.GroupsResult { + func on(groupsError: Selection, groupsSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "GroupsError", selection: groupsError.selection), GraphQLField.fragment(type: "GroupsSuccess", selection: groupsSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .groupsError: + let data = Objects.GroupsError(errorCodes: data.errorCodes) + return try groupsError.decode(data: data) + case .groupsSuccess: + let data = Objects.GroupsSuccess(groups: data.groups) + return try groupsSuccess.decode(data: data) + } + case .mocking: + return groupsError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GroupsResult = Selection +} + extension Unions { struct IntegrationsResult { let __typename: TypeName @@ -22722,6 +25016,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias IntegrationsResult = Selection } +extension Unions { + struct JoinGroupResult { + let __typename: TypeName + let errorCodes: [String: [Enums.JoinGroupErrorCode]] + let group: [String: Objects.RecommendationGroup] + + enum TypeName: String, Codable { + case joinGroupError = "JoinGroupError" + case joinGroupSuccess = "JoinGroupSuccess" + } + } +} + +extension Unions.JoinGroupResult: 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.JoinGroupErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "group": + if let value = try container.decode(Objects.RecommendationGroup?.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"] + group = map["group"] + } +} + +extension Fields where TypeLock == Unions.JoinGroupResult { + func on(joinGroupError: Selection, joinGroupSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "JoinGroupError", selection: joinGroupError.selection), GraphQLField.fragment(type: "JoinGroupSuccess", selection: joinGroupSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .joinGroupError: + let data = Objects.JoinGroupError(errorCodes: data.errorCodes) + return try joinGroupError.decode(data: data) + case .joinGroupSuccess: + let data = Objects.JoinGroupSuccess(group: data.group) + return try joinGroupSuccess.decode(data: data) + } + case .mocking: + return joinGroupError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias JoinGroupResult = Selection +} + extension Unions { struct LabelsResult { let __typename: TypeName @@ -23024,6 +25392,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias MergeHighlightResult = Selection } +extension Unions { + struct MoveFilterResult { + let __typename: TypeName + let errorCodes: [String: [Enums.MoveFilterErrorCode]] + let filter: [String: Objects.Filter] + + enum TypeName: String, Codable { + case moveFilterError = "MoveFilterError" + case moveFilterSuccess = "MoveFilterSuccess" + } + } +} + +extension Unions.MoveFilterResult: 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.MoveFilterErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "filter": + if let value = try container.decode(Objects.Filter?.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"] + filter = map["filter"] + } +} + +extension Fields where TypeLock == Unions.MoveFilterResult { + func on(moveFilterError: Selection, moveFilterSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "MoveFilterError", selection: moveFilterError.selection), GraphQLField.fragment(type: "MoveFilterSuccess", selection: moveFilterSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .moveFilterError: + let data = Objects.MoveFilterError(errorCodes: data.errorCodes) + return try moveFilterError.decode(data: data) + case .moveFilterSuccess: + let data = Objects.MoveFilterSuccess(filter: data.filter) + return try moveFilterSuccess.decode(data: data) + } + case .mocking: + return moveFilterError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias MoveFilterResult = Selection +} + extension Unions { struct MoveLabelResult { let __typename: TypeName @@ -23320,6 +25762,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias RecentSearchesResult = Selection } +extension Unions { + struct RecommendResult { + let __typename: TypeName + let errorCodes: [String: [Enums.RecommendErrorCode]] + let taskNames: [String: [String]] + + enum TypeName: String, Codable { + case recommendError = "RecommendError" + case recommendSuccess = "RecommendSuccess" + } + } +} + +extension Unions.RecommendResult: 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.RecommendErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "taskNames": + 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)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + taskNames = map["taskNames"] + } +} + +extension Fields where TypeLock == Unions.RecommendResult { + func on(recommendError: Selection, recommendSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "RecommendError", selection: recommendError.selection), GraphQLField.fragment(type: "RecommendSuccess", selection: recommendSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .recommendError: + let data = Objects.RecommendError(errorCodes: data.errorCodes) + return try recommendError.decode(data: data) + case .recommendSuccess: + let data = Objects.RecommendSuccess(taskNames: data.taskNames) + return try recommendSuccess.decode(data: data) + } + case .mocking: + return recommendError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias RecommendResult = Selection +} + extension Unions { struct ReminderResult { let __typename: TypeName @@ -23616,6 +26132,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias SaveArticleReadingProgressResult = Selection } +extension Unions { + struct SaveFilterResult { + let __typename: TypeName + let errorCodes: [String: [Enums.SaveFilterErrorCode]] + let filter: [String: Objects.Filter] + + enum TypeName: String, Codable { + case saveFilterError = "SaveFilterError" + case saveFilterSuccess = "SaveFilterSuccess" + } + } +} + +extension Unions.SaveFilterResult: 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.SaveFilterErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "filter": + if let value = try container.decode(Objects.Filter?.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"] + filter = map["filter"] + } +} + +extension Fields where TypeLock == Unions.SaveFilterResult { + func on(saveFilterError: Selection, saveFilterSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "SaveFilterError", selection: saveFilterError.selection), GraphQLField.fragment(type: "SaveFilterSuccess", selection: saveFilterSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .saveFilterError: + let data = Objects.SaveFilterError(errorCodes: data.errorCodes) + return try saveFilterError.decode(data: data) + case .saveFilterSuccess: + let data = Objects.SaveFilterSuccess(filter: data.filter) + return try saveFilterSuccess.decode(data: data) + } + case .mocking: + return saveFilterError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SaveFilterResult = Selection +} + extension Unions { struct SaveResult { let __typename: TypeName @@ -26227,6 +28817,15 @@ extension Enums { } } +extension Enums { + /// CreateGroupErrorCode + enum CreateGroupErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// CreateHighlightErrorCode enum CreateHighlightErrorCode: String, CaseIterable, Codable { @@ -26314,6 +28913,17 @@ extension Enums { } } +extension Enums { + /// DeleteFilterErrorCode + enum DeleteFilterErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// DeleteHighlightErrorCode enum DeleteHighlightErrorCode: String, CaseIterable, Codable { @@ -26429,6 +29039,15 @@ extension Enums { } } +extension Enums { + /// FiltersErrorCode + enum FiltersErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// GenerateApiKeyErrorCode enum GenerateApiKeyErrorCode: String, CaseIterable, Codable { @@ -26461,6 +29080,15 @@ extension Enums { } } +extension Enums { + /// GroupsErrorCode + enum GroupsErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// IntegrationType enum IntegrationType: String, CaseIterable, Codable { @@ -26477,6 +29105,17 @@ extension Enums { } } +extension Enums { + /// JoinGroupErrorCode + enum JoinGroupErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// LabelsErrorCode enum LabelsErrorCode: String, CaseIterable, Codable { @@ -26527,6 +29166,17 @@ extension Enums { } } +extension Enums { + /// MoveFilterErrorCode + enum MoveFilterErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// MoveLabelErrorCode enum MoveLabelErrorCode: String, CaseIterable, Codable { @@ -26601,6 +29251,17 @@ extension Enums { } } +extension Enums { + /// RecommendErrorCode + enum RecommendErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// ReminderErrorCode enum ReminderErrorCode: String, CaseIterable, Codable { @@ -26680,6 +29341,17 @@ extension Enums { } } +extension Enums { + /// SaveFilterErrorCode + enum SaveFilterErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// SearchErrorCode enum SearchErrorCode: String, CaseIterable, Codable { @@ -27208,6 +29880,29 @@ extension InputObjects { } } +extension InputObjects { + struct CreateGroupInput: Encodable, Hashable { + var expiresInDays: OptionalArgument = .absent() + + var maxMembers: OptionalArgument = .absent() + + var name: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if expiresInDays.hasValue { try container.encode(expiresInDays, forKey: .expiresInDays) } + if maxMembers.hasValue { try container.encode(maxMembers, forKey: .maxMembers) } + try container.encode(name, forKey: .name) + } + + enum CodingKeys: String, CodingKey { + case expiresInDays + case maxMembers + case name + } + } +} + extension InputObjects { struct CreateHighlightInput: Encodable, Hashable { var annotation: OptionalArgument = .absent() @@ -27495,6 +30190,25 @@ extension InputObjects { } } +extension InputObjects { + struct MoveFilterInput: Encodable, Hashable { + var afterFilterId: OptionalArgument = .absent() + + var filterId: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if afterFilterId.hasValue { try container.encode(afterFilterId, forKey: .afterFilterId) } + try container.encode(filterId, forKey: .filterId) + } + + enum CodingKeys: String, CodingKey { + case afterFilterId + case filterId + } + } +} + extension InputObjects { struct MoveLabelInput: Encodable, Hashable { var afterLabelId: OptionalArgument = .absent() @@ -27587,6 +30301,29 @@ extension InputObjects { } } +extension InputObjects { + struct RecommendInput: Encodable, Hashable { + var groupIds: [String] + + var note: OptionalArgument = .absent() + + var pageId: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(groupIds, forKey: .groupIds) + if note.hasValue { try container.encode(note, forKey: .note) } + try container.encode(pageId, forKey: .pageId) + } + + enum CodingKeys: String, CodingKey { + case groupIds + case note + case pageId + } + } +} + extension InputObjects { struct ReportItemInput: Encodable, Hashable { var itemUrl: String @@ -27687,6 +30424,33 @@ extension InputObjects { } } +extension InputObjects { + struct SaveFilterInput: Encodable, Hashable { + var description: OptionalArgument = .absent() + + var filter: String + + var id: OptionalArgument = .absent() + + var name: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if description.hasValue { try container.encode(description, forKey: .description) } + try container.encode(filter, forKey: .filter) + if id.hasValue { try container.encode(id, forKey: .id) } + try container.encode(name, forKey: .name) + } + + enum CodingKeys: String, CodingKey { + case description + case filter + case id + case name + } + } +} + extension InputObjects { struct SavePageInput: Encodable, Hashable { var clientRequestId: String @@ -28149,6 +30913,8 @@ extension InputObjects { var pageId: String + var savedAt: OptionalArgument = .absent() + var title: OptionalArgument = .absent() func encode(to encoder: Encoder) throws { @@ -28156,6 +30922,7 @@ extension InputObjects { if byline.hasValue { try container.encode(byline, forKey: .byline) } if description.hasValue { try container.encode(description, forKey: .description) } try container.encode(pageId, forKey: .pageId) + if savedAt.hasValue { try container.encode(savedAt, forKey: .savedAt) } if title.hasValue { try container.encode(title, forKey: .title) } } @@ -28163,6 +30930,7 @@ extension InputObjects { case byline case description case pageId + case savedAt case title } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateRecommendationGroup.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateRecommendationGroup.swift new file mode 100644 index 000000000..646cf45dc --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateRecommendationGroup.swift @@ -0,0 +1,53 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func createRecommendationGroup(name: String) async throws -> InternalRecommendationGroup { + enum MutationResult { + case saved(recommendationGroup: InternalRecommendationGroup) + case error(errorCode: Enums.CreateGroupErrorCode) + } + + let selection = Selection { + try $0.on( + createGroupError: .init { + .error(errorCode: try $0.errorCodes().first ?? .badRequest) + }, + createGroupSuccess: .init { + .saved(recommendationGroup: try $0.group(selection: recommendationGroupSelection)) + } + ) + } + + let input = InputObjects.CreateGroupInput(name: name) + + let mutation = Selection.Mutation { + try $0.createGroup(input: input, selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { [weak self] queryResult in + guard let payload = try? queryResult.get(), let self = self else { + continuation.resume(throwing: BasicError.message(messageText: "network error")) + return + } + + switch payload.data { + case let .saved(recommendationGroup: recommendationGroup): + if [recommendationGroup].persist(context: self.backgroundContext) != nil { + continuation.resume(returning: recommendationGroup) + } else { + continuation.resume(throwing: BasicError.message(messageText: "CoreData error")) + } + case let .error(errorCode: errorCode): + continuation.resume(throwing: BasicError.message(messageText: errorCode.rawValue)) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RecommendPage.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RecommendPage.swift new file mode 100644 index 000000000..68f604817 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RecommendPage.swift @@ -0,0 +1,48 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func recommendPage(pageID: String, groupIDs: [String], note: String?) async throws { + enum MutationResult { + case saved(taskNames: [String]) + case error(errorMessage: String) + } + + let selection = Selection { + try $0.on( + recommendError: .init { .error(errorMessage: try $0.errorCodes().first.toString()) }, + recommendSuccess: .init { + .saved(taskNames: try $0.taskNames()) + } + ) + } + + let mutation = Selection.Mutation { + try $0.recommend( + input: .init(groupIds: groupIDs, note: OptionalArgument(note), pageId: pageID), + selection: selection + ) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(mutation, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network error")) + return + } + + switch payload.data { + case .saved: + continuation.resume() + case let .error(errorMessage: errorMessage): + continuation.resume(throwing: BasicError.message(messageText: errorMessage)) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift index f8b427b37..bf4d9ffef 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePage.swift @@ -72,6 +72,7 @@ extension SaveArticleError { case .network, .timeout: return .network case .badpayload, .badURL, .badstatus, .cancelled: + print("HTTP ERROR", httpError) return .unknown(description: httpError.localizedDescription) } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift index 46c4160db..a9170e38e 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift @@ -44,6 +44,7 @@ extension DataService { contentReader: try $0.contentReader().rawValue, originalHtml: nil, language: try $0.language(), + recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [], labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [] ), htmlContent: try $0.content(), diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift index ce5d1fb7e..51ded19f7 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift @@ -224,6 +224,40 @@ extension DataService { } } +let recommendingUserSelection = Selection.RecommendingUser { + do { + return InternalUserProfile( + userID: try $0.userId(), + name: try $0.name(), + username: try $0.username(), + profileImageURL: nil // try $0.profileImageUrl() ?? nil + ) + } catch { + print("ERROR WITH recommendingUserSelection", error) + throw error + } +} + +let recommendationSelection = Selection.Recommendation { + do { + let result = InternalRecommendation( + id: try $0.id(), + name: try $0.name(), + user: try $0.user(selection: recommendingUserSelection.nullable), + recommendedAt: try $0.recommendedAt().value ?? Date() + ) + return result + } catch { + print("ERROR WITH recommendationSelection", error) + throw error + } +} + +private func emptyrecommended() -> [InternalRecommendation] { + print("got the empty InternalRecommendation") + return [] +} + private let libraryArticleSelection = Selection.Article { InternalLinkedItem( id: try $0.id(), @@ -249,6 +283,7 @@ private let libraryArticleSelection = Selection.Article { contentReader: try $0.contentReader().rawValue, originalHtml: nil, language: try $0.language(), + recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? emptyrecommended(), labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [] ) } @@ -286,6 +321,7 @@ private let searchItemSelection = Selection.SearchItem { contentReader: try $0.contentReader().rawValue, originalHtml: nil, language: try $0.language(), + recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [], labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [] ) } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/RecommendationGroupsQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/RecommendationGroupsQuery.swift new file mode 100644 index 000000000..a17a5c52c --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/RecommendationGroupsQuery.swift @@ -0,0 +1,52 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func recommendationGroups() async throws -> [InternalRecommendationGroup] { + enum QueryResult { + case success(result: [InternalRecommendationGroup]) + case error(error: String) + } + + let selection = Selection { + try $0.on( + groupsError: .init { + QueryResult.error(error: try $0.errorCodes().description) + }, + groupsSuccess: .init { + QueryResult.success(result: try $0.groups(selection: recommendationGroupSelection.list)) + } + ) + } + + let query = Selection.Query { + try $0.groups(selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + let context = backgroundContext + + return try await withCheckedThrowingContinuation { continuation in + send(query, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network request failed")) + return + } + + switch payload.data { + case let .success(result: result): + if result.persist(context: context) != nil { + continuation.resume(returning: result) + } else { + continuation.resume(throwing: BasicError.message(messageText: "CoreData error")) + } + case .error: + continuation.resume(throwing: BasicError.message(messageText: "Recommendation Groups fetch error")) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/RecommendationGroupSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/RecommendationGroupSelection.swift new file mode 100644 index 000000000..7759f9814 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/RecommendationGroupSelection.swift @@ -0,0 +1,12 @@ +import Models +import SwiftGraphQL + +let recommendationGroupSelection = Selection.RecommendationGroup { + InternalRecommendationGroup( + id: try $0.id(), + name: try $0.name(), + inviteUrl: try $0.inviteUrl(), + admins: try $0.admins(selection: userProfileSelection.list), + members: try $0.members(selection: userProfileSelection.list) + ) +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/UserProfileSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/UserProfileSelection.swift new file mode 100644 index 000000000..ad9562955 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/UserProfileSelection.swift @@ -0,0 +1,18 @@ +import Models +import SwiftGraphQL + +let profileSelection = Selection.Profile { + ( + username: try $0.username(), + profileImageURL: try $0.pictureUrl() + ) +} + +let userProfileSelection = Selection.User { + InternalUserProfile( + userID: try $0.id(), + name: try $0.name(), + username: try $0.profile(selection: profileSelection).username, + profileImageURL: try $0.profile(selection: profileSelection).profileImageURL + ) +} diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift index 1950ef3c6..46827adf8 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift @@ -26,6 +26,7 @@ struct InternalLinkedItem { let contentReader: String? let originalHtml: String? let language: String? + let recommendations: [InternalRecommendation] var labels: [InternalLinkedItemLabel] var isPDF: Bool { @@ -72,6 +73,14 @@ struct InternalLinkedItem { linkedItem.addToLabels(label.asManagedObject(inContext: context)) } + if let existingRecommendation = linkedItem.recommendations { + linkedItem.removeFromRecommendations(existingRecommendation) + } + + for recommendation in recommendations { + linkedItem.addToRecommendations(recommendation.asManagedObject(inContext: context)) + } + return linkedItem } } @@ -133,6 +142,7 @@ extension JSONArticle { contentReader: contentReader, originalHtml: nil, language: language, + recommendations: [], // TODO: labels: [] ) diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendation.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendation.swift new file mode 100644 index 000000000..80a33b0cc --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendation.swift @@ -0,0 +1,39 @@ +import CoreData +import Foundation +import Models + +public struct InternalRecommendation { + let id: String + let name: String + let user: InternalUserProfile? + let recommendedAt: Date + + func asManagedObject(inContext context: NSManagedObjectContext) -> Recommendation { + let existing = Recommendation.lookup(byID: id, inContext: context) + let recommendation = existing ?? Recommendation(entity: Recommendation.entity(), insertInto: context) + recommendation.id = id + recommendation.name = name + recommendation.recommendedAt = recommendedAt + recommendation.user = user?.asManagedObject(inContext: context) + return recommendation + } + + public static func make(_ recommendations: NSSet?) -> [InternalRecommendation] { + recommendations? + .compactMap { recommendation in + if let recommendation = recommendation as? Recommendation, + let id = recommendation.id, + let name = recommendation.name, + let recommendedAt = recommendation.recommendedAt + { + return InternalRecommendation( + id: id, + name: name, + user: InternalUserProfile.makeSingle(recommendation.user), + recommendedAt: recommendedAt + ) + } + return nil + } ?? [] + } +} diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift new file mode 100644 index 000000000..ff90cc253 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalRecommendationGroup.swift @@ -0,0 +1,69 @@ +// +// InternalRecommendationGroup.swift +// +// +// Created by Jackson Harper on 12/5/22. +// + +import CoreData +import Foundation +import Models + +public struct InternalRecommendationGroup: Identifiable { + public let id: String + public let name: String + public let inviteUrl: String + public let admins: [InternalUserProfile] + public let members: [InternalUserProfile] + + func asManagedObject(inContext context: NSManagedObjectContext) -> RecommendationGroup { + let fetchRequest: NSFetchRequest = RecommendationGroup.fetchRequest() + fetchRequest.predicate = NSPredicate( + format: "id == %@", id + ) + let existing = (try? context.fetch(fetchRequest))?.first + let recommendationGroup = existing ?? RecommendationGroup(entity: RecommendationGroup.entity(), insertInto: context) + + recommendationGroup.id = id + recommendationGroup.name = name + recommendationGroup.inviteUrl = inviteUrl + + return recommendationGroup + } + + static func make(from recommendationGroup: RecommendationGroup) -> InternalRecommendationGroup? { + if let id = recommendationGroup.id, + let name = recommendationGroup.name, + let inviteUrl = recommendationGroup.inviteUrl + { + return InternalRecommendationGroup( + id: id, + name: name, + inviteUrl: inviteUrl, + admins: InternalUserProfile.make(recommendationGroup.admins), + members: InternalUserProfile.make(recommendationGroup.members) + ) + } + return nil + } +} + +extension Sequence where Element == InternalRecommendationGroup { + func persist(context: NSManagedObjectContext) -> [NSManagedObjectID]? { + var result: [NSManagedObjectID]? + + context.performAndWait { + let recommendationGroups = map { $0.asManagedObject(inContext: context) } + do { + try context.save() + logger.debug("InternalRecommendationGroup saved succesfully") + result = recommendationGroups.map(\.objectID) + } catch { + context.rollback() + logger.debug("Failed to save InternalRecommendationGroups: \(error.localizedDescription)") + } + } + + return result + } +} diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalUserProfile.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalUserProfile.swift new file mode 100644 index 000000000..53b656890 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalUserProfile.swift @@ -0,0 +1,79 @@ +import CoreData +import Foundation +import Models + +public struct InternalUserProfile: Identifiable { + let userID: String + public let name: String + public let username: String + public let profileImageURL: String? + + public var id: String { + userID + } + + func persist(context: NSManagedObjectContext) -> NSManagedObjectID? { + var objectID: NSManagedObjectID? + + context.performAndWait { + let user = asManagedObject(inContext: context) + + do { + try context.save() + logger.debug("User saved succesfully") + objectID = user.objectID + } catch { + context.rollback() + logger.debug("Failed to save User: \(error.localizedDescription)") + } + } + + return objectID + } + + func asManagedObject(inContext context: NSManagedObjectContext) -> UserProfile { + let existing = UserProfile.lookup(byID: userID, inContext: context) + let userProfile = existing ?? UserProfile(entity: UserProfile.entity(), insertInto: context) + + userProfile.userID = userID + userProfile.name = name + userProfile.username = username + userProfile.profileImageURL = profileImageURL + return userProfile + } + + public static func makeSingle(_ user: UserProfile?) -> InternalUserProfile? { + if let user = user, + let userID = user.userID, + let name = user.name, + let username = user.username + { + return InternalUserProfile( + userID: userID, + name: name, + username: username, + profileImageURL: user.profileImageURL + ) + } + return nil + } + + public static func make(_ users: NSSet?) -> [InternalUserProfile] { + users? + .compactMap { user in + if let user = user as? UserProfile, + let userID = user.userID, + let name = user.name, + let username = user.username + { + return InternalUserProfile( + userID: userID, + name: name, + username: username, + profileImageURL: user.profileImageURL + ) + } + return nil + } ?? [] + } +} diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift index 42af43b98..027bc4427 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift @@ -86,6 +86,27 @@ public struct FeedCard: View { } #endif } + + if let recommendations = item.recommendations, recommendations.count > 0 { + let byStr = recommendations.reduce("") { str, item in + if let item = item as? Recommendation, let name = item.user?.name { + return str + name + } + return str + } + let inStr = recommendations.reduce("") { str, item in + if let item = item as? Recommendation, let name = item.name { + return str + name + } + return str + } + HStack { + Text("Recommended by \(byStr) in \(inStr)") + .font(.appCaption) + .frame(alignment: .leading) + Spacer() + } + } } .padding(.top, 0) .padding(.bottom, 8) diff --git a/apple/OmnivoreKit/Sources/Views/ProfileCard.swift b/apple/OmnivoreKit/Sources/Views/ProfileCard.swift index 9e923a560..dc1dc69aa 100644 --- a/apple/OmnivoreKit/Sources/Views/ProfileCard.swift +++ b/apple/OmnivoreKit/Sources/Views/ProfileCard.swift @@ -1,9 +1,9 @@ import SwiftUI public struct ProfileCardData { - let name: String - let username: String - let imageURL: URL? + public let name: String + public let username: String + public let imageURL: URL? public init(name: String = "", username: String = "", imageURL: URL? = nil) { self.name = name diff --git a/packages/api/src/elastic/pages.ts b/packages/api/src/elastic/pages.ts index 8cb48fbbf..bce0dba98 100644 --- a/packages/api/src/elastic/pages.ts +++ b/packages/api/src/elastic/pages.ts @@ -189,10 +189,10 @@ const appendIdsFilter = (body: SearchBody, ids: string[]): void => { const appendRecommendedBy = (body: SearchBody, recommendedBy: string): void => { body.query.bool.must.push({ nested: { - path: 'recommendedBy', + path: 'recommendations', query: { term: { - 'recommendedBy.name': recommendedBy, + 'recommendations.name': recommendedBy, }, }, }, diff --git a/packages/api/src/elastic/recommendation.ts b/packages/api/src/elastic/recommendation.ts index 511b3fae1..924f444fe 100644 --- a/packages/api/src/elastic/recommendation.ts +++ b/packages/api/src/elastic/recommendation.ts @@ -13,19 +13,19 @@ export const addRecommendation = async ( url: page.url, }) if (existingPage) { - if (existingPage.recommendedBy?.includes(recommendation)) { + if (existingPage.recommendations?.includes(recommendation)) { return existingPage._id } // update recommendedBy in the existing page - const recommendedBy = (existingPage.recommendedBy || []).concat( + const recommendations = (existingPage.recommendations || []).concat( recommendation ) await updatePage( existingPage.id, { - recommendedBy, + recommendations, }, ctx ) @@ -36,7 +36,7 @@ export const addRecommendation = async ( const newPage: Page = { ...page, id: '', - recommendedBy: [recommendation], + recommendations: [recommendation], userId: ctx.uid, readingProgressPercent: 0, readingProgressAnchorIndex: 0, diff --git a/packages/api/src/elastic/types.ts b/packages/api/src/elastic/types.ts index de2a0dd58..9b7122e57 100644 --- a/packages/api/src/elastic/types.ts +++ b/packages/api/src/elastic/types.ts @@ -53,10 +53,10 @@ export interface SearchBody { } | { nested: { - path: 'recommendedBy' + path: 'recommendations' query: { term: { - 'recommendedBy.name': string + 'recommendations.name': string } } } @@ -198,9 +198,18 @@ export interface Highlight { highlightPositionAnchorIndex?: number | null } +export interface RecommendingUser { + userId: string + name: string + username: string + profileImageURL: string | null +} + export interface Recommendation { id: string name: string + note: string | null + user: RecommendingUser recommendedAt: Date } @@ -240,7 +249,7 @@ export interface Page { readAt?: Date listenedAt?: Date wordsCount?: number - recommendedBy?: Recommendation[] + recommendations?: Recommendation[] } export interface SearchItem { @@ -273,7 +282,7 @@ export interface SearchItem { wordsCount?: number siteName?: string siteIcon?: string - recommendedBy?: Recommendation[] + recommendations?: Recommendation[] } const keys = ['_id', 'url', 'slug', 'userId', 'uploadFileId', 'state'] as const diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index d254c1547..8728bb795 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -110,7 +110,7 @@ export type Article = { readAt?: Maybe; readingProgressAnchorIndex: Scalars['Int']; readingProgressPercent: Scalars['Float']; - recommendedBy?: Maybe>; + recommendations?: Maybe>; savedAt: Scalars['Date']; savedByViewer?: Maybe; shareInfo?: Maybe; @@ -1787,6 +1787,7 @@ export enum RecommendErrorCode { export type RecommendInput = { groupIds: Array; + note?: InputMaybe; pageId: Scalars['ID']; }; @@ -1801,7 +1802,9 @@ export type Recommendation = { __typename?: 'Recommendation'; id: Scalars['ID']; name: Scalars['String']; + note?: Maybe; recommendedAt: Scalars['Date']; + user?: Maybe; }; export type RecommendationGroup = { @@ -1815,6 +1818,14 @@ export type RecommendationGroup = { updatedAt: Scalars['Date']; }; +export type RecommendingUser = { + __typename?: 'RecommendingUser'; + name: Scalars['String']; + profileImageURL?: Maybe; + userId: Scalars['String']; + username: Scalars['String']; +}; + export type Reminder = { __typename?: 'Reminder'; archiveUntil: Scalars['Boolean']; @@ -2046,7 +2057,7 @@ export type SearchItem = { readAt?: Maybe; readingProgressAnchorIndex: Scalars['Int']; readingProgressPercent: Scalars['Float']; - recommendedBy?: Maybe>; + recommendations?: Maybe>; savedAt: Scalars['Date']; shortId?: Maybe; siteIcon?: Maybe; @@ -3230,6 +3241,7 @@ export type ResolversTypes = { RecommendSuccess: ResolverTypeWrapper; Recommendation: ResolverTypeWrapper; RecommendationGroup: ResolverTypeWrapper; + RecommendingUser: ResolverTypeWrapper; Reminder: ResolverTypeWrapper; ReminderError: ResolverTypeWrapper; ReminderErrorCode: ReminderErrorCode; @@ -3631,6 +3643,7 @@ export type ResolversParentTypes = { RecommendSuccess: RecommendSuccess; Recommendation: Recommendation; RecommendationGroup: RecommendationGroup; + RecommendingUser: RecommendingUser; Reminder: Reminder; ReminderError: ReminderError; ReminderResult: ResolversParentTypes['ReminderError'] | ResolversParentTypes['ReminderSuccess']; @@ -3876,7 +3889,7 @@ export type ArticleResolvers, ParentType, ContextType>; readingProgressAnchorIndex?: Resolver; readingProgressPercent?: Resolver; - recommendedBy?: Resolver>, ParentType, ContextType>; + recommendations?: Resolver>, ParentType, ContextType>; savedAt?: Resolver; savedByViewer?: Resolver, ParentType, ContextType>; shareInfo?: Resolver, ParentType, ContextType>; @@ -4836,7 +4849,9 @@ export type RecommendSuccessResolvers = { id?: Resolver; name?: Resolver; + note?: Resolver, ParentType, ContextType>; recommendedAt?: Resolver; + user?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -4851,6 +4866,14 @@ export type RecommendationGroupResolvers; }; +export type RecommendingUserResolvers = { + name?: Resolver; + profileImageURL?: Resolver, ParentType, ContextType>; + userId?: Resolver; + username?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ReminderResolvers = { archiveUntil?: Resolver; id?: Resolver; @@ -4993,7 +5016,7 @@ export type SearchItemResolvers, ParentType, ContextType>; readingProgressAnchorIndex?: Resolver; readingProgressPercent?: Resolver; - recommendedBy?: Resolver>, ParentType, ContextType>; + recommendations?: Resolver>, ParentType, ContextType>; savedAt?: Resolver; shortId?: Resolver, ParentType, ContextType>; siteIcon?: Resolver, ParentType, ContextType>; @@ -5718,6 +5741,7 @@ export type Resolvers = { RecommendSuccess?: RecommendSuccessResolvers; Recommendation?: RecommendationResolvers; RecommendationGroup?: RecommendationGroupResolvers; + RecommendingUser?: RecommendingUserResolvers; Reminder?: ReminderResolvers; ReminderError?: ReminderErrorResolvers; ReminderResult?: ReminderResultResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 00e4a1c98..89f6cbf6b 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -86,7 +86,7 @@ type Article { readAt: Date readingProgressAnchorIndex: Int! readingProgressPercent: Float! - recommendedBy: [Recommendation!] + recommendations: [Recommendation!] savedAt: Date! savedByViewer: Boolean shareInfo: LinkShareInfo @@ -1250,6 +1250,7 @@ enum RecommendErrorCode { input RecommendInput { groupIds: [ID!]! + note: String pageId: ID! } @@ -1262,7 +1263,9 @@ type RecommendSuccess { type Recommendation { id: ID! name: String! + note: String recommendedAt: Date! + user: RecommendingUser } type RecommendationGroup { @@ -1275,6 +1278,13 @@ type RecommendationGroup { updatedAt: Date! } +type RecommendingUser { + name: String! + profileImageURL: String + userId: String! + username: String! +} + type Reminder { archiveUntil: Boolean! id: ID! @@ -1488,7 +1498,7 @@ type SearchItem { readAt: Date readingProgressAnchorIndex: Int! readingProgressPercent: Float! - recommendedBy: [Recommendation!] + recommendations: [Recommendation!] savedAt: Date! shortId: String siteIcon: String diff --git a/packages/api/src/resolvers/article_saving_request/index.ts b/packages/api/src/resolvers/article_saving_request/index.ts index 1b8f2764e..6a44d2b1a 100644 --- a/packages/api/src/resolvers/article_saving_request/index.ts +++ b/packages/api/src/resolvers/article_saving_request/index.ts @@ -42,7 +42,7 @@ export const createArticleSavingRequestResolver = authorized< articleSavingRequest: request, } } catch (err) { - console.log('error', err) + console.log('error saving article', err) if (isErrorWithCode(err)) { return { errorCodes: [err.errorCode as CreateArticleSavingRequestErrorCode], diff --git a/packages/api/src/resolvers/labels/index.ts b/packages/api/src/resolvers/labels/index.ts index 31daf9cb9..78cb75703 100644 --- a/packages/api/src/resolvers/labels/index.ts +++ b/packages/api/src/resolvers/labels/index.ts @@ -201,7 +201,7 @@ export const deleteLabelResolver = authorized< label, } } catch (error) { - log.error('error', error) + log.error('error deleting label', error) return { errorCodes: [DeleteLabelErrorCode.BadRequest], } diff --git a/packages/api/src/resolvers/recommendations/index.ts b/packages/api/src/resolvers/recommendations/index.ts index b9083844c..d3e90df89 100644 --- a/packages/api/src/resolvers/recommendations/index.ts +++ b/packages/api/src/resolvers/recommendations/index.ts @@ -146,8 +146,9 @@ export const recommendResolver = authorized< }) try { - const user = await getRepository(User).findOneBy({ - id: uid, + const user = await getRepository(User).findOne({ + where: { id: uid }, + relations: ['profile'], }) if (!user) { return { @@ -184,7 +185,15 @@ export const recommendResolver = authorized< member.user.id, page.id, { - ...group, + id: group.id, + name: group.name, + note: input.note ?? null, + user: { + userId: user.id, + name: user.name, + username: user.profile.username, + profileImageURL: user.profile.pictureUrl, + }, recommendedAt: new Date(), }, auth @@ -198,6 +207,7 @@ export const recommendResolver = authorized< taskNames, } } catch (error) { + console.log('Error recommending: ', error) log.error('Error recommending', { error, labels: { diff --git a/packages/api/src/resolvers/user/index.ts b/packages/api/src/resolvers/user/index.ts index d9a6f3828..4450ad910 100644 --- a/packages/api/src/resolvers/user/index.ts +++ b/packages/api/src/resolvers/user/index.ts @@ -211,7 +211,7 @@ export const googleSignupResolver: ResolverFn< me: userDataToUser({ ...user, profile: { ...profile, private: false } }), } } catch (err) { - console.log('error', err) + console.log('error signing up with google', err) if (isErrorWithCode(err)) { return { errorCodes: [err.errorCode as SignupErrorCode] } } diff --git a/packages/api/src/routers/auth/mobile/sign_up.ts b/packages/api/src/routers/auth/mobile/sign_up.ts index 9bfc36c78..55d9e6dea 100644 --- a/packages/api/src/routers/auth/mobile/sign_up.ts +++ b/packages/api/src/routers/auth/mobile/sign_up.ts @@ -72,7 +72,7 @@ export async function createMobileEmailSignUpResponse( json: {}, } } catch (e) { - console.log('error', e) + console.log('error creating mobile email sign up response', e) return signUpFailedPayload } } diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index fc1267142..de1a182e3 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -329,10 +329,19 @@ const schema = gql` updatedAt: Date! } + type RecommendingUser { + userId: String! + name: String! + username: String! + profileImageURL: String + } + type Recommendation { id: ID! name: String! + user: RecommendingUser recommendedAt: Date! + note: String } type Article { @@ -375,7 +384,7 @@ const schema = gql` state: ArticleSavingRequestStatus language: String readAt: Date - recommendedBy: [Recommendation!] + recommendations: [Recommendation!] } # Query: article @@ -1522,7 +1531,7 @@ const schema = gql` savedAt: Date! highlights: [Highlight!] siteIcon: String - recommendedBy: [Recommendation!] + recommendations: [Recommendation!] } type SearchItemEdge { @@ -2194,6 +2203,7 @@ const schema = gql` input RecommendInput { pageId: ID! groupIds: [ID!]! + note: String } union RecommendResult = RecommendSuccess | RecommendError diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 66ade68e5..0d1256e56 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -423,7 +423,7 @@ export const enqueueRecommendation = async ( } const headers = { - Authorization: authToken, + Cookie: `auth=${authToken}`, } // If there is no Google Cloud Project Id exposed, it means that we are in local environment if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) { diff --git a/packages/db/elastic_migrations/index_settings.json b/packages/db/elastic_migrations/index_settings.json index 5d4018d4b..cebf4c58b 100644 --- a/packages/db/elastic_migrations/index_settings.json +++ b/packages/db/elastic_migrations/index_settings.json @@ -7,13 +7,19 @@ "analyzer": { "strip_html_analyzer": { "tokenizer": "standard", - "char_filter": ["html_strip"], - "filter": ["lowercase"] + "char_filter": [ + "html_strip" + ], + "filter": [ + "lowercase" + ] } }, "normalizer": { "lowercase_normalizer": { - "filter": ["lowercase"] + "filter": [ + "lowercase" + ] } } } @@ -161,7 +167,7 @@ "wordsCount": { "type": "integer" }, - "recommendedBy": { + "recommendations": { "type": "nested", "properties": { "id": { @@ -173,9 +179,31 @@ }, "recommendedAt": { "type": "date" + }, + "note": { + "type": "text" + }, + "user": { + "type": "nested", + "properties": { + "userID": { + "type": "keyword" + }, + "name": { + "type": "keyword", + "normalizer": "lowercase_normalizer" + }, + "username": { + "type": "keyword", + "normalizer": "lowercase_normalizer" + }, + "profileImageURL": { + "type": "keyword" + } + } } } } } } -} +} \ No newline at end of file diff --git a/packages/web/lib/networking/mutations/joinGroupMutation.ts b/packages/web/lib/networking/mutations/joinGroupMutation.ts new file mode 100644 index 000000000..86c7b9000 --- /dev/null +++ b/packages/web/lib/networking/mutations/joinGroupMutation.ts @@ -0,0 +1,51 @@ +import { gql } from 'graphql-request' +import { gqlFetcher } from '../networkHelpers' + +export interface RecommendationGroup { + id: string + name: string + createdAt: Date + updatedAt: Date +} + +type JoinGroupResponse = { + joinGroup?: JoinGroupData + errorCodes?: string[] +} + +type JoinGroupData = { + group: RecommendationGroup | undefined +} + +export async function joinGroupMutation( + inviteCode: string +): Promise { + const mutation = gql` + mutation JoinGroup($inviteCode: String!) { + joinGroup(inviteCode: $inviteCode) { + ... on JoinGroupSuccess { + group { + id + name + createdAt + updatedAt + } + } + ... on JoinGroupError { + errorCodes + } + } + } + ` + + console.log('JoinGroupMutation', mutation) + + const response = await gqlFetcher(mutation, { inviteCode }) + console.log(' -- response', response) + const data = response as JoinGroupResponse | undefined + const error = data?.errorCodes?.find(() => true) + if (error) { + throw error + } + return data?.joinGroup?.group +} diff --git a/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx b/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx index 001842abe..be7893b41 100644 --- a/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx +++ b/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx @@ -80,8 +80,24 @@ export type LibraryItemNode = { state: State pageType: PageType siteName?: string - subscription?: string, + subscription?: string readAt?: string + recommendations?: Recommendation[] +} + +export type Recommendation = { + id: string + name: string + note?: string + user?: RecommendingUser + recommendedAt: Date +} + +export type RecommendingUser = { + userId: string + name: string + username: string + profileImageURL?: string } export type PageInfo = { @@ -135,6 +151,18 @@ export function useGetLibraryItemsQuery({ siteName subscription readAt + recommendations { + id + name + note + user { + userId + name + username + profileImageURL + } + recommendedAt + } } } pageInfo { @@ -198,7 +226,7 @@ export function useGetLibraryItemsQuery({ } const getIndexOf = (page: LibraryItems, item: LibraryItem) => { - return page.edges.findIndex(i => i.node.id === item.node.id) + return page.edges.findIndex((i) => i.node.id === item.node.id) } const performActionOnItem = async ( @@ -329,7 +357,9 @@ export function useGetLibraryItemsQuery({ }) unsubscribeMutation(item.node.subscription).then((res) => { if (res) { - showSuccessToast('Unsubscribed successfully', { position: 'bottom-right' }) + showSuccessToast('Unsubscribed successfully', { + position: 'bottom-right', + }) } else { showErrorToast('Error unsubscribing', { position: 'bottom-right', @@ -337,8 +367,8 @@ export function useGetLibraryItemsQuery({ } }) } - case 'update-item': - updateData(item) + case 'update-item': + updateData(item) break case 'refresh': await mutate() @@ -353,6 +383,6 @@ export function useGetLibraryItemsQuery({ performActionOnItem, size, setSize, - mutate + mutate, } } diff --git a/packages/web/pages/home.tsx b/packages/web/pages/home.tsx index 1e1201f72..f222eb42f 100644 --- a/packages/web/pages/home.tsx +++ b/packages/web/pages/home.tsx @@ -1,7 +1,6 @@ import { PrimaryLayout } from '../components/templates/PrimaryLayout' import { HomeFeedContainer } from '../components/templates/homeFeed/HomeFeedContainer' import { VStack } from './../components/elements/LayoutPrimitives' -import { useRef } from 'react' export default function Home(): JSX.Element { return diff --git a/packages/web/pages/invite/[inviteCode].tsx b/packages/web/pages/invite/[inviteCode].tsx new file mode 100644 index 000000000..6e68a1684 --- /dev/null +++ b/packages/web/pages/invite/[inviteCode].tsx @@ -0,0 +1,45 @@ +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { PageMetaData } from '../../components/patterns/PageMetaData' +import { ProfileLayout } from '../../components/templates/ProfileLayout' +import { joinGroupMutation } from '../../lib/networking/mutations/joinGroupMutation' +import { showSuccessToast } from '../../lib/toastHelpers' + +export default function InvitePage(): JSX.Element { + const router = useRouter() + const { inviteCode } = router.query + + const [error, setError] = useState(undefined) + + useEffect(() => { + if (!router.isReady) { + return + } + if (!inviteCode || typeof inviteCode != 'string') { + setError('No invite code provided') + } + + const joinGroup = async () => { + try { + const result = await joinGroupMutation(inviteCode as string) + if (!result) { + throw 'Unknown error occurred.' + } + showSuccessToast(`You have been added to the ${result.name} group`) + } catch (error) { + setError('Unable to join group') + } + } + + joinGroup().catch((error) => { + setError(error) + }) + }, [router, inviteCode]) + return ( + <> + + Accepting invite to join +
+ + ) +}