Range-diff !169

back to !169 Reply by mail on and off

1:  5dad039 ! 1:  ac96309 Reply by mail on and off
    @@ Commit message
         the account page shows it (reply_available, krz/gitbay#334); a server
         that does not say offers it, and its refusal explains.
     
    -    A settings switch now shows the server's value once a write returns, so
    -    one the server refused goes back rather than showing what it refused.
    -    It used to keep the refused value, on every settings screen.
    +    The account's switches go back when the server refuses a change rather
    +    than showing what it refused: SettingToggle takes a write that says
    +    whether it was accepted. Settings screens whose writes do not say keep
    +    the reload's word, as before.
     
         Closes #33
     
    @@ gitbay/Account/AccountViewModel.swift: final class AccountViewModel {
     +
     +    /// `notifications settings reply on|off`: issue and merge request mail
     +    /// carries a Reply-To, and a reply is posted as a comment.
    -+    func setReply(_ on: Bool) async {
    -+        await setPreference("reply", on)
    ++    @discardableResult
    ++    func setReply(_ on: Bool) async -> Bool {
    ++        return await setPreference("reply", on)
          }
      
          /// `notifications settings mail on|off`, then read the value back.
    +-    func setMail(_ on: Bool) async {
    +-        await setPreference("mail", on)
    ++    @discardableResult
    ++    func setMail(_ on: Bool) async -> Bool {
    ++        return await setPreference("mail", on)
    +     }
    + 
    +     /// `notifications settings watch on|off`, then read the value back.
    +-    func setWatch(_ on: Bool) async {
    +-        await setPreference("watch", on)
    ++    @discardableResult
    ++    func setWatch(_ on: Bool) async -> Bool {
    ++        return await setPreference("watch", on)
    +     }
    + 
    +-    private func setPreference(_ name: String, _ on: Bool) async {
    ++    /// Whether the server took the change, so the switch can go back when
    ++    /// it did not.
    ++    private func setPreference(_ name: String, _ on: Bool) async -> Bool {
    +         working = true
    +         actionError = nil
    +         defer { working = false }
    +         do {
    +             try await client.run(["notifications", "settings", name, on ? "on" : "off"])
    +             await loadNotificationPreferences()
    ++            return true
    +         } catch let error as GitbayError {
    +             actionError = error.userFacingMessage
    +         } catch {
    +             actionError = GitbayError.transport(error).userFacingMessage
    +         }
    ++        return false
    +     }
    + 
    +     // MARK: - SSH keys
     
      ## gitbay/Views/Account/AccountView.swift ##
     @@ gitbay/Views/Account/AccountView.swift: struct AccountView: View {
    -                     }
    -                     .disabled(model.working)
    -                 }
    -+                if model.offersReply, let replyOn = model.replyOn {
    -+                    SettingToggle(title: "Reply by mail", serverValue: replyOn) {
    -+                        await model.setReply($0)
    -+                    }
    +     private var notificationsSection: some View {
    +         if let mailOn = model.mailOn {
    +             Section {
    +-                SettingToggle(title: "Activity by mail", serverValue: mailOn) {
    ++                SettingToggle(title: "Activity by mail", serverValue: mailOn, accepted: {
    +                     await model.setMail($0)
    +-                }
    ++                })
    +                 .disabled(model.working)
    +                 if let watchOn = model.watchOn {
    +-                    SettingToggle(title: "Watch writable repositories", serverValue: watchOn) {
    ++                    SettingToggle(title: "Watch writable repositories", serverValue: watchOn, accepted: {
    +                         await model.setWatch($0)
    +-                    }
    ++                    })
     +                    .disabled(model.working)
     +                }
    ++                if model.offersReply, let replyOn = model.replyOn {
    ++                    SettingToggle(title: "Reply by mail", serverValue: replyOn, accepted: {
    ++                        await model.setReply($0)
    ++                    })
    +                     .disabled(model.working)
    +                 }
                  } header: {
                      Text("Notifications")
                  } footer: {
    @@ gitbay/Views/Account/AccountView.swift: struct AccountView: View {
          }
     
      ## gitbay/Views/Repos/RepoSettingsView.swift ##
    -@@ gitbay/Views/Repos/RepoSettingsView.swift: struct SettingToggle: View {
    +@@ gitbay/Views/Repos/RepoSettingsView.swift: private struct ApprovalsStepper: View {
    + /// A Toggle backed by real local state that mirrors the server value.
    + /// A computed Binding whose setter spawns a Task proved unreliable under
    + /// synthesized taps; a plain @State toggle is not.
    ++///
    ++/// A write that says whether the server took it lets the switch go back
    ++/// when it did not, rather than showing what was refused. A write that
    ++/// says nothing is taken as accepted, and the reload decides.
    + struct SettingToggle: View {
    + 
    +     let title: String
          let serverValue: Bool
    -     let write: (Bool) async -> Void
    +-    let write: (Bool) async -> Void
    ++    let write: (Bool) async -> Bool
      
    --    @State private var isOn = false
    --    @State private var seeded = false
    -+    /// The value being written, shown until the write returns. Then the
    -+    /// switch shows the server's value again: the new one when the write
    -+    /// was accepted and the screen reloaded, the old one when it was
    -+    /// refused. Computed as it is drawn, so it never races the reload.
    -+    @State private var pending: Bool?
    +     @State private var isOn = false
    +     @State private var seeded = false
      
    ++    init(title: String, serverValue: Bool, write: @escaping (Bool) async -> Void) {
    ++        self.title = title
    ++        self.serverValue = serverValue
    ++        self.write = { await write($0); return true }
    ++    }
    ++
    ++    init(title: String, serverValue: Bool, accepted write: @escaping (Bool) async -> Bool) {
    ++        self.title = title
    ++        self.serverValue = serverValue
    ++        self.write = write
    ++    }
    ++
          var body: some View {
    --        Toggle(title, isOn: $isOn)
    --            .onAppear {
    --                if !seeded {
    --                    isOn = serverValue
    --                    seeded = true
    -+        Toggle(title, isOn: Binding(
    -+            get: { pending ?? serverValue },
    -+            set: { newValue in
    -+                guard pending == nil, newValue != serverValue else { return }
    -+                pending = newValue
    -+                Task {
    -+                    await write(newValue)
    -+                    pending = nil
    -                 }
    +         Toggle(title, isOn: $isOn)
    +             .onAppear {
    +@@ gitbay/Views/Repos/RepoSettingsView.swift: struct SettingToggle: View {
                  }
    --            .onChange(of: serverValue) { _, newValue in
    --                isOn = newValue
    --            }
    --            .onChange(of: isOn) { _, newValue in
    --                guard newValue != serverValue else { return }
    +             .onChange(of: isOn) { _, newValue in
    +                 guard newValue != serverValue else { return }
     -                Task { await write(newValue) }
    --            }
    -+        ))
    ++                Task {
    ++                    if !(await write(newValue)) { isOn = !newValue }
    ++                }
    +             }
          }
      }