diff --git a/apple/OmnivoreKit/Sources/App/PrimaryContentCategory.swift b/apple/OmnivoreKit/Sources/App/PrimaryContentCategory.swift index 60ddf6863..699c63cb9 100644 --- a/apple/OmnivoreKit/Sources/App/PrimaryContentCategory.swift +++ b/apple/OmnivoreKit/Sources/App/PrimaryContentCategory.swift @@ -40,7 +40,7 @@ enum PrimaryContentCategory: Identifiable, Hashable, Equatable { case .feed: HomeFeedView() case .profile: - ProfileContainerView() + ProfileView() } } diff --git a/apple/OmnivoreKit/Sources/App/SnackbarExtension.swift b/apple/OmnivoreKit/Sources/App/SnackbarExtension.swift new file mode 100644 index 000000000..fab837616 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/SnackbarExtension.swift @@ -0,0 +1,9 @@ +import Foundation +import Services +import Views + +extension Snackbar { + static func show(message: String) { + NSNotification.operationSuccess(message: message) + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/HomeFeedView.swift b/apple/OmnivoreKit/Sources/App/Views/HomeFeedView.swift index f87f7eadc..13cb2ca1d 100644 --- a/apple/OmnivoreKit/Sources/App/Views/HomeFeedView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/HomeFeedView.swift @@ -113,7 +113,7 @@ final class HomeFeedViewModel: ObservableObject { receiveValue: { [weak self] _ in self?.isLoading = false stopNetworkActivityIndicator() - NSNotification.operationSuccess(message: archived ? "Link archived" : "Link moved to Inbox") + Snackbar.show(message: archived ? "Link archived" : "Link moved to Inbox") } ) .store(in: &subscriptions) @@ -130,16 +130,15 @@ final class HomeFeedViewModel: ObservableObject { dataService.removeLinkPublisher(itemID: linkId) .sink( receiveCompletion: { [weak self] completion in - guard case let .failure(error) = completion else { return } + guard case .failure = completion else { return } self?.isLoading = false stopNetworkActivityIndicator() - print(error) - NSNotification.operationFailed(message: "Failed to remove link") + Snackbar.show(message: "Failed to remove link") }, receiveValue: { [weak self] _ in self?.isLoading = false stopNetworkActivityIndicator() - NSNotification.operationSuccess(message: "Link removed") + Snackbar.show(message: "Link removed") } ) .store(in: &subscriptions) @@ -169,7 +168,7 @@ final class HomeFeedViewModel: ObservableObject { self?.isLoading = false stopNetworkActivityIndicator() if let message = successMessage { - NSNotification.operationSuccess(message: message) + Snackbar.show(message: message) } } ) @@ -391,7 +390,7 @@ struct HomeFeedView: View { .toolbar { ToolbarItem { NavigationLink( - destination: { ProfileContainerView() }, + destination: { ProfileView() }, label: { Image.profile .resizable() diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/NewsletterEmailsView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/NewsletterEmailsView.swift new file mode 100644 index 000000000..0e7063d36 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/NewsletterEmailsView.swift @@ -0,0 +1,108 @@ +import Combine +import Models +import Services +import SwiftUI +import Views + +final class NewsletterEmailsViewModel: ObservableObject { + private var hasLoadedInitialEmails = false + @Published var isLoading = false + @Published var emails = [NewsletterEmail]() + + var subscriptions = Set() + + func loadEmails(dataService: DataService) { + isLoading = true + + dataService.newsletterEmailsPublisher().sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] result in + self?.isLoading = false + self?.emails = result + self?.hasLoadedInitialEmails = true + } + ) + .store(in: &subscriptions) + } + + func createEmail(dataService: DataService) { + isLoading = true + + dataService.createNewsletterEmailPublisher().sink( + receiveCompletion: { [weak self] _ in + self?.isLoading = false + }, + receiveValue: { [weak self] result in + self?.isLoading = false + self?.emails.insert(result, at: 0) + } + ) + .store(in: &subscriptions) + } +} + +struct NewsletterEmailsView: View { + @EnvironmentObject var dataService: DataService + @ObservedObject var viewModel = NewsletterEmailsViewModel() + let footerText = "Add PDFs to your library, or subscribe to emails using an Omnivore email address." + + var body: some View { + Group { + #if os(iOS) + Form { + innerBody + } + #elseif os(macOS) + List { + innerBody + } + .listStyle(InsetListStyle()) + #endif + } + .onAppear { viewModel.loadEmails(dataService: dataService) } + } + + private var innerBody: some View { + Group { + Section(footer: Text(footerText)) { + Button( + action: { + viewModel.createEmail(dataService: dataService) + }, + label: { + HStack { + Image(systemName: "plus.circle.fill").foregroundColor(.green) + Text("Create a new email address") + Spacer() + } + } + ) + .disabled(viewModel.isLoading) + } + + if !viewModel.emails.isEmpty { + Section(header: Text("Existing Emails (Tap to copy)")) { + ForEach(viewModel.emails) { newsletterEmail in + Button( + action: { + #if os(iOS) + UIPasteboard.general.string = newsletterEmail.email + #endif + + #if os(macOS) + let pasteBoard = NSPasteboard.general + pasteBoard.clearContents() + pasteBoard.writeObjects([newsletterEmail.email as NSString]) + #endif + + Snackbar.show(message: "Email copied") + }, + label: { Text(newsletterEmail.email) } + ) + } + } + } + } + .navigationTitle("Emails") + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/ProfileContainerView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift similarity index 85% rename from apple/OmnivoreKit/Sources/App/Views/ProfileContainerView.swift rename to apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index 5832b0b63..332e732b4 100644 --- a/apple/OmnivoreKit/Sources/App/Views/ProfileContainerView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -26,11 +26,12 @@ final class ProfileContainerViewModel: ObservableObject { } } -struct ProfileContainerView: View { +struct ProfileView: View { @EnvironmentObject var authenticator: Authenticator @EnvironmentObject var dataService: DataService @ObservedObject private var viewModel = ProfileContainerViewModel() + @State private var showLogoutConfirmation = false var body: some View { @@ -50,9 +51,13 @@ struct ProfileContainerView: View { Group { Section { ProfileCard(data: viewModel.profileCardData) - .onAppear { - viewModel.loadProfileData(dataService: dataService) - } + .onAppear { viewModel.loadProfileData(dataService: dataService) } + } + + Section { + NavigationLink(destination: NewsletterEmailsView()) { + Text("Emails") + } } Section { @@ -70,9 +75,7 @@ struct ProfileContainerView: View { #if os(iOS) Button( - action: { - DataService.showIntercomMessenger?() - }, + action: { DataService.showIntercomMessenger?() }, label: { Text("Feedback") } ) #endif @@ -118,12 +121,12 @@ private extension BasicWebAppView { } private static func omnivoreWebView(path: String, baseURL: URL) -> BasicWebAppView { - let urlRequest = URLRequest.webRequest( - baseURL: baseURL, - urlPath: path, - queryParams: nil - ) + let url: URL = { + var urlComponents = URLComponents() + urlComponents.path = path + return urlComponents.url(relativeTo: baseURL)! + }() - return BasicWebAppView(request: urlRequest) + return BasicWebAppView(request: URLRequest(url: url)) } } diff --git a/apple/OmnivoreKit/Sources/Models/NewsletterEmail.swift b/apple/OmnivoreKit/Sources/Models/NewsletterEmail.swift new file mode 100644 index 000000000..ee4270668 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/NewsletterEmail.swift @@ -0,0 +1,14 @@ +import Foundation + +public struct NewsletterEmail: Identifiable { + public let id = UUID() + public let emailId: String + public let email: String + public let confirmationCode: String? + + public init(emailId: String, email: String, confirmationCode: String?) { + self.emailId = emailId + self.email = email + self.confirmationCode = confirmationCode + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index c94d2dddf..89528dbc1 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -2957,6 +2957,8 @@ extension Objects { let id: [String: String] let image: [String: String] let isArchived: [String: Bool] + let labels: [String: [Objects.Label]] + let linkId: [String: String] let originalArticleUrl: [String: String] let originalHtml: [String: String] let pageType: [String: Enums.PageType] @@ -3034,6 +3036,14 @@ extension Objects.Article: Decodable { if let value = try container.decode(Bool?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "labels": + if let value = try container.decode([Objects.Label]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "linkId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "originalArticleUrl": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -3111,6 +3121,8 @@ extension Objects.Article: Decodable { id = map["id"] image = map["image"] isArchived = map["isArchived"] + labels = map["labels"] + linkId = map["linkId"] originalArticleUrl = map["originalArticleUrl"] originalHtml = map["originalHtml"] pageType = map["pageType"] @@ -3544,6 +3556,37 @@ extension Fields where TypeLock == Objects.Article { return Bool.mockValue } } + + func linkId() throws -> String? { + let field = GraphQLField.leaf( + name: "linkId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.linkId[field.alias!] + case .mocking: + return nil + } + } + + func labels(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "labels", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.labels[field.alias!]) + case .mocking: + return selection.mock() + } + } } extension Selection where TypeLock == Never, Type == Never { @@ -10609,6 +10652,9 @@ extension Selection where TypeLock == Never, Type == Never { extension Objects { struct Label { let __typename: TypeName = .label + let color: [String: String] + let createdAt: [String: DateTime] + let description: [String: String] let id: [String: String] let name: [String: String] @@ -10630,6 +10676,18 @@ extension Objects.Label: Decodable { let field = GraphQLField.getFieldNameFromAlias(alias) switch field { + case "color": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "createdAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "description": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "id": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -10648,6 +10706,9 @@ extension Objects.Label: Decodable { } } + color = map["color"] + createdAt = map["createdAt"] + description = map["description"] id = map["id"] name = map["name"] } @@ -10689,6 +10750,57 @@ extension Fields where TypeLock == Objects.Label { return String.mockValue } } + + func color() throws -> String { + let field = GraphQLField.leaf( + name: "color", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.color[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.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 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 + } + } } extension Selection where TypeLock == Never, Type == Never { @@ -11219,6 +11331,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias SignupError = Selection } +extension Objects { + struct SetLabelsSuccess { + let __typename: TypeName = .setLabelsSuccess + let labels: [String: [Objects.Label]] + + enum TypeName: String, Codable { + case setLabelsSuccess = "SetLabelsSuccess" + } + } +} + +extension Objects.SetLabelsSuccess: 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 "labels": + if let value = try container.decode([Objects.Label]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + labels = map["labels"] + } +} + +extension Fields where TypeLock == Objects.SetLabelsSuccess { + func labels(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "labels", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.labels[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SetLabelsSuccess = Selection +} + +extension Objects { + struct SetLabelsError { + let __typename: TypeName = .setLabelsError + let errorCodes: [String: [Enums.SetLabelsErrorCode]] + + enum TypeName: String, Codable { + case setLabelsError = "SetLabelsError" + } + } +} + +extension Objects.SetLabelsError: 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.SetLabelsErrorCode]?.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.SetLabelsError { + func errorCodes() throws -> [Enums.SetLabelsErrorCode] { + 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 SetLabelsError = Selection +} + extension Objects { struct Mutation { let __typename: TypeName = .mutation @@ -11249,6 +11492,7 @@ extension Objects { let setBookmarkArticle: [String: Unions.SetBookmarkArticleResult] let setDeviceToken: [String: Unions.SetDeviceTokenResult] let setFollow: [String: Unions.SetFollowResult] + let setLabels: [String: Unions.SetLabelsResult] let setLinkArchived: [String: Unions.ArchiveLinkResult] let setShareArticle: [String: Unions.SetShareArticleResult] let setShareHighlight: [String: Unions.SetShareHighlightResult] @@ -11389,6 +11633,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.SetFollowResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "setLabels": + if let value = try container.decode(Unions.SetLabelsResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "setLinkArchived": if let value = try container.decode(Unions.ArchiveLinkResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11478,6 +11726,7 @@ extension Objects.Mutation: Decodable { setBookmarkArticle = map["setBookmarkArticle"] setDeviceToken = map["setDeviceToken"] setFollow = map["setFollow"] + setLabels = map["setLabels"] setLinkArchived = map["setLinkArchived"] setShareArticle = map["setShareArticle"] setShareHighlight = map["setShareHighlight"] @@ -12254,6 +12503,25 @@ extension Fields where TypeLock == Objects.Mutation { return selection.mock() } } + + func setLabels(input: InputObjects.SetLabelsInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "setLabels", + arguments: [Argument(name: "input", type: "SetLabelsInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.setLabels[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } } extension Selection where TypeLock == Never, Type == Never { @@ -12669,10 +12937,10 @@ extension Fields where TypeLock == Objects.Query { } } - func labels(linkId: String, selection: Selection) throws -> Type { + func labels(selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "labels", - arguments: [Argument(name: "linkId", type: "ID!", value: linkId)], + arguments: [], selection: selection.selection ) select(field) @@ -16404,6 +16672,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias SignupResult = Selection } +extension Unions { + struct SetLabelsResult { + let __typename: TypeName + let errorCodes: [String: [Enums.SetLabelsErrorCode]] + let labels: [String: [Objects.Label]] + + enum TypeName: String, Codable { + case setLabelsSuccess = "SetLabelsSuccess" + case setLabelsError = "SetLabelsError" + } + } +} + +extension Unions.SetLabelsResult: 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.SetLabelsErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "labels": + if let value = try container.decode([Objects.Label]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + 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"] + labels = map["labels"] + } +} + +extension Fields where TypeLock == Unions.SetLabelsResult { + func on(setLabelsSuccess: Selection, setLabelsError: Selection) throws -> Type { + select([GraphQLField.fragment(type: "SetLabelsSuccess", selection: setLabelsSuccess.selection), GraphQLField.fragment(type: "SetLabelsError", selection: setLabelsError.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .setLabelsSuccess: + let data = Objects.SetLabelsSuccess(labels: data.labels) + return try setLabelsSuccess.decode(data: data) + case .setLabelsError: + let data = Objects.SetLabelsError(errorCodes: data.errorCodes) + return try setLabelsError.decode(data: data) + } + case .mocking: + return setLabelsSuccess.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SetLabelsResult = Selection +} + // MARK: - Enums enum Enums {} @@ -16997,6 +17339,8 @@ extension Enums { case badRequest = "BAD_REQUEST" case notFound = "NOT_FOUND" + + case labelAlreadyExists = "LABEL_ALREADY_EXISTS" } } @@ -17011,6 +17355,17 @@ extension Enums { } } +extension Enums { + /// SetLabelsErrorCode + enum SetLabelsErrorCode: String, CaseIterable, Codable { + case unauthorized = "UNAUTHORIZED" + + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + } +} + // MARK: - Input Objects enum InputObjects {} @@ -17851,19 +18206,23 @@ extension InputObjects { extension InputObjects { struct CreateLabelInput: Encodable, Hashable { - var linkId: String - var name: String + var color: String + + var description: OptionalArgument = .absent() + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(linkId, forKey: .linkId) try container.encode(name, forKey: .name) + try container.encode(color, forKey: .color) + if description.hasValue { try container.encode(description, forKey: .description) } } enum CodingKeys: String, CodingKey { - case linkId case name + case color + case description } } } @@ -17921,3 +18280,22 @@ extension InputObjects { } } } + +extension InputObjects { + struct SetLabelsInput: Encodable, Hashable { + var linkId: String + + var labelIds: [String] + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(linkId, forKey: .linkId) + try container.encode(labelIds, forKey: .labelIds) + } + + enum CodingKeys: String, CodingKey { + case linkId + case labelIds + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateNewsletterEmailMutation.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateNewsletterEmailMutation.swift new file mode 100644 index 000000000..b8028e47e --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateNewsletterEmailMutation.swift @@ -0,0 +1,60 @@ +import Combine +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func createNewsletterEmailPublisher() -> AnyPublisher { + enum MutationResult { + case saved(newsletterEmail: NewsletterEmail) + case error(errorCode: Enums.CreateNewsletterEmailErrorCode) + } + + let selection = Selection { + try $0.on( + createNewsletterEmailSuccess: .init { + .saved(newsletterEmail: try $0.newsletterEmail(selection: Selection.NewsletterEmail { + NewsletterEmail( + emailId: try $0.id(), + email: try $0.address(), + confirmationCode: try $0.confirmationCode() + ) + })) + }, createNewsletterEmailError: .init { + .error(errorCode: try $0.errorCodes().first ?? .badRequest) + } + ) + } + + let mutation = Selection.Mutation { + try $0.createNewsletterEmail(selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return Deferred { + Future { promise in + send(mutation, to: path, headers: headers) { result in + switch result { + case let .success(payload): + if let graphqlError = payload.errors { + promise(.failure(.message(messageText: "graphql error: \(graphqlError)"))) + } + + switch payload.data { + case let .saved(newsletterEmail: newsletterEmail): + promise(.success(newsletterEmail)) + case let .error(errorCode: errorCode): + promise(.failure(.message(messageText: errorCode.rawValue))) + } + case .failure: + promise(.failure(.message(messageText: "graphql error"))) + } + } + } + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/NewsletterEmailsQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/NewsletterEmailsQuery.swift new file mode 100644 index 000000000..25ccea69f --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/NewsletterEmailsQuery.swift @@ -0,0 +1,59 @@ +import Combine +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func newsletterEmailsPublisher() -> AnyPublisher<[NewsletterEmail], ServerError> { + enum QueryResult { + case success(result: [NewsletterEmail]) + case error(error: String) + } + + let newsletterEmailSelection = Selection.NewsletterEmail { + NewsletterEmail( + emailId: try $0.id(), + email: try $0.address(), + confirmationCode: try $0.confirmationCode() + ) + } + + let selection = Selection { + try $0.on( + newsletterEmailsSuccess: .init { + QueryResult.success(result: try $0.newsletterEmails(selection: newsletterEmailSelection.list)) + }, + newsletterEmailsError: .init { + QueryResult.error(error: try $0.errorCodes().description) + } + ) + } + + let query = Selection.Query { + try $0.newsletterEmails(selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return Deferred { + Future { promise in + send(query, to: path, headers: headers) { result in + switch result { + case let .success(payload): + switch payload.data { + case let .success(result: result): + promise(.success(result)) + case .error: + promise(.failure(.unknown)) + } + case .failure: + promise(.failure(.unknown)) + } + } + } + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/apple/OmnivoreKit/Sources/Views/SnackBar.swift b/apple/OmnivoreKit/Sources/Views/SnackBar.swift index aed304e18..89b8b86f5 100644 --- a/apple/OmnivoreKit/Sources/Views/SnackBar.swift +++ b/apple/OmnivoreKit/Sources/Views/SnackBar.swift @@ -1,6 +1,6 @@ import SwiftUI -struct Snackbar: View { +public struct Snackbar: View { @Binding var isShowing: Bool private let presenting: AnyView private let text: Text @@ -27,7 +27,7 @@ struct Snackbar: View { self.action = action } - var body: some View { + public var body: some View { GeometryReader { geometry in ZStack(alignment: .center) { self.presenting diff --git a/packages/api/.env.example b/packages/api/.env.example index fc982d2ea..2590b876d 100644 --- a/packages/api/.env.example +++ b/packages/api/.env.example @@ -23,3 +23,4 @@ GCS_UPLOAD_BUCKET= GCS_UPLOAD_SA_KEY_FILE_PATH= TWITTER_BEARER_TOKEN= PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper' +REMINDER_TASK_HANDLER_URL= \ No newline at end of file