SubscriptionAddView.swift 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import SwiftUI
  2. struct SubscriptionAddView: View {
  3. private let tag = "SubscriptionAddView"
  4. @Binding var isShowing: Bool
  5. @EnvironmentObject private var store: Store
  6. @State private var topic: String = ""
  7. @State private var useAnother: Bool = false
  8. @State private var baseUrl: String = ""
  9. @State private var showLogin: Bool = false
  10. @State private var username: String = ""
  11. @State private var password: String = ""
  12. @State private var loading = false
  13. @State private var addError: String?
  14. @State private var loginError: String?
  15. private var subscriptionManager: SubscriptionManager {
  16. return SubscriptionManager(store: store)
  17. }
  18. var body: some View {
  19. NavigationView {
  20. // This is a little weird, but it works. The nagivation link for the login view
  21. // is rendered in the backgroun (it's hidden), abd we toggle it manually.
  22. // If anyone has a better way to do a two-page layout let me know.
  23. addView
  24. .background(Group {
  25. NavigationLink(
  26. destination: loginView,
  27. isActive: $showLogin
  28. ) {
  29. EmptyView()
  30. }
  31. })
  32. }
  33. }
  34. private var addView: some View {
  35. VStack(alignment: .leading, spacing: 0) {
  36. Form {
  37. Section(
  38. footer: Text("Topics may not be password-protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications")
  39. ) {
  40. TextField("Topic name, e.g. phil_alerts", text: $topic)
  41. .disableAutocapitalization()
  42. .disableAutocorrection(true)
  43. }
  44. Section(
  45. footer:
  46. (useAnother) ? Text("To ensure instant delivery from your self-hosted server, be sure to set upstream-base-url in your server's config, otherwise messages may arrive with significant delay.") : Text("")
  47. ) {
  48. Toggle("Use another server", isOn: $useAnother)
  49. if useAnother {
  50. TextField("Service URL, e.g. https://ntfy.home.io", text: $baseUrl)
  51. .disableAutocapitalization()
  52. .disableAutocorrection(true)
  53. }
  54. }
  55. }
  56. if let error = addError {
  57. ErrorView(error: error)
  58. }
  59. }
  60. .navigationTitle("Add subscription")
  61. .navigationBarTitleDisplayMode(.inline)
  62. .toolbar {
  63. ToolbarItem(placement: .navigationBarLeading) {
  64. Button(action: cancelAction) {
  65. Text("Cancel")
  66. }
  67. }
  68. ToolbarItem(placement: .navigationBarTrailing) {
  69. Button(action: subscribeOrShowLoginAction) {
  70. VStack {
  71. if loading {
  72. ProgressView()
  73. .progressViewStyle(CircularProgressViewStyle())
  74. } else {
  75. Text("Subscribe")
  76. }
  77. }
  78. .fixedSize(horizontal: true, vertical: false)
  79. }
  80. .disabled(!isAddViewValid())
  81. }
  82. }
  83. }
  84. private var loginView: some View {
  85. VStack(alignment: .leading, spacing: 0) {
  86. Form {
  87. Section(
  88. footer: Text("This topic requires that you log in with username and password. The user will be stored on your device, and will be re-used for other topics.")
  89. ) {
  90. TextField("Username", text: $username)
  91. .disableAutocapitalization()
  92. .disableAutocorrection(true)
  93. SecureField("Password", text: $password)
  94. }
  95. }
  96. if let error = loginError {
  97. ErrorView(error: error)
  98. }
  99. }
  100. .navigationTitle("Login required")
  101. .navigationBarTitleDisplayMode(.inline)
  102. .toolbar {
  103. ToolbarItem(placement: .navigationBarTrailing) {
  104. Button(action: subscribeWithUserAction) {
  105. if loading {
  106. ProgressView()
  107. .progressViewStyle(CircularProgressViewStyle())
  108. } else {
  109. Text("Subscribe")
  110. }
  111. }
  112. .disabled(!isLoginViewValid())
  113. }
  114. }
  115. }
  116. private var sanitizedTopic: String {
  117. return topic.trimmingCharacters(in: .whitespaces)
  118. }
  119. private func isAddViewValid() -> Bool {
  120. if sanitizedTopic.isEmpty {
  121. return false
  122. } else if sanitizedTopic.range(of: "^[-_A-Za-z0-9]{1,64}$", options: .regularExpression, range: nil, locale: nil) == nil {
  123. return false
  124. } else if selectedBaseUrl.range(of: "^https?://.+", options: .regularExpression, range: nil, locale: nil) == nil {
  125. return false
  126. } else if store.getSubscription(baseUrl: selectedBaseUrl, topic: topic) != nil {
  127. return false
  128. }
  129. return true
  130. }
  131. private func isLoginViewValid() -> Bool {
  132. if username.isEmpty || password.isEmpty {
  133. return false
  134. }
  135. return true
  136. }
  137. private func subscribeOrShowLoginAction() {
  138. loading = true
  139. addError = nil
  140. let user = store.getUser(baseUrl: selectedBaseUrl)?.toBasicUser()
  141. ApiService.shared.checkAuth(baseUrl: selectedBaseUrl, topic: topic, user: user) { result in
  142. switch result {
  143. case .Success:
  144. DispatchQueue.global(qos: .background).async {
  145. subscriptionManager.subscribe(baseUrl: selectedBaseUrl, topic: sanitizedTopic)
  146. resetAndHide()
  147. }
  148. // Do not reset "loading", because resetAndHide() will do that after everything is done
  149. case .Unauthorized:
  150. if let user = user {
  151. addError = "User \(user.username) is not authorized to read this topic"
  152. } else {
  153. addError = nil // Reset
  154. showLogin = true
  155. }
  156. loading = false
  157. case .Error(let err):
  158. addError = err
  159. loading = false
  160. }
  161. }
  162. }
  163. private func subscribeWithUserAction() {
  164. loading = true
  165. loginError = nil
  166. let user = BasicUser(username: username, password: password)
  167. ApiService.shared.checkAuth(baseUrl: selectedBaseUrl, topic: topic, user: user) { result in
  168. switch result {
  169. case .Success:
  170. DispatchQueue.global(qos: .background).async {
  171. store.saveUser(baseUrl: selectedBaseUrl, username: username, password: password)
  172. subscriptionManager.subscribe(baseUrl: selectedBaseUrl, topic: sanitizedTopic)
  173. resetAndHide()
  174. }
  175. // Do not reset "loading", because resetAndHide() will do that after everything is done
  176. case .Unauthorized:
  177. loginError = "Invalid credentials, or user \(username) is not authorized to read this topic"
  178. loading = false
  179. case .Error(let err):
  180. loginError = err
  181. loading = false
  182. }
  183. }
  184. }
  185. private func cancelAction() {
  186. resetAndHide()
  187. }
  188. private var selectedBaseUrl: String {
  189. return (useAnother) ? baseUrl : store.getDefaultBaseUrl()
  190. }
  191. private func resetAndHide() {
  192. isShowing = false
  193. DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
  194. // Hide first and then reset, otherwise we'll see the text fields change
  195. addError = nil
  196. loginError = nil
  197. loading = false
  198. baseUrl = ""
  199. topic = ""
  200. useAnother = false
  201. }
  202. }
  203. }
  204. struct ErrorView: View {
  205. var error: String
  206. var body: some View {
  207. HStack {
  208. Image(systemName: "exclamationmark.triangle.fill")
  209. .foregroundColor(.red)
  210. .font(.title2)
  211. Text(error)
  212. .font(.subheadline)
  213. }
  214. .padding([.leading, .trailing], 20)
  215. .padding([.top, .bottom], 10)
  216. }
  217. }
  218. struct SubscriptionAddView_Previews: PreviewProvider {
  219. @State static var isShowing = true
  220. static var previews: some View {
  221. let store = Store.preview
  222. SubscriptionAddView(isShowing: $isShowing)
  223. .environmentObject(store)
  224. }
  225. }