From de85abc47c533702dff4b2e0dbbe59ccbd934ff2 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 11 Jul 2026 10:13:10 +0200 Subject: [PATCH] feat: iOS mailboxes rebuilt end to end - the list is a full-screen drawer browser cover (warmup status scopes with live counts, search pill, multi-select bulk warmup/remove, circular close) since pushing it fought the system back swipe; connecting is an air onboarding flow with real Google/Outlook brand logos, a web-style authorize step with scope bullets, working in-app OAuth via ASWebAuthenticationSession against the new warmbly:// callback, an SMTP/IMAP wizard, and a start-warmup offer on success; the detail screen becomes editable (warmup ramp steppers, sending window and weekday bitmask chips matching the Go scheduler's Sunday-first layout, sender profile, sending limits, disconnect) with debounced optimistic PATCHes --- .../GoogleLogo.imageset/Contents.json | 12 + .../GoogleLogo.imageset/google-logo.png | Bin 0 -> 25777 bytes .../OutlookLogo.imageset/Contents.json | 12 + .../OutlookLogo.imageset/outlook-logo.png | Bin 0 -> 84179 bytes .../Mailboxes/MailboxConnectFlow.swift | 1123 +++++++++++++++++ .../Mailboxes/MailboxConnectSheet.swift | 257 ---- .../Mailboxes/MailboxDetailStore.swift | 68 + .../Mailboxes/MailboxDetailView.swift | 394 +++++- .../Features/Mailboxes/MailboxModels.swift | 8 +- .../Mailboxes/MailboxesRootView.swift | 729 +++++++++-- .../Features/Mailboxes/MailboxesSidebar.swift | 158 +++ .../Features/Mailboxes/MailboxesStore.swift | 156 ++- 12 files changed, 2501 insertions(+), 416 deletions(-) create mode 100644 ios/Warmbly/Assets.xcassets/GoogleLogo.imageset/Contents.json create mode 100644 ios/Warmbly/Assets.xcassets/GoogleLogo.imageset/google-logo.png create mode 100644 ios/Warmbly/Assets.xcassets/OutlookLogo.imageset/Contents.json create mode 100644 ios/Warmbly/Assets.xcassets/OutlookLogo.imageset/outlook-logo.png create mode 100644 ios/Warmbly/Features/Mailboxes/MailboxConnectFlow.swift delete mode 100644 ios/Warmbly/Features/Mailboxes/MailboxConnectSheet.swift create mode 100644 ios/Warmbly/Features/Mailboxes/MailboxesSidebar.swift diff --git a/ios/Warmbly/Assets.xcassets/GoogleLogo.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/GoogleLogo.imageset/Contents.json new file mode 100644 index 00000000..52778d36 --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/GoogleLogo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "google-logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Warmbly/Assets.xcassets/GoogleLogo.imageset/google-logo.png b/ios/Warmbly/Assets.xcassets/GoogleLogo.imageset/google-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..971ef954918b6c95203d16d616bee70ee8b1b966 GIT binary patch literal 25777 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 literal 0 HcmV?d00001 diff --git a/ios/Warmbly/Assets.xcassets/OutlookLogo.imageset/Contents.json b/ios/Warmbly/Assets.xcassets/OutlookLogo.imageset/Contents.json new file mode 100644 index 00000000..3d9d1c4e --- /dev/null +++ b/ios/Warmbly/Assets.xcassets/OutlookLogo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "outlook-logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Warmbly/Assets.xcassets/OutlookLogo.imageset/outlook-logo.png b/ios/Warmbly/Assets.xcassets/OutlookLogo.imageset/outlook-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8f5ff31af3c4ec50151757ce0fd181cccc36d4f2 GIT binary patch literal 84179 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 literal 0 HcmV?d00001 diff --git a/ios/Warmbly/Features/Mailboxes/MailboxConnectFlow.swift b/ios/Warmbly/Features/Mailboxes/MailboxConnectFlow.swift new file mode 100644 index 00000000..8e3e558f --- /dev/null +++ b/ios/Warmbly/Features/Mailboxes/MailboxConnectFlow.swift @@ -0,0 +1,1123 @@ +import AuthenticationServices +import SwiftUI + +/// Full-screen "connect a mailbox" onboarding wearing the same air as +/// sign-up: the sky with its flight ambience and a step badge, a full-bleed +/// white sheet below. Gmail/Outlook connect through an in-app +/// ASWebAuthenticationSession ceremony; everything else goes through the +/// native SMTP/IMAP form. Dismissal always goes through `onClose()`; the +/// environment DismissAction can no-op in this app's presentation contexts. +struct MailboxConnectFlow: View { + var onClose: () -> Void = {} + var onConnected: ((EmailAccount) -> Void)? = nil + + private enum Page: Int { + case provider, connect, account, imap, smtp, done + + var icon: String { + switch self { + case .provider: "envelope.badge.person.crop" + case .connect: "lock.shield.fill" + case .account: "person.crop.circle" + case .imap: "tray.and.arrow.down.fill" + case .smtp: "paperplane.fill" + case .done: "checkmark.seal.fill" + } + } + + var skyLabel: String { + switch self { + case .provider: "Add a mailbox" + case .connect: "Authorize access" + case .account: "Your address" + case .imap: "Incoming mail" + case .smtp: "Outgoing mail" + case .done: "Connected" + } + } + } + + /// Which branch the step badge counts: OAuth is two steps, SMTP is five. + private enum Route { + case undecided, oauth, smtp + } + + private enum WarmupOffer: Equatable { + case offer, starting, active, failed(String) + } + + private enum Field: Hashable { + case name, email + case imapHost, imapPort, imapUser, imapPass + case smtpHost, smtpPort, smtpUser, smtpPass + } + + @Environment(AppEnvironment.self) private var env + + @State private var page = Page.provider + @State private var route = Route.undecided + @State private var direction = 1.0 + @State private var badgeAppeared = false + + // OAuth path + @State private var pendingProvider = "gmail" + @State private var oauthProvider: String? + @State private var oauthFlow = MailboxOAuthFlow() + + // SMTP path form + @State private var name = "" + @State private var email = "" + @State private var imapHost = "" + @State private var imapPort = "993" + @State private var imapUsername = "" + @State private var imapPassword = "" + @State private var smtpHost = "" + @State private var smtpPort = "587" + @State private var sameLogin = true + @State private var smtpUsername = "" + @State private var smtpPassword = "" + + @State private var busy = false + @State private var errorMessage: String? + @State private var errorPulse = 0 + + // Done page + @State private var connectedAccount: EmailAccount? + @State private var warmupOffer = WarmupOffer.offer + + @FocusState private var focused: Field? + + var body: some View { + ZStack(alignment: .bottom) { + SkyBackdrop() + VStack(spacing: 0) { + topBar + skyArea + sheet + } + } + .sensoryFeedback(.impact(weight: .light), trigger: page) + .sensoryFeedback(.error, trigger: errorPulse) + } + + private var pagePath: [Page] { + route == .smtp + ? [.provider, .account, .imap, .smtp, .done] + : [.provider, .connect, .done] + } + + private var stepIndex: Int { pagePath.firstIndex(of: page) ?? 0 } + + private var isBusy: Bool { busy || warmupOffer == .starting || oauthProvider != nil } + + private var pageTransition: AnyTransition { + .asymmetric( + insertion: .move(edge: direction > 0 ? .trailing : .leading).combined(with: .opacity), + removal: .move(edge: direction > 0 ? .leading : .trailing).combined(with: .opacity) + ) + } + + // MARK: - Sky chrome + + private var topBar: some View { + HStack(spacing: 12) { + if stepIndex > 0, page != .done { + Button { + goBack() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 40, height: 40) + .background(.white.opacity(0.16), in: Circle()) + } + .buttonStyle(PressableButtonStyle()) + .accessibilityLabel("Back") + .disabled(isBusy) + .transition(.opacity.combined(with: .scale(scale: 0.7))) + } + + HStack(spacing: 8) { + WarmblyLogo() + .fill(.white) + .frame(width: 27, height: 28) + Text("Warmbly") + .font(.system(size: 20, weight: .heavy)) + .tracking(-0.4) + .foregroundStyle(.white) + .fixedSize() + } + .shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 8, y: 3) + + Spacer() + + Button { + onClose() + } label: { + Image(systemName: "xmark") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 40, height: 40) + .background(.white.opacity(0.16), in: Circle()) + } + .buttonStyle(PressableButtonStyle()) + .accessibilityLabel("Cancel") + .disabled(isBusy) + } + .padding(.horizontal, 16) + .padding(.top, 6) + .animation(.spring(response: 0.45, dampingFraction: 0.86), value: page) + } + + private var skyArea: some View { + ZStack { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { focused = nil } + + HeroFlightScene() + + pageBadge + .scaleEffect(badgeAppeared ? 1 : 0.9) + .opacity(badgeAppeared ? 1 : 0) + } + .frame(maxWidth: .infinity, minHeight: 40, maxHeight: .infinity) + .animation(.spring(response: 0.5, dampingFraction: 0.85), value: page) + .onAppear { + withAnimation(.spring(response: 0.7, dampingFraction: 0.75).delay(0.05)) { + badgeAppeared = true + } + } + } + + private var pageBadge: some View { + ViewThatFits(in: .vertical) { + VStack(spacing: 12) { + ZStack { + Circle().fill(.white.opacity(0.16)) + Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1) + Image(systemName: page.icon) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) + .contentTransition(.symbolEffect(.replace)) + } + .frame(width: 54, height: 54) + + VStack(spacing: 4) { + Text(page.skyLabel) + .font(.system(size: 15.5, weight: .bold)) + .foregroundStyle(.white) + .contentTransition(.opacity) + Text("Step \(stepIndex + 1) of \(pagePath.count)") + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.7)) + .contentTransition(.numericText()) + } + + pageSegments + } + .padding(.vertical, 16) + + HStack(spacing: 10) { + ZStack { + Circle().fill(.white.opacity(0.16)) + Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1) + Image(systemName: page.icon) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .contentTransition(.symbolEffect(.replace)) + } + .frame(width: 34, height: 34) + + VStack(alignment: .leading, spacing: 4) { + Text(page.skyLabel) + .font(.system(size: 13.5, weight: .semibold)) + .foregroundStyle(.white) + .contentTransition(.opacity) + HStack(spacing: 7) { + pageSegments + Text("Step \(stepIndex + 1) of \(pagePath.count)") + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.7)) + .contentTransition(.numericText()) + } + } + } + .padding(.vertical, 8) + } + .shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 6, y: 2) + } + + private var pageSegments: some View { + HStack(spacing: 6) { + ForEach(Array(pagePath.enumerated()), id: \.element.rawValue) { index, segment in + Capsule() + .fill(.white.opacity(index <= stepIndex ? 0.95 : 0.3)) + .frame(width: segment == page ? 26 : 14, height: 4) + } + } + } + + // MARK: - The sheet + + private var sheet: some View { + VStack(alignment: .leading, spacing: 0) { + // Always a ScrollView: ViewThatFits swaps identities when the + // keyboard shrinks the space, which destroys the focused field + // mid-tap on the taller form pages. + ScrollView { + pageContent + } + .scrollBounceBehavior(.basedOnSize) + .scrollDismissesKeyboard(.interactively) + + footer + } + .padding(.horizontal, 24) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + UnevenRoundedRectangle(cornerRadii: .init(topLeading: 36, topTrailing: 36)) + .fill(Color(.systemBackground) + .shadow(.drop(color: Color(hex: 0x0F172A).opacity(0.28), radius: 34, y: -6))) + .padding(.bottom, -600) + .ignoresSafeArea() + } + .geometryGroup() + .layoutPriority(1) + } + + private var pageContent: some View { + Group { + switch page { + case .provider: providerPage + case .connect: connectPage + case .account: accountPage + case .imap: imapPage + case .smtp: smtpPage + case .done: donePage + } + } + .padding(.top, 30) + .padding(.bottom, 8) + .transition(pageTransition) + } + + private var footer: some View { + VStack(spacing: 12) { + if let errorMessage { + Text(errorMessage) + .font(.system(size: 13.5)) + .foregroundStyle(WTheme.negative) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .transition(.opacity) + } + + if page == .smtp, busy { + Text("Verified against your server before saving.") + .font(.system(size: 13)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .transition(.opacity) + } + + if page == .connect, oauthProvider != nil { + Text("Waiting for authorization in the \(providerDisplayName) window.") + .font(.system(size: 13)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .transition(.opacity) + } + + if page != .provider { + Button { + Task { await primaryAction() } + } label: { + Group { + if primaryBusy { + ProgressView().tint(.white) + } else { + HStack(spacing: 8) { + Text(primaryLabel) + if let icon = primaryIcon { + Image(systemName: icon) + .font(.system(size: 14, weight: .semibold)) + } + } + } + } + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 56) + .background( + LinearGradient( + colors: !canAdvance && !primaryBusy + ? [WTheme.accent.opacity(0.35), WTheme.accent.opacity(0.35)] + : [Color(hex: 0x0EA5E9), Color(hex: 0x0284C7)], + startPoint: .top, + endPoint: .bottom + ), + in: RoundedRectangle(cornerRadius: 17) + ) + .shadow(color: !canAdvance ? .clear : Color(hex: 0x0284C7).opacity(0.32), radius: 12, y: 6) + } + .buttonStyle(PressableButtonStyle()) + .disabled(primaryBusy || !canAdvance) + .animation(.easeOut(duration: 0.18), value: canAdvance) + } + + if page == .done, warmupOffer == .offer { + Button("Skip for now") { + onClose() + } + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.secondary) + .disabled(primaryBusy) + .transition(.opacity) + } + } + .padding(.top, 10) + .padding(.bottom, 14) + .animation(.easeOut(duration: 0.2), value: page) + .animation(.easeOut(duration: 0.2), value: errorMessage != nil) + .animation(.easeOut(duration: 0.2), value: warmupOffer) + } + + private var primaryBusy: Bool { busy || warmupOffer == .starting || oauthProvider != nil } + + private var primaryLabel: String { + switch page { + case .provider, .account, .imap: "Continue" + case .connect: "Continue with \(providerDisplayName)" + case .smtp: "Connect mailbox" + case .done: + switch warmupOffer { + case .offer, .starting: "Start warmup" + case .active, .failed: "Done" + } + } + } + + private var primaryIcon: String? { + switch page { + case .connect: "arrow.up.right.square" + case .smtp: "paperplane.fill" + case .done: warmupOffer == .offer ? "flame.fill" : nil + default: nil + } + } + + // MARK: - Pages + + private func pageTitle(_ title: String, _ subtitle: String) -> some View { + VStack(alignment: .leading, spacing: 7) { + Text(title) + .font(.system(size: 30, weight: .bold)) + .tracking(-0.6) + .foregroundStyle(.primary) + Text(subtitle) + .font(.system(size: 15.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 22) + } + + private var providerPage: some View { + VStack(alignment: .leading, spacing: 0) { + pageTitle( + "Connect a sending account", + "Warm it up and send campaigns from your own address." + ) + + VStack(spacing: 12) { + providerRow( + "Gmail / Google Workspace", + sub: "OAuth via Google. Best deliverability for Gmail.", + provider: "gmail" + ) { + brandLogoTile("GoogleLogo", size: 42) + } + providerRow( + "Outlook / Microsoft 365", + sub: "OAuth via Microsoft. Native sync for Outlook accounts.", + provider: "outlook" + ) { + brandLogoTile("OutlookLogo", size: 42) + } + providerRow( + "Other (SMTP / IMAP)", + sub: "Any provider with a host, port and app password.", + provider: nil + ) { + IconTile(symbol: "server.rack", tone: .slate, size: 42) + } + } + + Text("Credentials are stored encrypted. You can disconnect anytime.") + .font(.system(size: 13)) + .foregroundStyle(.tertiary) + .padding(.top, 12) + } + } + + /// Brand mark on a white tile with a hairline border, like the web's + /// provider rows. + private func brandLogoTile(_ asset: String, size: CGFloat) -> some View { + Image(asset) + .resizable() + .scaledToFit() + .frame(width: size * 0.55, height: size * 0.55) + .frame(width: size, height: size) + .background(.white, in: RoundedRectangle(cornerRadius: size * 0.32)) + .overlay( + RoundedRectangle(cornerRadius: size * 0.32) + .strokeBorder(Color(.separator).opacity(0.5), lineWidth: 1) + ) + } + + private func providerRow( + _ title: String, + sub: String, + provider: String?, + @ViewBuilder tile: () -> Tile + ) -> some View { + Button { + errorMessage = nil + if let provider { + pendingProvider = provider + route = .oauth + goTo(.connect) + } else { + route = .smtp + goTo(.account) + } + } label: { + HStack(spacing: 12) { + tile() + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 15.5, weight: .semibold)) + .foregroundStyle(.primary) + Text(sub) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 14) + .padding(.vertical, 13) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17)) + } + .buttonStyle(PressableButtonStyle()) + } + + // MARK: - Connect page (OAuth ceremony) + + private var providerDisplayName: String { + pendingProvider == "outlook" ? "Microsoft" : "Google" + } + + private var providerLogoAsset: String { + pendingProvider == "outlook" ? "OutlookLogo" : "GoogleLogo" + } + + private var connectPage: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 14) { + brandLogoTile(providerLogoAsset, size: 56) + VStack(alignment: .leading, spacing: 3) { + Text("Connect with \(providerDisplayName)") + .font(.system(size: 22, weight: .bold)) + .tracking(-0.4) + .foregroundStyle(.primary) + Text(pendingProvider == "outlook" ? "Outlook or Microsoft 365" : "Gmail or Google Workspace") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + } + } + .padding(.bottom, 16) + + Text("We'll open a \(providerDisplayName) sign-in window. Approve the access and you're done.") + .font(.system(size: 15.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 20) + + VStack(alignment: .leading, spacing: 12) { + connectScopeRow("paperplane.fill", "Send and read mail on your behalf") + connectScopeRow("arrowshape.turn.up.left.fill", "Track replies and deliveries") + connectScopeRow("lock.fill", "Tokens are stored encrypted. Revoke anytime.") + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17)) + } + } + + private func connectScopeRow(_ symbol: String, _ text: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Image(systemName: symbol) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(WTheme.accent) + .frame(width: 18) + Text(text) + .font(.system(size: 14)) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var accountPage: some View { + VStack(alignment: .leading, spacing: 0) { + pageTitle( + "Your sending address", + "The name and email recipients will see." + ) + + VStack(alignment: .leading, spacing: 14) { + fieldBlock("Name", helper: "The sender name recipients see.") { + styledField("Alex Rivera", text: $name, field: .name) { + focused = .email + } + .textContentType(.name) + } + + fieldBlock("Email") { + styledField("alex@company.com", text: $email, field: .email, submit: .continue) { + Task { await advanceIfValid() } + } + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + } + .onAppear { + if name.isEmpty { focused = .name } + } + } + + private var imapPage: some View { + VStack(alignment: .leading, spacing: 0) { + pageTitle( + "Incoming mail", + "IMAP keeps replies and inbox activity in sync." + ) + + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + fieldBlock("Host") { + styledField("imap.example.com", text: $imapHost, field: .imapHost) { + focused = .imapUser + } + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + fieldBlock("Port") { + styledField("993", text: $imapPort, field: .imapPort) + .keyboardType(.numberPad) + .frame(width: 96) + } + } + + fieldBlock("Username") { + styledField("alex@company.com", text: $imapUsername, field: .imapUser) { + focused = .imapPass + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + fieldBlock("Password", helper: "Usually an app-specific password, not your main login.") { + styledField("App password", text: $imapPassword, field: .imapPass, secure: true, submit: .continue) { + Task { await advanceIfValid() } + } + } + } + } + .onAppear { + if imapUsername.isEmpty { imapUsername = trimmedEmail } + if imapHost.isEmpty { focused = .imapHost } + } + } + + private var smtpPage: some View { + VStack(alignment: .leading, spacing: 0) { + pageTitle( + "Outgoing mail", + "The SMTP server your campaigns send through." + ) + + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + fieldBlock("Host") { + styledField("smtp.example.com", text: $smtpHost, field: .smtpHost) { + focused = .smtpPort + } + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + fieldBlock("Port", helper: "465 or 587.") { + styledField("587", text: $smtpPort, field: .smtpPort) + .keyboardType(.numberPad) + .frame(width: 96) + } + } + + Toggle(isOn: $sameLogin.animation(.spring(response: 0.4, dampingFraction: 0.85))) { + Text("Use the same login as IMAP") + .font(.system(size: 15.5, weight: .medium)) + .foregroundStyle(.primary) + } + .tint(WTheme.accent) + .padding(.horizontal, 16) + .frame(height: 56) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17)) + + if !sameLogin { + fieldBlock("Username") { + styledField("alex@company.com", text: $smtpUsername, field: .smtpUser) { + focused = .smtpPass + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + .transition(.opacity.combined(with: .move(edge: .top))) + + fieldBlock("Password") { + styledField("App password", text: $smtpPassword, field: .smtpPass, secure: true, submit: .continue) { + Task { await advanceIfValid() } + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + .onAppear { + if smtpHost.isEmpty { focused = .smtpHost } + } + } + + private var donePage: some View { + VStack(alignment: .leading, spacing: 0) { + pageTitle( + "Mailbox connected", + "Syncing has started. It will appear in your accounts list right away." + ) + + if let account = connectedAccount { + HStack(spacing: 12) { + IconTile(symbol: "envelope.fill", tone: .sky, size: 42) + VStack(alignment: .leading, spacing: 2) { + Text(account.email) + .font(.system(size: 16.5, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.middle) + Text(account.providerLabel) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 20)) + .foregroundStyle(.white, WTheme.positive) + } + .padding(.horizontal, 14) + .padding(.vertical, 13) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17)) + .padding(.bottom, 14) + } + + warmupBlock + } + } + + private var warmupBlock: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + IconTile(symbol: "flame.fill", tone: .orange, size: 42) + VStack(alignment: .leading, spacing: 4) { + Text(warmupOffer == .active ? "Warming up" : "Start warming up") + .font(.system(size: 15.5, weight: .semibold)) + .foregroundStyle(.primary) + .contentTransition(.opacity) + if warmupOffer == .active { + StatusPill(text: "warming", tone: .orange, pulsing: true) + .transition(.scale(scale: 0.6).combined(with: .opacity)) + } + } + Spacer(minLength: 8) + if warmupOffer == .active { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 20)) + .foregroundStyle(.white, Tone.orange.color) + .transition(.scale(scale: 0.4).combined(with: .opacity)) + } + } + + Text(warmupOffer == .active + ? "This mailbox is building sender reputation. Track its progress from the accounts list." + : "Gradually builds sender reputation before campaigns. Starts at 10 a day and ramps automatically.") + .font(.system(size: 13.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if case let .failed(message) = warmupOffer { + Text(message) + .font(.system(size: 13)) + .foregroundStyle(WTheme.negative) + .fixedSize(horizontal: false, vertical: true) + .transition(.opacity) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Tone.orange.background.opacity(0.55), in: RoundedRectangle(cornerRadius: 17)) + .overlay( + RoundedRectangle(cornerRadius: 17) + .strokeBorder(Tone.orange.color.opacity(0.25), lineWidth: 1) + ) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: warmupOffer) + } + + // MARK: - Field primitives + + private func fieldBlock(_ label: String, helper: String? = nil, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 7) { + Text(label) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + content() + if let helper { + Text(helper) + .font(.system(size: 12.5)) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func styledField( + _ placeholder: String, + text: Binding, + field: Field, + secure: Bool = false, + submit: SubmitLabel = .next, + onSubmit: @escaping () -> Void = {} + ) -> some View { + Group { + if secure { + SecureField(placeholder, text: text) + } else { + TextField(placeholder, text: text) + } + } + .focused($focused, equals: field) + .submitLabel(submit) + .onSubmit(onSubmit) + .font(.system(size: 16.5)) + .padding(.horizontal, 16) + .frame(height: 56) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17)) + .overlay( + RoundedRectangle(cornerRadius: 17) + .strokeBorder(focused == field ? WTheme.accent : .clear, lineWidth: 1.8) + ) + .animation(.easeOut(duration: 0.18), value: focused) + } + + // MARK: - Flow + + private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) } + private var trimmedEmail: String { email.trimmingCharacters(in: .whitespaces) } + + private var canAdvance: Bool { + switch page { + case .provider: + return false + case .connect: + return true + case .account: + return trimmedName.count >= 2 && trimmedEmail.contains("@") && trimmedEmail.contains(".") + case .imap: + return !imapHost.trimmingCharacters(in: .whitespaces).isEmpty + && (Int(imapPort) ?? 0) > 0 + case .smtp: + let port = Int(smtpPort) ?? 0 + return !smtpHost.trimmingCharacters(in: .whitespaces).isEmpty + && (port == 465 || port == 587) + case .done: + return true + } + } + + private func goTo(_ target: Page) { + focused = nil + let path = pagePath + let from = path.firstIndex(of: page) ?? 0 + let to = path.firstIndex(of: target) ?? 0 + direction = to >= from ? 1 : -1 + withAnimation(.spring(response: 0.45, dampingFraction: 0.86)) { + page = target + } + } + + private func goBack() { + errorMessage = nil + let path = pagePath + guard let index = path.firstIndex(of: page), index > 0 else { return } + let previous = path[index - 1] + focused = nil + direction = -1 + withAnimation(.spring(response: 0.45, dampingFraction: 0.86)) { + page = previous + } + if previous == .provider { route = .undecided } + } + + private func primaryAction() async { + switch page { + case .provider: + break + case .connect: + await startOAuth(pendingProvider) + case .account, .imap, .smtp: + await advanceIfValid() + case .done: + switch warmupOffer { + case .offer: + await startWarmup() + case .active, .failed: + onClose() + case .starting: + break + } + } + } + + private func advanceIfValid() async { + guard canAdvance else { return } + errorMessage = nil + switch page { + case .account: + goTo(.imap) + case .imap: + goTo(.smtp) + case .smtp: + await submitSMTP() + case .provider, .connect, .done: + break + } + } + + private func showError(_ message: String) { + withAnimation(.easeOut(duration: 0.2)) { + errorMessage = message + } + errorPulse += 1 + } + + private func finishConnect(_ account: EmailAccount) { + connectedAccount = account + onConnected?(account) + UINotificationFeedbackGenerator().notificationOccurred(.success) + if route != .smtp { route = .oauth } + goTo(.done) + } + + // MARK: - OAuth + + private func startOAuth(_ provider: String) async { + guard oauthProvider == nil, !busy else { return } + withAnimation(.easeOut(duration: 0.2)) { errorMessage = nil } + oauthProvider = provider + defer { oauthProvider = nil } + do { + let start: MailboxOAuthStartResponse = try await env.api.post( + "emails/onboarding/oauth/start", + body: MailboxOAuthStartBody(provider: provider) + ) + guard let url = URL(string: start.url) else { + showError("The provider returned an invalid authorization URL.") + return + } + + let callback = try await oauthFlow.authorize(url: url) + let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? [] + func query(_ name: String) -> String? { + items.first { $0.name == name }?.value + } + + if let providerError = query("error"), !providerError.isEmpty { + // Declining consent is a cancel, not a failure worth a banner. + if providerError != "access_denied" { + showError("The provider couldn't authorize the connection (\(providerError)).") + } + return + } + guard query("state") == start.state else { + showError("Sign-in session mismatch. Please try again.") + return + } + guard let code = query("code"), !code.isEmpty else { + showError("The provider didn't return an authorization code. Please try again.") + return + } + + let account: EmailAccount = try await env.api.post( + "emails/onboarding/oauth/finish", + body: MailboxOAuthFinishBody(code: code, state: start.state) + ) + finishConnect(account) + } catch MailboxOAuthFlow.Failure.cancelled { + // User closed the sheet; stay quiet. + } catch { + showError((error as? APIError)?.errorDescription ?? error.localizedDescription) + } + } + + // MARK: - SMTP submit + + private func submitSMTP() async { + focused = nil + errorMessage = nil + busy = true + let login = ( + username: imapUsername, + password: imapPassword + ) + let body = MailboxSMTPConnectBody( + email: trimmedEmail, + name: trimmedName, + smtp: MailboxServerCredentials( + username: sameLogin ? login.username : smtpUsername, + password: sameLogin ? login.password : smtpPassword, + host: smtpHost.trimmingCharacters(in: .whitespaces), + port: Int(smtpPort) ?? 587 + ), + imap: MailboxServerCredentials( + username: login.username, + password: login.password, + host: imapHost.trimmingCharacters(in: .whitespaces), + port: Int(imapPort) ?? 993 + ) + ) + do { + let account: EmailAccount = try await env.api.post("emails/onboarding/smtp-imap", body: body) + busy = false + finishConnect(account) + } catch { + busy = false + showError((error as? APIError)?.errorDescription ?? error.localizedDescription) + } + } + + // MARK: - Warmup offer + + private func startWarmup() async { + guard let account = connectedAccount, warmupOffer == .offer else { return } + withAnimation(.easeOut(duration: 0.2)) { warmupOffer = .starting } + do { + let updated: EmailAccount = try await env.api.post("emails/\(account.id)/warmup/start") + connectedAccount = updated + UINotificationFeedbackGenerator().notificationOccurred(.success) + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + warmupOffer = .active + } + } catch { + let message = (error as? APIError)?.errorDescription ?? error.localizedDescription + withAnimation(.easeOut(duration: 0.2)) { + warmupOffer = .failed(message) + } + errorPulse += 1 + } + } +} + +/// `POST /emails/onboarding/oauth/finish` body (not in MailboxModels). +private struct MailboxOAuthFinishBody: Encodable { + var code: String + var state: String +} + +/// The mailbox OAuth ceremony: opens the backend-minted authorization URL in +/// an ASWebAuthenticationSession and hands back the `warmbly://email-oauth` +/// callback. Mirrors GoogleSignInFlow's presentation-context pattern. +@MainActor +final class MailboxOAuthFlow: NSObject, ASWebAuthenticationPresentationContextProviding { + enum Failure: Error { + case cancelled + case malformedResponse + } + + private var activeSession: ASWebAuthenticationSession? + private var presentationWindow: UIWindow? + + func authorize(url: URL) async throws -> URL { + // Captured up front (we're on the main actor) so the presentation + // callback never has to conjure a window of its own. + guard let window = Self.keyWindow else { throw Failure.malformedResponse } + presentationWindow = window + defer { presentationWindow = nil } + + return try await withCheckedThrowingContinuation { continuation in + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "warmbly") { [weak self] callbackURL, error in + Task { @MainActor in self?.activeSession = nil } + if let error { + if let webError = error as? ASWebAuthenticationSessionError, webError.code == .canceledLogin { + continuation.resume(throwing: Failure.cancelled) + } else { + continuation.resume(throwing: error) + } + return + } + guard let callbackURL else { + continuation.resume(throwing: Failure.malformedResponse) + return + } + continuation.resume(returning: callbackURL) + } + session.presentationContextProvider = self + // Keep the user's provider session so re-connects skip the login. + session.prefersEphemeralWebBrowserSession = false + activeSession = session + if !session.start() { + activeSession = nil + continuation.resume(throwing: Failure.malformedResponse) + } + } + } + + nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + MainActor.assumeIsolated { + // authorize guards that a window exists before the session starts. + presentationWindow ?? Self.keyWindow! + } + } + + private static var keyWindow: UIWindow? { + let windows = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + return windows.first(where: \.isKeyWindow) ?? windows.first + } +} diff --git a/ios/Warmbly/Features/Mailboxes/MailboxConnectSheet.swift b/ios/Warmbly/Features/Mailboxes/MailboxConnectSheet.swift deleted file mode 100644 index f26a043f..00000000 --- a/ios/Warmbly/Features/Mailboxes/MailboxConnectSheet.swift +++ /dev/null @@ -1,257 +0,0 @@ -import SwiftUI - -/// Modal connect flow. As a sheet it is its own root, so it wraps content in -/// a NavigationStack (unlike pushed detail views). Offers the two provider -/// OAuth launchers and the fully-native SMTP/IMAP form. -struct MailboxConnectSheet: View { - @Environment(AppEnvironment.self) private var env - @Environment(\.dismiss) private var dismiss - @Environment(\.openURL) private var openURL - - @State private var mode: Mode = .choose - @State private var errorText: String? - @State private var isWorking = false - - // SMTP/IMAP form - @State private var email = "" - @State private var displayName = "" - @State private var smtpHost = "" - @State private var smtpPort = "465" - @State private var smtpUsername = "" - @State private var smtpPassword = "" - @State private var imapHost = "" - @State private var imapPort = "993" - @State private var imapUsername = "" - @State private var imapPassword = "" - - enum Mode: Equatable { - case choose - case smtp - } - - var body: some View { - NavigationStack { - Group { - switch mode { - case .choose: chooser - case .smtp: smtpForm - } - } - .navigationTitle("Connect a mailbox") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - if mode == .smtp { - ToolbarItem(placement: .confirmationAction) { - if isWorking { - ProgressView().controlSize(.small) - } else { - Button("Connect") { - Task { await connectSMTP() } - } - .disabled(!smtpFormValid) - } - } - } - } - .alert( - "Couldn't connect", - isPresented: Binding( - get: { errorText != nil }, - set: { if !$0 { errorText = nil } } - ) - ) { - Button("OK", role: .cancel) {} - } message: { - Text(errorText ?? "") - } - } - .presentationDragIndicator(.visible) - .sensoryFeedback(.selection, trigger: mode) - } - - // MARK: Chooser - - private var chooser: some View { - List { - Section { - providerRow("Gmail", subtitle: "Connect with Google", provider: "gmail", symbol: "envelope.fill", tone: .rose) - providerRow("Outlook", subtitle: "Connect with Microsoft", provider: "outlook", symbol: "envelope.fill", tone: .sky) - } header: { - Text("OAuth") - } - Section { - Button { - mode = .smtp - } label: { - HStack(spacing: 12) { - IconTile(symbol: "server.rack", tone: .slate, size: 38) - VStack(alignment: .leading, spacing: 2) { - Text("SMTP / IMAP") - .font(.body.weight(.medium)) - .foregroundStyle(.primary) - Text("Host, port, and app password") - .font(.footnote) - .foregroundStyle(.secondary) - } - Spacer() - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.tertiary) - } - .padding(.vertical, 4) - } - .buttonStyle(TapScaleStyle()) - } header: { - Text("Manual") - } footer: { - Text("Use a host, port, and app password for providers without OAuth.") - } - } - } - - private func providerRow(_ title: String, subtitle: String, provider: String, symbol: String, tone: Tone) -> some View { - Button { - Task { await startOAuth(provider: provider) } - } label: { - HStack(spacing: 12) { - IconTile(symbol: symbol, tone: tone, size: 38) - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.body.weight(.medium)) - .foregroundStyle(.primary) - Text(subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - } - Spacer() - if isWorking { - ProgressView().controlSize(.small) - } else { - Image(systemName: "arrow.up.forward.app") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(WTheme.accent) - } - } - .padding(.vertical, 4) - } - .buttonStyle(TapScaleStyle()) - .disabled(isWorking) - } - - // MARK: SMTP form - - private var smtpForm: some View { - List { - Section("Mailbox") { - TextField("Email address", text: $email) - .textContentType(.emailAddress) - .keyboardType(.emailAddress) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - TextField("Sender name", text: $displayName) - } - Section("Outgoing (SMTP)") { - TextField("Host", text: $smtpHost) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - TextField("Port (465 or 587)", text: $smtpPort) - .keyboardType(.numberPad) - TextField("Username", text: $smtpUsername) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - SecureField("Password", text: $smtpPassword) - } - Section("Incoming (IMAP)") { - TextField("Host", text: $imapHost) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - TextField("Port", text: $imapPort) - .keyboardType(.numberPad) - TextField("Username", text: $imapUsername) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - SecureField("Password", text: $imapPassword) - } - Section { - Button { - Task { await connectSMTP() } - } label: { - Group { - if isWorking { - ProgressView().tint(.white) - } else { - Text("Connect mailbox") - .font(.body.weight(.semibold)) - } - } - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(WTheme.accent) - .controlSize(.large) - .disabled(!smtpFormValid || isWorking) - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) - } - } - } - - private var smtpFormValid: Bool { - guard email.contains("@"), displayName.count >= 2 else { return false } - guard !smtpHost.isEmpty, !imapHost.isEmpty else { return false } - guard let sPort = Int(smtpPort), sPort == 465 || sPort == 587 else { return false } - guard let iPort = Int(imapPort), iPort > 0 else { return false } - return true - } - - // MARK: Actions - - private func startOAuth(provider: String) async { - isWorking = true - defer { isWorking = false } - do { - let response: MailboxOAuthStartResponse = try await env.api.post( - "emails/onboarding/oauth/start", - body: MailboxOAuthStartBody(provider: provider) - ) - if let url = URL(string: response.url) { - openURL(url) - dismiss() - } else { - errorText = "The provider returned an invalid authorization URL." - } - } catch { - errorText = error.localizedDescription - } - } - - private func connectSMTP() async { - isWorking = true - defer { isWorking = false } - let body = MailboxSMTPConnectBody( - email: email.trimmingCharacters(in: .whitespaces), - name: displayName.trimmingCharacters(in: .whitespaces), - smtp: MailboxServerCredentials( - username: smtpUsername, - password: smtpPassword, - host: smtpHost.trimmingCharacters(in: .whitespaces), - port: Int(smtpPort) ?? 465 - ), - imap: MailboxServerCredentials( - username: imapUsername, - password: imapPassword, - host: imapHost.trimmingCharacters(in: .whitespaces), - port: Int(imapPort) ?? 993 - ) - ) - do { - let _: EmailAccount = try await env.api.post("emails/onboarding/smtp-imap", body: body) - dismiss() - } catch { - errorText = error.localizedDescription - } - } -} diff --git a/ios/Warmbly/Features/Mailboxes/MailboxDetailStore.swift b/ios/Warmbly/Features/Mailboxes/MailboxDetailStore.swift index 9541bc8e..5da03f8b 100644 --- a/ios/Warmbly/Features/Mailboxes/MailboxDetailStore.swift +++ b/ios/Warmbly/Features/Mailboxes/MailboxDetailStore.swift @@ -16,8 +16,16 @@ final class MailboxDetailStore { private(set) var banStatus: MailboxBanStatus? private(set) var isCheckingAuth = false private(set) var isSaving = false + /// True once a local edit or PATCH response refreshed the row, so views + /// can stop preferring analytics for fields the list response omits. + private(set) var rowEdited = false var actionError: String? + // Debounce state for stepper bursts: one snapshot per burst, one body. + private var pendingBody = MailboxUpdateBody() + private var pendingSnapshot: EmailAccount? + private var editGeneration = 0 + init(account: EmailAccount) { self.account = account } @@ -75,6 +83,7 @@ final class MailboxDetailStore { defer { isSaving = false } do { let updated: EmailAccount = try await api.patch("emails/\(account.id)", body: body) + rowEdited = true apply(updated) return true } catch { @@ -82,4 +91,63 @@ final class MailboxDetailStore { return false } } + + /// Optimistic partial update: apply the local mutation immediately, PATCH, + /// then adopt the server's row (or roll back and surface the error). + func update(_ api: APIClient, body: MailboxUpdateBody, mutate: (inout EmailAccount) -> Void) async { + let previous = account + rowEdited = true + withAnimation(.snappy) { mutate(&account) } + do { + let updated: EmailAccount = try await api.patch("emails/\(account.id)", body: body) + withAnimation(.snappy) { account = updated } + } catch { + withAnimation(.snappy) { account = previous } + actionError = error.localizedDescription + } + } + + /// Stepper variant: every tap applies locally right away, but rapid taps + /// coalesce into one PATCH after 600ms of quiet. A failed PATCH rolls the + /// whole burst back to the pre-burst row. + func updateDebounced( + _ api: APIClient, + mutateBody: (inout MailboxUpdateBody) -> Void, + mutate: (inout EmailAccount) -> Void + ) async { + if pendingSnapshot == nil { pendingSnapshot = account } + mutateBody(&pendingBody) + rowEdited = true + withAnimation(.snappy) { mutate(&account) } + editGeneration += 1 + let generation = editGeneration + try? await Task.sleep(for: .milliseconds(600)) + guard generation == editGeneration else { return } + let body = pendingBody + let snapshot = pendingSnapshot + pendingBody = MailboxUpdateBody() + pendingSnapshot = nil + do { + let updated: EmailAccount = try await api.patch("emails/\(account.id)", body: body) + withAnimation(.snappy) { account = updated } + } catch { + if let snapshot { + withAnimation(.snappy) { account = snapshot } + } + actionError = error.localizedDescription + } + } + + // MARK: Disconnect + + /// DELETE /emails/:id (204); true on success so the view can pop. + func deleteAccount(_ api: APIClient) async -> Bool { + do { + let _: EmptyBody = try await api.delete("emails/\(account.id)") + return true + } catch { + actionError = error.localizedDescription + return false + } + } } diff --git a/ios/Warmbly/Features/Mailboxes/MailboxDetailView.swift b/ios/Warmbly/Features/Mailboxes/MailboxDetailView.swift index 3ed94dec..e53fec2c 100644 --- a/ios/Warmbly/Features/Mailboxes/MailboxDetailView.swift +++ b/ios/Warmbly/Features/Mailboxes/MailboxDetailView.swift @@ -27,10 +27,18 @@ enum MailboxDetailTab: String, CaseIterable, Identifiable { /// NOT create its own NavigationStack. Claims presence on `mailbox:` and /// reloads on the emailAccounts/analytics pulse. struct MailboxDetailView: View { + private enum SetupField: Hashable { case name, replyTo } + @Environment(AppEnvironment.self) private var env + @Environment(\.dismiss) private var dismiss @State private var store: MailboxDetailStore @State private var tab: MailboxDetailTab = .overview + @State private var displayName = "" + @State private var replyTo = "" + @State private var fieldsSeeded = false + @State private var confirmDisconnect = false + @FocusState private var focusedField: SetupField? init(account: EmailAccount) { _store = State(initialValue: MailboxDetailStore(account: account)) @@ -55,7 +63,10 @@ struct MailboxDetailView: View { .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .presenceResource(store.presenceKey) - .task { await store.loadAnalytics(env.api) } + .task { + seedFieldsIfNeeded() + await store.loadAnalytics(env.api) + } .task { await store.loadBanStatus(env.api) } .onChange(of: env.realtime.pulse(for: .emailAccounts)) { Task { await store.loadAnalytics(env.api) } @@ -63,6 +74,24 @@ struct MailboxDetailView: View { .onChange(of: env.realtime.pulse(for: .analytics)) { Task { await store.loadAnalytics(env.api) } } + .onChange(of: focusedField) { previous, current in + // Text rows save on focus loss, like the profile settings rows. + if previous == .name, current != .name { saveDisplayName() } + if previous == .replyTo, current != .replyTo { saveReplyTo() } + } + .confirmationDialog( + "Disconnect \(account.email)?", + isPresented: $confirmDisconnect, + titleVisibility: .visible + ) { + Button("Disconnect", role: .destructive) { + Task { + if await store.deleteAccount(env.api) { dismiss() } + } + } + } message: { + Text("Campaigns stop sending from it and history stays.") + } .alert( "Something went wrong", isPresented: Binding( @@ -185,8 +214,7 @@ struct MailboxDetailView: View { } } } - .listStyle(.insetGrouped) - .scrollContentBackground(.hidden) + .listStyle(.plain) } private func healthSection(_ health: MailboxHealth) -> some View { @@ -239,9 +267,10 @@ struct MailboxDetailView: View { private var warmupTab: some View { List { warmupSection + rampSection + windowSection } - .listStyle(.insetGrouped) - .scrollContentBackground(.hidden) + .listStyle(.plain) } @ViewBuilder @@ -327,15 +356,351 @@ struct MailboxDetailView: View { } } + // MARK: Warmup ramp (editable) + + // List responses omit warmup_reply_rate (decodes as 0), so prefer the + // analytics snapshot until a PATCH refreshes the row. + private var replyRateValue: Int { + if store.rowEdited { return account.warmupReplyRate ?? 0 } + return store.analytics?.warmupStatus?.replyRate ?? account.warmupReplyRate ?? 0 + } + + private var rampSection: some View { + Section { + settingStepper( + "Starting volume", helper: "Emails per day when warmup begins.", + value: account.warmupBase ?? 10, range: 1...100, suffix: "/day", + write: { $0.warmupBase = $1 }, mutate: { $0.warmupBase = $1 } + ) + settingStepper( + "Daily increase", helper: "How many more each day as reputation builds.", + value: account.warmupIncrease ?? 1, range: 1...50, suffix: "+/day", + write: { $0.warmupIncrease = $1 }, mutate: { $0.warmupIncrease = $1 } + ) + settingStepper( + "Maximum volume", helper: "Keep conservative for new mailboxes, about 40 a day.", + value: account.warmupMax ?? 40, range: max(1, account.warmupBase ?? 10)...500, suffix: "/day", + write: { $0.warmupMax = $1 }, mutate: { $0.warmupMax = $1 } + ) + settingStepper( + "Reply rate", helper: "Share of warmup mail that gets a reply.", + value: replyRateValue, range: 0...100, suffix: "%", + write: { $0.warmupReplyRate = $1 }, mutate: { $0.warmupReplyRate = $1 } + ) + } header: { + EyebrowLabel("Ramp configuration") + } + } + + // MARK: Sending window (editable) + + /// HH:MM in 30-minute steps, the format the scheduler parses ("15:04"). + private static let timeSlots: [String] = (0..<48).map { + String(format: "%02d:%02d", $0 / 2, $0 % 2 * 30) + } + + /// Backend bit layout is Go time.Weekday: bit 0 = Sunday ... bit 6 = + /// Saturday (scheduler findNextValidDay); mask 0 means every day. + private static let dayBits: [(label: String, bit: Int)] = [ + ("Mon", 1), ("Tue", 2), ("Wed", 3), ("Thu", 4), ("Fri", 5), ("Sat", 6), ("Sun", 0), + ] + + private var windowSection: some View { + Section { + timeRow("Start time", current: timeValue(account.warmupStartTime, fallback: "08:00")) { slot in + Task { + await store.update(env.api, body: MailboxUpdateBody(warmupStartTime: slot)) { + $0.warmupStartTime = slot + } + } + } + timeRow("End time", current: timeValue(account.warmupEndTime, fallback: "20:00")) { slot in + Task { + await store.update(env.api, body: MailboxUpdateBody(warmupEndTime: slot)) { + $0.warmupEndTime = slot + } + } + } + sendingDaysRow + } header: { + EyebrowLabel("Sending window") + } + } + + private func timeValue(_ raw: String?, fallback: String) -> String { + guard let raw, !raw.isEmpty else { return fallback } + return raw + } + + private func timeRow(_ title: String, current: String, apply: @escaping (String) -> Void) -> some View { + HStack(spacing: 12) { + Text(title) + .font(.body.weight(.medium)) + Spacer(minLength: 8) + if canManage { + Menu { + ForEach(Self.timeSlots, id: \.self) { slot in + Button { + apply(slot) + } label: { + if slot == current { + Label(slot, systemImage: "checkmark") + } else { + Text(slot) + } + } + } + } label: { + HStack(spacing: 3) { + Text(current) + .font(.subheadline) + .monospacedDigit() + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .semibold)) + } + .foregroundStyle(WTheme.accent) + } + } else { + Text(current) + .font(.subheadline) + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } + + private var sendingDaysRow: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Sending days") + .font(.body.weight(.medium)) + HStack(spacing: 6) { + ForEach(Self.dayBits, id: \.bit) { day in + dayChip(day.label, bit: day.bit) + } + } + Text("Leave all off to send every day.") + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(.vertical, 6) + } + + private func dayChip(_ label: String, bit: Int) -> some View { + let mask = account.warmupDays ?? 0 + let on = mask & (1 << bit) != 0 + return Button { + let next = mask ^ (1 << bit) + Task { + await store.update(env.api, body: MailboxUpdateBody(warmupDays: next)) { + $0.warmupDays = next + } + } + } label: { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(on ? Color.white : Color.secondary) + .frame(maxWidth: .infinity) + .frame(height: 30) + .background(on ? AnyShapeStyle(WTheme.accent) : AnyShapeStyle(Tone.slate.background), in: Capsule()) + } + .buttonStyle(TapScaleStyle()) + .disabled(!canManage) + } + + // MARK: Shared editing controls + + private func settingStepper( + _ title: String, + helper: String, + value: Int, + range: ClosedRange, + suffix: String, + write: @escaping (inout MailboxUpdateBody, Int) -> Void, + mutate: @escaping (inout EmailAccount, Int) -> Void + ) -> some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.body.weight(.medium)) + Text(helper) + .font(.footnote) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + if canManage { + HStack(spacing: 10) { + stepButton("minus", enabled: value > range.lowerBound) { + step(to: max(range.lowerBound, value - 1), write: write, mutate: mutate) + } + Text("\(value)\(suffix)") + .font(.system(size: 15, weight: .semibold)) + .monospacedDigit() + .frame(minWidth: 46) + .contentTransition(.numericText()) + stepButton("plus", enabled: value < range.upperBound) { + step(to: min(range.upperBound, value + 1), write: write, mutate: mutate) + } + } + } else { + Text("\(value)\(suffix)") + .font(.subheadline) + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + private func step( + to newValue: Int, + write: @escaping (inout MailboxUpdateBody, Int) -> Void, + mutate: @escaping (inout EmailAccount, Int) -> Void + ) { + Task { + await store.updateDebounced( + env.api, + mutateBody: { write(&$0, newValue) }, + mutate: { mutate(&$0, newValue) } + ) + } + } + + private func stepButton(_ symbol: String, enabled: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(enabled ? AnyShapeStyle(WTheme.accent) : AnyShapeStyle(Color(.tertiaryLabel))) + .frame(width: 30, height: 30) + .background(Tone.slate.background, in: Circle()) + } + .buttonStyle(TapScaleStyle()) + .disabled(!enabled) + } + // MARK: Setup tab (domain auth + identity details) private var setupTab: some View { List { + senderProfileSection + limitsSection authSection identitySection + if canManage { + dangerSection + } + } + .listStyle(.plain) + } + + // MARK: Sender profile (editable) + + private var senderProfileSection: some View { + Section { + fieldRow("Display name") { + TextField("Sender name", text: $displayName) + .focused($focusedField, equals: .name) + .submitLabel(.done) + .onSubmit { saveDisplayName() } + .disabled(!canManage) + } + VStack(alignment: .leading, spacing: 4) { + fieldRow("Reply-to") { + TextField(account.email, text: $replyTo) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focusedField, equals: .replyTo) + .submitLabel(.done) + .onSubmit { saveReplyTo() } + .disabled(!canManage) + } + Text("Where replies land. Empty uses the mailbox address.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } header: { + EyebrowLabel("Sender profile") + } + } + + private func fieldRow(_ label: String, @ViewBuilder field: () -> Field) -> some View { + HStack(spacing: 12) { + Text(label) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(width: 96, alignment: .leading) + field() + } + .padding(.vertical, 4) + } + + private func seedFieldsIfNeeded() { + guard !fieldsSeeded else { return } + displayName = account.name ?? "" + replyTo = account.replyTo ?? "" + fieldsSeeded = true + } + + private func saveDisplayName() { + let trimmed = displayName.trimmingCharacters(in: .whitespaces) + guard trimmed != (account.name ?? "") else { return } + Task { + await store.update(env.api, body: MailboxUpdateBody(name: trimmed)) { $0.name = trimmed } + displayName = store.account.name ?? trimmed + } + } + + private func saveReplyTo() { + let trimmed = replyTo.trimmingCharacters(in: .whitespaces) + guard trimmed != (account.replyTo ?? "") else { return } + Task { + // Empty string clears reply-to; replies go to the mailbox itself. + await store.update(env.api, body: MailboxUpdateBody(replyTo: trimmed)) { $0.replyTo = trimmed } + replyTo = store.account.replyTo ?? "" + } + } + + // MARK: Sending limits (editable) + + // min_wait_time is stored in seconds; edit it in whole minutes. + private var minGapMinutes: Int { + max(1, min(120, (account.minWaitTime ?? 600) / 60)) + } + + private var limitsSection: some View { + Section { + settingStepper( + "Daily campaign cap", helper: "Max cold emails per day. Default 50, raise only with good reputation.", + value: account.campaignLimit ?? 50, range: 1...100, suffix: "/day", + write: { $0.campaignLimit = $1 }, mutate: { $0.campaignLimit = $1 } + ) + settingStepper( + "Minimum gap", helper: "Time between two sends from this mailbox.", + value: minGapMinutes, range: 1...120, suffix: " min", + write: { $0.minWaitTime = $1 * 60 }, mutate: { $0.minWaitTime = $1 * 60 } + ) + } header: { + EyebrowLabel("Sending limits") + } + } + + // MARK: Danger zone + + private var dangerSection: some View { + Section { + Button(role: .destructive) { + confirmDisconnect = true + } label: { + HStack { + Spacer() + Text("Disconnect mailbox") + Spacer() + } + } + } header: { + EyebrowLabel("Danger zone") } - .listStyle(.insetGrouped) - .scrollContentBackground(.hidden) } @ViewBuilder @@ -381,23 +746,10 @@ struct MailboxDetailView: View { .padding(.vertical, 2) } + // Name, reply-to, cap and gap moved into the editable sections above. private var identitySection: some View { Section("Details") { - if let name = account.name, !name.isEmpty { - LabeledContent("Sender name", value: name) - } LabeledContent("Provider", value: account.providerLabel) - LabeledContent("Daily cap") { - Text("\(account.campaignLimit ?? 50)/day") - .monospacedDigit() - } - LabeledContent("Min gap") { - Text(MailboxFormat.gap(account.minWaitTime ?? 600)) - .monospacedDigit() - } - if let replyTo = account.replyTo, !replyTo.isEmpty { - LabeledContent("Reply-to", value: replyTo) - } if let synced = account.lastSyncedAt { LabeledContent("Last synced", value: WFormat.relative(synced)) } diff --git a/ios/Warmbly/Features/Mailboxes/MailboxModels.swift b/ios/Warmbly/Features/Mailboxes/MailboxModels.swift index 6dc714d3..6ff33b27 100644 --- a/ios/Warmbly/Features/Mailboxes/MailboxModels.swift +++ b/ios/Warmbly/Features/Mailboxes/MailboxModels.swift @@ -526,11 +526,13 @@ enum MailboxFormat { return "\(seconds) s" } - /// Weekday bitmask (bit 0 = Monday); 0 or 127 = every day. + /// Weekday bitmask (bit 0 = Sunday, matching Go's time.Weekday in the + /// scheduler); 0 or 127 = every day. Rendered Mon-first. static func weekdays(_ mask: Int) -> String { guard mask > 0, mask < 127 else { return "every day" } - let names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - let picked = (0 ..< 7).filter { mask & (1 << $0) != 0 }.map { names[$0] } + let names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + let monFirst = [1, 2, 3, 4, 5, 6, 0] + let picked = monFirst.filter { mask & (1 << $0) != 0 }.map { names[$0] } return picked.joined(separator: " ") } } diff --git a/ios/Warmbly/Features/Mailboxes/MailboxesRootView.swift b/ios/Warmbly/Features/Mailboxes/MailboxesRootView.swift index 9f68a413..e15d49e4 100644 --- a/ios/Warmbly/Features/Mailboxes/MailboxesRootView.swift +++ b/ios/Warmbly/Features/Mailboxes/MailboxesRootView.swift @@ -1,174 +1,494 @@ import SwiftUI -/// Mailboxes screen, pushed from Home or More: fleet summary, server-side -/// search, cursor pagination, and realtime pulse reloads. No NavigationStack -/// of its own; it lives on the pushing tab's stack. +/// Mailboxes browser, presented as a full-screen cover from Home or More +/// (like the CRM browsers): a slide-in drawer (sky hero + warmup status +/// scopes with live counts + sliding tone capsule + edge swipe), a search +/// pill, bare fleet stat columns that drive the same scope, multi-select with +/// bulk warmup and remove, cursor pagination, and realtime pulse reloads. +/// Owns its NavigationStack; dismissal goes through `onClose` (the +/// environment DismissAction is unreliable in this app's cover contexts). struct MailboxesRootView: View { + var onClose: () -> Void = {} + @Environment(AppEnvironment.self) private var env @State private var store = MailboxesStore() @State private var searchText = "" + @State private var scope: MailboxScope = .all + @State private var sidebarOpen = false + @State private var sidebarDrag: CGFloat = 0 @State private var showConnect = false @State private var pendingRemove: EmailAccount? + @State private var confirmBulkRemove = false + @FocusState private var searchFocused: Bool + + private static let sidebarWidth: CGFloat = 300 private var canManage: Bool { env.session.can(.manageEmails) } private var canViewAnalytics: Bool { env.session.can(.viewAnalytics) } + private var isSearching: Bool { !searchText.trimmingCharacters(in: .whitespaces).isEmpty } + private var connectedCount: Int { store.allCount } - var body: some View { - content - .navigationTitle("Mailboxes") - .background(Color(.systemGroupedBackground)) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - showConnect = true - } label: { - Image(systemName: "plus") - } - .accessibilityLabel("Connect a mailbox") - } - } - .navigationDestination(for: EmailAccount.self) { account in - MailboxDetailView(account: account) - } - .searchable(text: $searchText, prompt: "Search mailboxes") - .task(id: searchText) { await runSearch() } - .onChange(of: env.realtime.pulse(for: .emailAccounts)) { - Task { await store.load(env.api, includeStatuses: canViewAnalytics) } - } - .onChange(of: env.realtime.pulse(for: .analytics)) { - Task { await store.loadStatuses(env.api) } - } - .sheet(isPresented: $showConnect) { MailboxConnectSheet() } - .confirmationDialog( - "Remove \(pendingRemove?.email ?? "this mailbox")?", - isPresented: Binding( - get: { pendingRemove != nil }, - set: { if !$0 { pendingRemove = nil } } - ), - titleVisibility: .visible, - presenting: pendingRemove - ) { account in - Button("Remove account", role: .destructive) { - Task { await store.deleteAccount(env.api, id: account.id) } - } - } message: { account in - Text("Sending stops immediately and \(account.email) leaves all warmup pools.") - } - .alert( - "Something went wrong", - isPresented: Binding( - get: { store.actionError != nil }, - set: { if !$0 { store.actionError = nil } } - ) - ) { - Button("OK", role: .cancel) {} - } message: { - Text(store.actionError ?? "") - } + /// The spine bumps emailAccounts and analytics together for account + /// events; one summed key avoids double reloads. + private var reloadPulse: Int { + env.realtime.pulse(for: .emailAccounts) &+ env.realtime.pulse(for: .analytics) } - // MARK: Content + /// The loaded rows narrowed to the active scope. + private var visibleAccounts: [EmailAccount] { + switch scope { + case .all: + return store.accounts + case .warming: + return store.accounts.filter(\.isWarmingActive) + case .paused: + return store.accounts.filter(\.isWarmupPaused) + case .issues: + return store.accounts.filter { store.statuses[$0.id]?.health?.hasIssue == true } + case .off: + return store.accounts.filter { $0.warmup == nil } + } + } + + var body: some View { + NavigationStack { + browser + } + } + + private var browser: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + mainPane + .scaleEffect(sidebarOpen ? 0.97 : 1, anchor: .trailing) + if sidebarOpen { + Color.black.opacity(0.32) + .ignoresSafeArea() + .transition(.opacity) + .onTapGesture { closeSidebar() } + } + drawer(topInset: geo.safeAreaInsets.top) + } + } + // Own chrome, like the other drawer browsers: the drawer hero runs to + // the top and the search pill row carries the close button. + .toolbarVisibility(.hidden, for: .navigationBar) + .navigationDestination(for: EmailAccount.self) { account in + MailboxDetailView(account: account) + } + .task(id: searchText) { await runSearch() } + .onChange(of: reloadPulse) { + Task { await store.load(env.api, includeStatuses: canViewAnalytics) } + } + .onChange(of: scope) { store.exitSelection() } + .sensoryFeedback(.selection, trigger: scope) + .sensoryFeedback(.impact(weight: .light), trigger: sidebarOpen) + .sensoryFeedback(.impact(weight: .medium), trigger: store.isSelecting) + .fullScreenCover(isPresented: $showConnect) { + MailboxConnectFlow(onClose: { showConnect = false }, onConnected: { account in + store.insert(account) + }) + } + .confirmationDialog( + "Remove \(pendingRemove?.email ?? "this mailbox")?", + isPresented: Binding( + get: { pendingRemove != nil }, + set: { if !$0 { pendingRemove = nil } } + ), + titleVisibility: .visible, + presenting: pendingRemove + ) { account in + Button("Remove account", role: .destructive) { + Task { await store.deleteAccount(env.api, id: account.id) } + } + } message: { account in + Text("Sending stops immediately and \(account.email) leaves all warmup pools.") + } + .confirmationDialog( + "Remove \(store.selectedCount) mailbox\(store.selectedCount == 1 ? "" : "es")?", + isPresented: $confirmBulkRemove, + titleVisibility: .visible + ) { + Button("Remove \(store.selectedCount)", role: .destructive) { + Task { await store.bulkRemove(env.api) } + } + } message: { + Text("This disconnects them from Warmbly.") + } + .alert( + "Something went wrong", + isPresented: Binding( + get: { store.actionError != nil }, + set: { if !$0 { store.actionError = nil } } + ) + ) { + Button("OK", role: .cancel) {} + } message: { + Text(store.actionError ?? "") + } + } + + // MARK: Main pane + + private var mainPane: some View { + VStack(spacing: 0) { + if store.isSelecting { selectionHeader } else { searchBar } + if store.hasLoaded, !isSearching, !store.accounts.isEmpty { + statsHeader + } + scopeCaption + listArea + } + .background(Color(.systemBackground)) + .overlay(alignment: .bottom) { + if store.isSelecting { + MailboxSelectionBar( + count: store.selectedCount, + onStart: { Task { await store.bulkWarmup(env.api, action: "start") } }, + onPause: { Task { await store.bulkWarmup(env.api, action: "pause") } }, + onRemove: { confirmBulkRemove = true }, + onClear: { store.exitSelection() } + ) + .padding(.bottom, 10) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .simultaneousGesture( + DragGesture(minimumDistance: 25) + .onEnded { value in + // Gmail's edge swipe: open the drawer from the left edge. + if !sidebarOpen, !store.isSelecting, value.startLocation.x < 44, value.translation.width > 70 { + openSidebar() + } + } + ) + } + + // MARK: Search pill + + private var searchBar: some View { + HStack(spacing: 10) { + HStack(spacing: 6) { + Button { + openSidebar() + } label: { + Image(systemName: "line.3.horizontal") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(.primary) + .frame(width: 38, height: 38) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Open mailboxes menu") + + TextField("Search by email", text: $searchText) + .font(.subheadline) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.search) + .focused($searchFocused) + + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear search") + } + + PresenceAvatars() + .padding(.trailing, 8) + } + .frame(height: 44) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + + if canManage { + Button { + showConnect = true + } label: { + Image(systemName: "plus") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(.primary) + .frame(width: 44, height: 44) + .background(Color(.secondarySystemBackground), in: Circle()) + } + .buttonStyle(TapScaleStyle()) + .accessibilityLabel("Connect a mailbox") + } + + Button { + onClose() + } label: { + Image(systemName: "xmark") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 44, height: 44) + .background(Color(.secondarySystemBackground), in: Circle()) + } + .buttonStyle(TapScaleStyle()) + .accessibilityLabel("Close mailboxes") + } + .padding(.horizontal, 12) + .padding(.top, 4) + .padding(.bottom, 6) + } + + // MARK: Selection header + + private var selectionHeader: some View { + let visibleIDs = visibleAccounts.map(\.id) + return HStack(spacing: 12) { + Button("Done") { store.exitSelection() } + .fontWeight(.semibold) + Spacer() + Text("\(store.selectedCount) selected") + .font(.subheadline.weight(.semibold)) + .monospacedDigit() + .contentTransition(.numericText()) + Spacer() + Button(store.allSelected(of: visibleIDs) ? "Clear" : "Select all") { + store.selectAll(visibleIDs) + } + } + .padding(.horizontal, 16) + .frame(height: 44) + .padding(.top, 4) + .padding(.bottom, 6) + } + + // MARK: Fleet stats + + /// Bare stat columns over a hairline; each drives the same scope state as + /// the drawer. + private var statsHeader: some View { + VStack(spacing: 10) { + HStack(spacing: 10) { + fleetStat("Connected", count: connectedCount, tone: nil, target: .all) + fleetStat("Warming", count: store.warmingCount, tone: store.warmingCount > 0 ? .orange : nil, target: .warming) + fleetStat( + "Issues", + count: store.issueCount, + tone: store.issueCount > 0 ? .rose : nil, + target: .issues, + enabled: canViewAnalytics + ) + } + Divider() + } + .padding(.horizontal, 20) + .padding(.top, 6) + } + + private func fleetStat(_ label: String, count: Int, tone: Tone?, target: MailboxScope, enabled: Bool = true) -> some View { + let selected = scope == target && target != .all + return Button { + withAnimation(.snappy) { scope = selected ? .all : target } + } label: { + VStack(alignment: .leading, spacing: 3) { + EyebrowLabel(label) + Text("\(count)") + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(tone?.color ?? Color.primary) + .contentTransition(.numericText()) + Capsule() + .fill(selected ? (tone?.color ?? WTheme.accent) : Color.clear) + .frame(width: 26, height: 3) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!enabled) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(label), \(count)") + .accessibilityAddTraits(selected ? .isSelected : []) + } + + // MARK: Scope caption + + private var scopeCaption: some View { + HStack(spacing: 6) { + Text(captionTitle) + .font(.caption.weight(.semibold)) + .tracking(0.9) + .foregroundStyle(.secondary) + if !isSearching, store.count(for: scope) > 0 { + Text(WFormat.compact(store.count(for: scope))) + .font(.caption.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(.tertiary) + .contentTransition(.numericText()) + } + Spacer() + if isSearching, store.hasLoaded { + Text("\(store.totalCount ?? store.accounts.count) found") + .font(.caption.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(.secondary) + .contentTransition(.numericText()) + } + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 2) + } + + private var captionTitle: String { + if isSearching { return "SEARCH RESULTS" } + return scope.title.uppercased() + } + + // MARK: List @ViewBuilder - private var content: some View { + private var listArea: some View { if !store.hasLoaded { ScrollView { SkeletonRows(rows: 10) } } else if let error = store.loadError, store.accounts.isEmpty { ErrorStateView(title: "Couldn't load mailboxes", message: error) { await store.load(env.api, includeStatuses: canViewAnalytics) } - } else if store.accounts.isEmpty { - if searchText.isEmpty { - EmptyStateView( - title: "No mailboxes yet", - message: "Connect a sender account to start warming and sending.", - ctaTitle: "Connect a mailbox" - ) { showConnect = true } - } else { - EmptyStateView( - title: "No matches", - message: "No mailbox matches \"\(searchText)\"." - ) - } + } else if visibleAccounts.isEmpty { + emptyState } else { - accountList - } - } - - private var accountList: some View { - List { - Section { - fleetSummary - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 2, leading: 20, bottom: 4, trailing: 20)) - } - Section { - ForEach(store.accounts) { account in - NavigationLink(value: account) { - MailboxRowView(account: account, status: store.statuses[account.id]) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - swipeButtons(for: account) - } + List { + ForEach(visibleAccounts) { account in + row(account) } if store.nextCursor != nil { - HStack { + HStack(spacing: 8) { Spacer() ProgressView().controlSize(.small) + Text("Loading more…") + .font(.footnote) + .foregroundStyle(.secondary) Spacer() } + .padding(.vertical, 6) .listRowSeparator(.hidden) .task { await store.loadMore(env.api) } + } else { + endMarker } } - } - .listStyle(.insetGrouped) - .refreshable { await store.load(env.api, includeStatuses: canViewAnalytics) } - } - - private var fleetSummary: some View { - HStack(spacing: 10) { - fleetStat( - value: "\(store.totalCount ?? store.accounts.count)", - label: "Connected", - symbol: "envelope.fill", - tone: .sky - ) - fleetStat( - value: "\(store.warmingCount)", - label: "Warming", - symbol: "flame.fill", - tone: store.warmingCount > 0 ? .orange : .slate - ) - fleetStat( - value: "\(store.issueCount)", - label: "Issues", - symbol: store.issueCount > 0 ? "exclamationmark.triangle.fill" : "checkmark.circle.fill", - tone: store.issueCount > 0 ? .rose : .emerald - ) + .listStyle(.plain) + .scrollDismissesKeyboard(.immediately) + .refreshable { await store.load(env.api, includeStatuses: canViewAnalytics) } } } - private func fleetStat(value: String, label: String, symbol: String, tone: Tone) -> some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Image(systemName: symbol) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(tone.color) - Text(value) - .font(.system(size: 22, weight: .bold, design: .rounded)) - .monospacedDigit() - .contentTransition(.numericText()) + @ViewBuilder + private func row(_ account: EmailAccount) -> some View { + Group { + if store.isSelecting { + Button { + store.toggleSelected(account.id) + } label: { + MailboxRowView( + account: account, + status: store.statuses[account.id], + selecting: true, + selected: store.isSelected(account.id) + ) + } + .buttonStyle(.plain) + } else { + MailboxRowView(account: account, status: store.statuses[account.id]) + .background(NavigationLink(value: account) { EmptyView() }.opacity(0)) } - Text(label) - .font(.footnote.weight(.medium)) - .foregroundStyle(.secondary) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 14) - .padding(.vertical, 12) - .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .listRowBackground(store.isSelected(account.id) ? Tone.sky.background : Color(.systemBackground)) + .listRowInsets(EdgeInsets(top: 4, leading: 14, bottom: 4, trailing: 16)) + .simultaneousGesture( + LongPressGesture(minimumDuration: 0.4).onEnded { _ in + if !store.isSelecting, canManage { store.enterSelection(with: account.id) } + } + ) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if !store.isSelecting { + swipeButtons(for: account) + } + } + } + + /// End-of-list marker: the exact total for the current scope or search. + private var endMarker: some View { + let count = scope == .all ? connectedCount : visibleAccounts.count + return HStack { + Spacer() + Text("\(count) mailbox\(count == 1 ? "" : "es")") + .font(.footnote) + .monospacedDigit() + .foregroundStyle(.tertiary) + Spacer() + } + .padding(.vertical, 10) + .listRowSeparator(.hidden) + } + + @ViewBuilder + private var emptyState: some View { + if isSearching { + EmptyStateView( + title: "No matches", + message: "No mailbox matches \"\(searchText)\"." + ) + } else { + switch scope { + case .warming: + EmptyStateView( + title: "Nothing warming", + message: "Start warmup on a mailbox and it shows up here." + ) + case .paused: + EmptyStateView( + title: "Nothing paused", + message: "Mailboxes with warmup on hold show up here." + ) + case .issues: + EmptyStateView( + title: "No issues", + message: "Every mailbox with health data looks fine right now." + ) + case .off: + EmptyStateView( + title: "Everything is warming", + message: "Mailboxes that never started warmup show up here." + ) + case .all: + connectEmptyState + } + } + } + + private var connectEmptyState: some View { + VStack(spacing: 10) { + Image(systemName: "envelope.open") + .font(.system(size: 18)) + .foregroundStyle(WTheme.accent) + .frame(width: 36, height: 36) + .background(Tone.sky.background, in: RoundedRectangle(cornerRadius: 8)) + Text("No mailboxes yet") + .font(.system(size: 14, weight: .medium)) + Text("Connect your first mailbox to start warming up and sending campaigns.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 260) + if canManage { + Button("Add mailbox") { showConnect = true } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .padding(.top, 6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 48) } @ViewBuilder @@ -207,6 +527,60 @@ struct MailboxesRootView: View { } } + // MARK: Drawer + + private func drawer(topInset: CGFloat) -> some View { + MailboxesSidebar( + store: store, + selection: scope, + topInset: topInset, + revealed: sidebarOpen + ) { newScope in + withAnimation(.spring(response: 0.38, dampingFraction: 0.8)) { scope = newScope } + // Let the highlight capsule slide to the tapped row before closing. + Task { + try? await Task.sleep(for: .milliseconds(280)) + closeSidebar() + } + } + .frame(width: Self.sidebarWidth) + .frame(maxHeight: .infinity) + .background(Color(.systemBackground)) + .clipShape(UnevenRoundedRectangle(bottomTrailingRadius: 26, topTrailingRadius: 26, style: .continuous)) + .shadow(color: .black.opacity(sidebarOpen ? 0.22 : 0), radius: 30, x: 6, y: 0) + .ignoresSafeArea() + .offset(x: drawerOffset) + .gesture( + DragGesture() + .onChanged { value in + sidebarDrag = min(0, value.translation.width) + } + .onEnded { value in + if value.translation.width < -80 || value.predictedEndTranslation.width < -160 { + closeSidebar() + } else { + withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { sidebarDrag = 0 } + } + } + ) + } + + private var drawerOffset: CGFloat { + (sidebarOpen ? 0 : -Self.sidebarWidth - 40) + sidebarDrag + } + + private func openSidebar() { + searchFocused = false + withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) { sidebarOpen = true } + } + + private func closeSidebar() { + withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) { + sidebarOpen = false + sidebarDrag = 0 + } + } + // MARK: Search + initial load private func runSearch() async { @@ -214,6 +588,10 @@ struct MailboxesRootView: View { try? await Task.sleep(for: .milliseconds(350)) if Task.isCancelled { return } } + // Searching runs server-side across the whole fleet; drop any filter. + if isSearching, scope != .all { + withAnimation(.snappy) { scope = .all } + } store.query = searchText await store.load(env.api, includeStatuses: canViewAnalytics) } @@ -224,11 +602,19 @@ struct MailboxesRootView: View { struct MailboxRowView: View { let account: EmailAccount let status: AccountAnalytics? + var selecting: Bool = false + var selected: Bool = false private var health: MailboxHealth? { status?.health } var body: some View { HStack(spacing: 12) { + if selecting { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 22)) + .foregroundStyle(selected ? WTheme.accent : Color(.tertiaryLabel)) + .transition(.scale.combined(with: .opacity)) + } WAvatar(name: account.email, seed: account.id, size: 42) .overlay(alignment: .bottomTrailing) { if account.isWarmingActive { @@ -237,7 +623,7 @@ struct MailboxRowView: View { .foregroundStyle(.white) .frame(width: 16, height: 16) .background(Tone.orange.color, in: Circle()) - .overlay(Circle().strokeBorder(Color(.secondarySystemGroupedBackground), lineWidth: 1.5)) + .overlay(Circle().strokeBorder(Color(.systemBackground), lineWidth: 1.5)) .offset(x: 3, y: 3) } } @@ -259,6 +645,7 @@ struct MailboxRowView: View { } } .padding(.vertical, 6) + .contentShape(Rectangle()) } @ViewBuilder @@ -287,3 +674,77 @@ struct MailboxRowView: View { return "warming" } } + +// MARK: - Selection bar + +/// Floating bottom-center bar shown while mailboxes are multi-selected: +/// count plus bulk warmup start/pause and remove. +struct MailboxSelectionBar: View { + let count: Int + let onStart: () -> Void + let onPause: () -> Void + let onRemove: () -> Void + let onClear: () -> Void + + var body: some View { + HStack(spacing: 8) { + Text("\(count)") + .font(.subheadline.weight(.bold)) + .monospacedDigit() + .foregroundStyle(.white) + .frame(minWidth: 26, minHeight: 26) + .background(WTheme.accent, in: Circle()) + .contentTransition(.numericText()) + + Spacer(minLength: 6) + + Button(action: onStart) { + Label("Start", systemImage: "flame.fill") + .font(.subheadline.weight(.semibold)) + } + .buttonStyle(.borderedProminent) + .tint(Tone.orange.color) + .controlSize(.small) + .disabled(count == 0) + .accessibilityLabel("Start warmup for \(count) mailboxes") + + Button(action: onPause) { + Label("Pause", systemImage: "pause.fill") + .font(.subheadline.weight(.semibold)) + } + .buttonStyle(.bordered) + .tint(WTheme.warning) + .controlSize(.small) + .disabled(count == 0) + .accessibilityLabel("Pause warmup for \(count) mailboxes") + + Button(role: .destructive, action: onRemove) { + Image(systemName: "trash") + .font(.system(size: 15, weight: .semibold)) + .frame(width: 30, height: 30) + } + .buttonStyle(.bordered) + .tint(WTheme.negative) + .controlSize(.small) + .disabled(count == 0) + .accessibilityLabel("Remove \(count) mailboxes") + + Button(action: onClear) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 30, height: 30) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear selection") + } + .padding(.leading, 12) + .padding(.trailing, 8) + .padding(.vertical, 8) + .background(.regularMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color(.separator).opacity(0.35), lineWidth: 1)) + .shadow(color: .black.opacity(0.14), radius: 14, y: 4) + .padding(.horizontal, 20) + } +} diff --git a/ios/Warmbly/Features/Mailboxes/MailboxesSidebar.swift b/ios/Warmbly/Features/Mailboxes/MailboxesSidebar.swift new file mode 100644 index 00000000..e34daae9 --- /dev/null +++ b/ios/Warmbly/Features/Mailboxes/MailboxesSidebar.swift @@ -0,0 +1,158 @@ +import SwiftUI + +/// Navigation drawer for the mailboxes browser, mirroring the contacts / +/// campaigns / unibox sidebars: a slim sky hero with live fleet totals, then +/// the warmup status scopes as pill rows on a rounded white sheet. Counts come +/// from the loaded fleet. The selected pill slides between rows in the scope's +/// tone and rows cascade in when the drawer opens. +struct MailboxesSidebar: View { + let store: MailboxesStore + let selection: MailboxScope + let topInset: CGFloat + let revealed: Bool + let onSelect: (MailboxScope) -> Void + + @Namespace private var activeNS + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + hero + ScrollView { + VStack(alignment: .leading, spacing: 2) { + sectionLabel("Status") + scopeRows + } + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 40) + } + .background { + UnevenRoundedRectangle(topLeadingRadius: 24, topTrailingRadius: 24, style: .continuous) + .fill(Color(.systemBackground)) + .shadow(color: .black.opacity(0.1), radius: 14, y: -4) + } + } + .background(alignment: .top) { + AirSkyWash().frame(height: 340) + } + .background(Color(.systemBackground)) + } + + // MARK: Hero + + private var hero: some View { + VStack(alignment: .leading, spacing: 13) { + HStack(spacing: 8) { + WarmblyLogo() + .fill(.white) + .frame(width: 21, height: 21 * (764 / 746)) + Text("Mailboxes") + .font(.system(size: 17.5, weight: .heavy)) + .tracking(-0.4) + .foregroundStyle(.white) + } + Text("Sender accounts, warmup and health") + .font(.footnote.weight(.medium)) + .foregroundStyle(.white.opacity(0.82)) + .lineLimit(1) + HStack(spacing: 6) { + heroBadge(symbol: "envelope.fill", text: "\(WFormat.compact(store.allCount)) connected") + if store.warmingCount > 0 { + heroBadge(symbol: "flame.fill", text: "\(WFormat.compact(store.warmingCount)) warming", live: true) + } + } + } + .padding(.horizontal, 20) + .padding(.top, topInset + 12) + .padding(.bottom, 16) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func heroBadge(symbol: String, text: String, live: Bool = false) -> some View { + HStack(spacing: 5) { + Image(systemName: symbol) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(.white.opacity(0.9)) + .modifier(PingEffect(active: live, color: .white)) + Text(text) + .font(.footnote.weight(.medium)) + .monospacedDigit() + .foregroundStyle(.white) + .contentTransition(.numericText()) + } + .padding(.horizontal, 10) + .padding(.vertical, 5.5) + .background(.white.opacity(0.16), in: Capsule()) + } + + private func sectionLabel(_ text: String) -> some View { + EyebrowLabel(text) + .padding(.horizontal, 14) + .padding(.top, 14) + .padding(.bottom, 6) + } + + // MARK: Rows + + @ViewBuilder + private var scopeRows: some View { + ForEach(Array(MailboxScope.allCases.enumerated()), id: \.element) { index, scope in + row(index: index, scope: scope) + } + } + + private func row(index: Int, scope: MailboxScope) -> some View { + let selected = selection == scope + let count = store.count(for: scope) + // Warming stays orange and issues stay rose even unselected, mirroring + // the stat columns; the capsule itself carries the scope's tone. + let countColor: Color? = switch scope { + case .warming: count > 0 ? Tone.orange.color : nil + case .issues: count > 0 ? Tone.rose.color : nil + default: nil + } + return Button { + onSelect(scope) + } label: { + HStack(spacing: 13) { + Image(systemName: scope.icon) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(selected ? scope.tone.color : Color.secondary) + .frame(width: 24) + Text(scope.title) + .font(.subheadline.weight(selected ? .semibold : .medium)) + .foregroundStyle(selected ? scope.tone.color : Color.primary) + .lineLimit(1) + Spacer(minLength: 8) + if count > 0 { + Text(WFormat.compact(count)) + .font(.footnote.weight(countColor != nil || selected ? .semibold : .medium)) + .monospacedDigit() + .foregroundStyle(countColor ?? (selected ? scope.tone.color : Color.secondary)) + .contentTransition(.numericText()) + } + } + .padding(.horizontal, 16) + .frame(height: 44) + .background { + if selected { + Capsule() + .fill(scope.tone.background) + .matchedGeometryEffect(id: "mailboxdrawer-active", in: activeNS) + } + } + .contentShape(Capsule()) + } + .buttonStyle(TapScaleStyle()) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(scope.title), \(count)") + .accessibilityAddTraits(selected ? .isSelected : []) + .opacity(revealed ? 1 : 0) + .offset(x: revealed ? 0 : -18) + .animation( + .spring(response: 0.42, dampingFraction: 0.82) + .delay(revealed ? 0.03 + min(Double(index), 14) * 0.024 : 0), + value: revealed + ) + } +} diff --git a/ios/Warmbly/Features/Mailboxes/MailboxesStore.swift b/ios/Warmbly/Features/Mailboxes/MailboxesStore.swift index cb96bc97..e018c635 100644 --- a/ios/Warmbly/Features/Mailboxes/MailboxesStore.swift +++ b/ios/Warmbly/Features/Mailboxes/MailboxesStore.swift @@ -1,8 +1,49 @@ import Foundation import SwiftUI +// MARK: - Scope + +/// Which slice of the fleet the list shows. Every scope is a client-side +/// filter over the loaded rows; Issues additionally needs analytics statuses. +enum MailboxScope: Hashable, CaseIterable { + case all, warming, paused, issues, off + + var title: String { + switch self { + case .all: "All mailboxes" + case .warming: "Warming" + case .paused: "Paused" + case .issues: "Issues" + case .off: "Warmup off" + } + } + + var icon: String { + switch self { + case .all: "tray.full" + case .warming: "flame.fill" + case .paused: "pause.circle" + case .issues: "exclamationmark.triangle" + case .off: "moon.zzz" + } + } + + var tone: Tone { + switch self { + case .all: .sky + case .warming: .orange + case .paused: .amber + case .issues: .rose + case .off: .slate + } + } +} + +// MARK: - Store + /// List-screen store: the mailbox rows plus the per-account health snapshot -/// from `/analytics/accounts`, merged by id. +/// from `/analytics/accounts`, merged by id. Owns multi-select and the bulk +/// warmup/remove actions behind the floating selection bar. @MainActor @Observable final class MailboxesStore { @@ -19,13 +60,68 @@ final class MailboxesStore { // MARK: Derived stats + var allCount: Int { totalCount ?? accounts.count } + /// Warming = warmup anchor set and not paused; never a status string. var warmingCount: Int { accounts.filter(\.isWarmingActive).count } + var pausedCount: Int { accounts.filter(\.isWarmupPaused).count } + var issueCount: Int { accounts.filter { statuses[$0.id]?.health?.hasIssue == true }.count } + /// Warmup never enabled: no ramp anchor at all. + var offCount: Int { accounts.filter { $0.warmup == nil }.count } + + func count(for scope: MailboxScope) -> Int { + switch scope { + case .all: allCount + case .warming: warmingCount + case .paused: pausedCount + case .issues: issueCount + case .off: offCount + } + } + + // MARK: Selection + + private(set) var selectedIDs: Set = [] + var isSelecting = false + + var selectedCount: Int { selectedIDs.count } + + func isSelected(_ id: String) -> Bool { selectedIDs.contains(id) } + + func toggleSelected(_ id: String) { + if selectedIDs.contains(id) { selectedIDs.remove(id) } else { selectedIDs.insert(id) } + } + + func enterSelection(with id: String? = nil) { + withAnimation(.snappy) { + isSelecting = true + if let id { selectedIDs.insert(id) } + } + } + + func exitSelection() { + withAnimation(.snappy) { + isSelecting = false + selectedIDs.removeAll() + } + } + + /// Toggle select-all over the rows currently visible in the list. + func selectAll(_ ids: [String]) { + withAnimation(.snappy) { + if allSelected(of: ids) { selectedIDs.removeAll() } else { selectedIDs = Set(ids) } + } + } + + func allSelected(of ids: [String]) -> Bool { + !ids.isEmpty && ids.allSatisfy { selectedIDs.contains($0) } + } + // MARK: Loading func load(_ api: APIClient, includeStatuses: Bool) async { @@ -88,6 +184,18 @@ final class MailboxesStore { return params } + /// Optimistic insert from the connect flow; realtime refreshes the rest. + func insert(_ account: EmailAccount) { + withAnimation { + if let index = accounts.firstIndex(where: { $0.id == account.id }) { + accounts[index] = account + } else { + accounts.insert(account, at: 0) + if let total = totalCount { totalCount = total + 1 } + } + } + } + // MARK: Row actions /// action: "start" | "pause" | "resume" | "stop"; returns the updated row. @@ -114,4 +222,50 @@ final class MailboxesStore { actionError = error.localizedDescription } } + + // MARK: Bulk actions + + /// action: "start" | "pause". Applies per-row results as they land and + /// reports partial failures in one line. + func bulkWarmup(_ api: APIClient, action: String) async { + let ids = Array(selectedIDs) + guard !ids.isEmpty else { return } + var failures = 0 + for id in ids { + do { + let updated: EmailAccount = try await api.post("emails/\(id)/warmup/\(action)") + if let index = accounts.firstIndex(where: { $0.id == id }) { + withAnimation { accounts[index] = updated } + } + } catch { + failures += 1 + } + } + if failures > 0 { + actionError = "\(failures) of \(ids.count) mailboxes couldn't \(action)" + } + exitSelection() + } + + func bulkRemove(_ api: APIClient) async { + let ids = Array(selectedIDs) + guard !ids.isEmpty else { return } + var failures = 0 + for id in ids { + do { + let _: EmptyBody = try await api.delete("emails/\(id)") + withAnimation { + accounts.removeAll { $0.id == id } + statuses.removeValue(forKey: id) + if let total = totalCount { totalCount = max(0, total - 1) } + } + } catch { + failures += 1 + } + } + if failures > 0 { + actionError = "\(failures) of \(ids.count) mailboxes couldn't be removed" + } + exitSelection() + } }