Skip to content

Commit c6cb510

Browse files
authored
Add a banner to ask user to validate their email (#24172)
* Add a banner to ask user to validate their email * Address PR comments * Replace Spacer() with .frame * Update verify email cell UI * Update variables * Update UI to be similar to the web * Fix typos
1 parent 82f1682 commit c6cb510

File tree

2 files changed

+204
-4
lines changed

2 files changed

+204
-4
lines changed

WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift

+18-4
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class MeViewController: UITableViewController {
3737
}
3838

3939
ImmuTable.registerRows([
40+
VerifyEmailRow.self,
4041
NavigationItemRow.self,
4142
IndicatorNavigationItemRow.self,
4243
ButtonRow.self,
@@ -47,6 +48,7 @@ class MeViewController: UITableViewController {
4748
WPStyleGuide.configureAutomaticHeightRows(for: tableView)
4849

4950
NotificationCenter.default.addObserver(self, selector: #selector(MeViewController.accountDidChange), name: NSNotification.Name.WPAccountDefaultWordPressComAccountChanged, object: nil)
51+
NotificationCenter.default.addObserver(self, selector: #selector(MeViewController.refreshAccountDetailsAndSettings), name: UIApplication.didBecomeActiveNotification, object: nil)
5052

5153
WPStyleGuide.configureColors(view: view, tableView: tableView)
5254
tableView.accessibilityIdentifier = "Me Table"
@@ -114,9 +116,13 @@ class MeViewController: UITableViewController {
114116

115117
fileprivate func tableViewModel(with account: WPAccount?) -> ImmuTable {
116118
let accessoryType: UITableViewCell.AccessoryType = .disclosureIndicator
117-
118119
let loggedIn = account != nil
119120

121+
var verificationSection: ImmuTableSection?
122+
if let account, account.verificationStatus == .unverified {
123+
verificationSection = ImmuTableSection(rows: [VerifyEmailRow()])
124+
}
125+
120126
let myProfile = NavigationItemRow(
121127
title: RowTitles.myProfile,
122128
icon: UIImage(named: "site-menu-people")?.withRenderingMode(.alwaysTemplate),
@@ -162,7 +168,14 @@ class MeViewController: UITableViewController {
162168

163169
let shouldShowQRLoginRow = FeatureFlag.qrCodeLogin.enabled && !(account?.settings?.twoStepEnabled ?? false)
164170

165-
var sections: [ImmuTableSection] = [
171+
var sections: [ImmuTableSection] = []
172+
173+
// Add verification section first if it exists
174+
if let verificationSection {
175+
sections.append(verificationSection)
176+
}
177+
178+
sections.append(contentsOf: [
166179
ImmuTableSection(rows: {
167180
var rows: [ImmuTableRow] = [appSettingsRow]
168181
if loggedIn {
@@ -176,7 +189,7 @@ class MeViewController: UITableViewController {
176189
return rows
177190
}()),
178191
ImmuTableSection(rows: [helpAndSupportIndicator]),
179-
]
192+
])
180193

181194
#if IS_JETPACK
182195
if RemoteFeatureFlag.domainManagement.enabled() && loggedIn && !isSidebarModeEnabled {
@@ -405,6 +418,7 @@ class MeViewController: UITableViewController {
405418
}
406419
return false
407420
}
421+
408422
guard let sections = handler?.viewModel.sections,
409423
let section = sections.firstIndex(where: { $0.rows.contains(where: matchRow) }),
410424
let row = sections[section].rows.firstIndex(where: matchRow) else {
@@ -432,7 +446,7 @@ class MeViewController: UITableViewController {
432446
return try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext)
433447
}
434448

435-
fileprivate func refreshAccountDetailsAndSettings() {
449+
@objc fileprivate func refreshAccountDetailsAndSettings() {
436450
guard let account = defaultAccount(), let api = account.wordPressComRestApi else {
437451
reloadViewModel()
438452
return
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import Foundation
2+
import UIKit
3+
import SwiftUI
4+
import WordPressUI
5+
import Combine
6+
7+
final class VerifyEmailRow: ImmuTableRow {
8+
static let cell = ImmuTableCell.class(VerifyEmailCell.self)
9+
let action: ImmuTableAction? = nil
10+
11+
func configureCell(_ cell: UITableViewCell) {
12+
// Do nothing.
13+
}
14+
}
15+
16+
final class VerifyEmailCell: UITableViewCell {
17+
private let hostingView: UIHostingView<VerifyEmailView>
18+
19+
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
20+
hostingView = .init(view: .init())
21+
super.init(style: style, reuseIdentifier: reuseIdentifier)
22+
setupView()
23+
}
24+
25+
required init?(coder: NSCoder) {
26+
fatalError("init(coder:) has not been implemented")
27+
}
28+
29+
private func setupView() {
30+
selectionStyle = .none
31+
32+
contentView.addSubview(hostingView)
33+
hostingView.pinEdges(to: contentView)
34+
}
35+
}
36+
37+
private struct VerifyEmailView: View {
38+
@StateObject private var viewModel = VerifyEmailViewModel()
39+
40+
var body: some View {
41+
VStack(alignment: .leading, spacing: 8) {
42+
HStack {
43+
Image(systemName: "envelope.circle.fill")
44+
Text(Strings.verifyEmailTitle)
45+
}
46+
.foregroundStyle(Color(uiColor: #colorLiteral(red: 0.8392476439, green: 0.2103677094, blue: 0.2182099223, alpha: 1)))
47+
.font(.subheadline.weight(.semibold))
48+
49+
Text(viewModel.state.message)
50+
.font(.callout)
51+
.foregroundStyle(.primary)
52+
53+
Spacer()
54+
55+
Button {
56+
viewModel.sendVerificationEmail()
57+
} label: {
58+
HStack {
59+
if viewModel.state.showsActivityIndicator {
60+
ProgressView()
61+
.progressViewStyle(CircularProgressViewStyle())
62+
}
63+
64+
Text(viewModel.state.buttonTitle)
65+
.font(.callout)
66+
}
67+
}
68+
.buttonStyle(.borderless)
69+
.disabled(!viewModel.state.isButtonEnabled)
70+
}
71+
.padding()
72+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
73+
}
74+
}
75+
76+
// This value is not an actual "timeout" value of the verification link. It's just an arbitrary value to prevent
77+
// users from sending links repeatedly.
78+
private let verificationLinkTimeout: TimeInterval = 300
79+
80+
@MainActor
81+
private class VerifyEmailViewModel: ObservableObject {
82+
enum State {
83+
case needsVerification
84+
case sending
85+
case sent(Date)
86+
case error(Error)
87+
88+
var message: String {
89+
let email = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext)?.email ?? ""
90+
91+
switch self {
92+
case .needsVerification, .sending, .sent:
93+
if let email, !email.isEmpty {
94+
return String(format: Strings.verifyMessage, email, Strings.sendButton)
95+
} else {
96+
return String(format: Strings.verifyMessageNoEmail, Strings.sendButton)
97+
}
98+
case .error(let error):
99+
return error.localizedDescription
100+
}
101+
}
102+
103+
var buttonTitle: String {
104+
switch self {
105+
case .needsVerification:
106+
return Strings.sendButton
107+
case .sending:
108+
return Strings.sendingButton
109+
case .sent:
110+
return Strings.sentButton
111+
case .error:
112+
return Strings.retryButton
113+
}
114+
}
115+
116+
var isButtonEnabled: Bool {
117+
switch self {
118+
case .needsVerification, .error: return true
119+
case .sending: return false
120+
case .sent(let date):
121+
return Date().timeIntervalSince(date) >= verificationLinkTimeout
122+
}
123+
}
124+
125+
var showsActivityIndicator: Bool {
126+
if case .sending = self {
127+
return true
128+
}
129+
return false
130+
}
131+
}
132+
133+
private let userID: NSNumber
134+
135+
private var lastVerificationSentDate: Date? {
136+
get {
137+
let key = "LastEmailVerificationSentDate-\(userID)"
138+
return UserDefaults.standard.object(forKey: key) as? Date
139+
}
140+
set {
141+
let key = "LastEmailVerificationSentDate-\(userID)"
142+
UserDefaults.standard.set(newValue, forKey: key)
143+
}
144+
}
145+
146+
@Published private(set) var state: State
147+
148+
init() {
149+
userID = (try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext)?.userID) ?? 0
150+
state = .needsVerification
151+
152+
if let sentDate = lastVerificationSentDate,
153+
Date().timeIntervalSince(sentDate) < verificationLinkTimeout {
154+
state = .sent(sentDate)
155+
}
156+
}
157+
158+
func sendVerificationEmail() {
159+
guard state.isButtonEnabled else { return }
160+
161+
state = .sending
162+
163+
let accountService = AccountService(coreDataStack: ContextManager.shared)
164+
accountService.requestVerificationEmail({ [weak self] in
165+
Task { @MainActor [weak self] in
166+
guard let self else { return }
167+
self.lastVerificationSentDate = Date()
168+
self.state = .sent(Date())
169+
}
170+
}, failure: { [weak self] error in
171+
Task { @MainActor [weak self] in
172+
self?.state = .error(error)
173+
}
174+
})
175+
}
176+
}
177+
178+
private enum Strings {
179+
static let verifyEmailTitle = NSLocalizedString("me.verifyEmail.title", value: "Verify Your Email", comment: "Title for email verification card")
180+
static let verifyMessage = NSLocalizedString("me.verifyEmail.message.withEmail", value: "Verify your email to secure your account and access more features.\nCheck your inbox at %@ for the confirmation email, or click '%@' to get a new one.", comment: "Message for email verification card with email address")
181+
static let verifyMessageNoEmail = NSLocalizedString("me.verifyEmail.message.noEmail", value: "Verify your email to secure your account and access more features.\nCheck your inbox for the confirmation email, or click '%@' to get a new one..", comment: "Message for email verification card")
182+
static let sendButton = NSLocalizedString("me.verifyEmail.button.send", value: "Resend email", comment: "Button title to send verification link")
183+
static let sendingButton = NSLocalizedString("me.verifyEmail.button.sending", value: "Sending...", comment: "Button title while verification link is being sent")
184+
static let sentButton = NSLocalizedString("me.verifyEmail.button.sent", value: "Email sent", comment: "Button title after verification link is sent")
185+
static let retryButton = NSLocalizedString("me.verifyEmail.button.retry", value: "Try Again", comment: "Button title when verification link sending failed")
186+
}

0 commit comments

Comments
 (0)