Merge pull request #138 from omnivore-app/feature/emails-ui

Newsletter Email for mac/iOS
This commit is contained in:
Satindar Dhillon 2022-02-25 19:52:17 -08:00 committed by GitHub
commit de07e9ad92
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 660 additions and 29 deletions

View file

@ -40,7 +40,7 @@ enum PrimaryContentCategory: Identifiable, Hashable, Equatable {
case .feed:
HomeFeedView()
case .profile:
ProfileContainerView()
ProfileView()
}
}

View file

@ -0,0 +1,9 @@
import Foundation
import Services
import Views
extension Snackbar {
static func show(message: String) {
NSNotification.operationSuccess(message: message)
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Type>(selection: Selection<Type, [Objects.Label]?>) 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<T> = Selection<T, Objects.SignupError>
}
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<Type>(selection: Selection<Type, [Objects.Label]>) 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<T> = Selection<T, Objects.SetLabelsSuccess>
}
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<T> = Selection<T, Objects.SetLabelsError>
}
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<Type>(input: InputObjects.SetLabelsInput, selection: Selection<Type, Unions.SetLabelsResult>) 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<Type>(linkId: String, selection: Selection<Type, Unions.LabelsResult>) throws -> Type {
func labels<Type>(selection: Selection<Type, Unions.LabelsResult>) 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<T> = Selection<T, Unions.SignupResult>
}
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<Type>(setLabelsSuccess: Selection<Type, Objects.SetLabelsSuccess>, setLabelsError: Selection<Type, Objects.SetLabelsError>) 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<T> = Selection<T, Unions.SetLabelsResult>
}
// 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<String> = .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
}
}
}

View file

@ -0,0 +1,60 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func createNewsletterEmailPublisher() -> AnyPublisher<NewsletterEmail, BasicError> {
enum MutationResult {
case saved(newsletterEmail: NewsletterEmail)
case error(errorCode: Enums.CreateNewsletterEmailErrorCode)
}
let selection = Selection<MutationResult, Unions.CreateNewsletterEmailResult> {
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()
}
}

View file

@ -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<QueryResult, Unions.NewsletterEmailsResult> {
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()
}
}

View file

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

View file

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