Skip to content

Commit 85205c4

Browse files
Merge pull request #432 from woocommerce/issue/19-mark-as-read-for-multiple-notes
Notifications: Mark All as Read
2 parents e02b516 + f8d3c5d commit 85205c4

File tree

7 files changed

+209
-40
lines changed

7 files changed

+209
-40
lines changed

Networking/Networking/Remote/NotificationsRemote.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,26 @@ public class NotificationsRemote: Remote {
4343
/// - read: The new Read Status to be set.
4444
/// - completion: Closure to be executed on completion, indicating whether the OP was successful or not.
4545
///
46-
public func updateReadStatus(_ notificationID: String, read: Bool, completion: @escaping (Error?) -> Void) {
46+
public func updateReadStatus(noteIds: [Int64], read: Bool, completion: @escaping (Error?) -> Void) {
4747
// Note: Isn't the API wonderful?
48+
//
4849
let booleanFromPlanetMars = read ? Constants.readAsInteger : Constants.unreadAsInteger
49-
let payload = [notificationID: booleanFromPlanetMars]
5050

51-
var parameters = [String: String]()
52-
if let encoded = payload.toJSONEncoded() {
53-
parameters[ParameterKeys.counts] = encoded
51+
// Payload: [NoteID: ReadStatus]
52+
//
53+
var payload = [String: Int]()
54+
55+
for noteId in noteIds {
56+
let noteIdAsString = String(noteId)
57+
payload[noteIdAsString] = booleanFromPlanetMars
5458
}
5559

60+
// Parameters: [.counts: [Payload]]
61+
//
62+
let parameters: [String: Any] = [
63+
ParameterKeys.counts: payload
64+
]
65+
5666
let request = DotcomRequest(wordpressApiVersion: .mark1_1, method: .post, path: Paths.read, parameters: parameters)
5767
let mapper = SuccessResultMapper()
5868

Networking/Networking/Requests/DotcomRequest.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ struct DotcomRequest: URLRequestConvertible {
2424

2525
/// Parameters
2626
///
27-
let parameters: [String: String]?
27+
let parameters: [String: Any]?
2828

2929

3030
/// Designated Initializer.
@@ -35,7 +35,7 @@ struct DotcomRequest: URLRequestConvertible {
3535
/// - path: RPC that should be executed.
3636
/// - parameters: Collection of String parameters to be passed over to our target RPC.
3737
///
38-
init(wordpressApiVersion: WordPressAPIVersion, method: HTTPMethod, path: String, parameters: [String: String]? = nil) {
38+
init(wordpressApiVersion: WordPressAPIVersion, method: HTTPMethod, path: String, parameters: [String: Any]? = nil) {
3939
self.wordpressApiVersion = wordpressApiVersion
4040
self.method = method
4141
self.path = path

Networking/NetworkingTests/Remote/NotificationsRemoteTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ class NotificationsRemoteTests: XCTestCase {
114114

115115
network.simulateResponse(requestUrlSuffix: "notifications/read", filename: "generic_error")
116116

117-
remote.updateReadStatus("", read: true) { error in
117+
remote.updateReadStatus(noteIds: [], read: true) { error in
118118
let error = error as? DotcomError
119119
XCTAssertEqual(error?.code, "unknown_token")
120120
expectation.fulfill()
@@ -131,7 +131,7 @@ class NotificationsRemoteTests: XCTestCase {
131131

132132
network.simulateResponse(requestUrlSuffix: "notifications/read", filename: "generic_success")
133133

134-
remote.updateReadStatus("", read: true) { error in
134+
remote.updateReadStatus(noteIds: [], read: true) { error in
135135
XCTAssertNil(error)
136136
expectation.fulfill()
137137
}

WooCommerce/Classes/ViewRelated/Notifications/NotificationsViewController.swift

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Gridicons
33
import Yosemite
44
import WordPressUI
55
import SafariServices
6+
import Gridicons
67

78

89
// MARK: - NotificationsViewController
@@ -13,6 +14,10 @@ class NotificationsViewController: UIViewController {
1314
///
1415
@IBOutlet private var tableView: UITableView!
1516

17+
/// Haptic Feedback!
18+
///
19+
private let hapticGenerator = UINotificationFeedbackGenerator()
20+
1621
/// ResultsController: Surrounds us. Binds the galaxy together. And also, keeps the UITableView <> (Stored) Notes in sync.
1722
///
1823
private lazy var resultsController: ResultsController<StorageNote> = {
@@ -80,6 +85,7 @@ class NotificationsViewController: UIViewController {
8085
view.backgroundColor = StyleManager.tableViewBackgroundColor
8186

8287
configureNavigationItem()
88+
configureNavigationBarButtons()
8389
configureTableView()
8490
configureTableViewCells()
8591
configureResultsController()
@@ -111,6 +117,20 @@ private extension NotificationsViewController {
111117
navigationItem.backBarButtonItem = UIBarButtonItem(title: String(), style: .plain, target: nil, action: nil)
112118
}
113119

120+
/// Setup: NavigationBar Buttons
121+
///
122+
func configureNavigationBarButtons() {
123+
let rightBarButton = UIBarButtonItem(image: Gridicon.iconOfType(.menus),
124+
style: .plain,
125+
target: self,
126+
action: #selector(markAllAsRead))
127+
rightBarButton.tintColor = .white
128+
rightBarButton.accessibilityTraits = .button
129+
rightBarButton.accessibilityLabel = NSLocalizedString("Mark All as Read", comment: "Accessibility label for the Mark All Notifications as Read Button")
130+
rightBarButton.accessibilityHint = NSLocalizedString("Marks Every Notification as Read", comment: "VoiceOver accessibility hint for the Mark All Notifications as Read Action")
131+
navigationItem.rightBarButtonItem = rightBarButton
132+
}
133+
114134
/// Setup: TableView
115135
///
116136
func configureTableView() {
@@ -149,13 +169,37 @@ private extension NotificationsViewController {
149169
sender.endRefreshing()
150170
}
151171
}
172+
173+
@IBAction func markAllAsRead() {
174+
let unread = resultsController.fetchedObjects.filter { $0.read == false }
175+
if unread.isEmpty {
176+
DDLogVerbose("# Every single notification is already marked as Read!")
177+
return
178+
}
179+
180+
markAsRead(notes: unread)
181+
hapticGenerator.notificationOccurred(.success)
182+
}
152183
}
153184

154185

155-
// MARK: - Sync'ing Helpers
186+
// MARK: - Yosemite Wrappers
156187
//
157188
private extension NotificationsViewController {
158189

190+
/// Marks the specified collection of Notifications as Read.
191+
///
192+
func markAsRead(notes: [Note]) {
193+
let identifiers = notes.map { $0.noteId }
194+
let action = NotificationAction.updateMultipleReadStatus(noteIds: identifiers, read: true) { error in
195+
if let error = error {
196+
DDLogError("⛔️ Error marking notifications as read: \(error)")
197+
}
198+
}
199+
200+
StoresManager.shared.dispatch(action)
201+
}
202+
159203
/// Synchronizes the Notifications associated to the active WordPress.com account.
160204
///
161205
func synchronizeNotifications(onCompletion: (() -> Void)? = nil) {

Yosemite/Yosemite/Actions/NotificationAction.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ public enum NotificationAction: Action {
99
case synchronizeNotifications(onCompletion: (Error?) -> Void)
1010
case synchronizeNotification(noteId: Int64, onCompletion: (Error?) -> Void)
1111
case updateLastSeen(timestamp: String, onCompletion: (Error?) -> Void)
12-
case updateReadStatus(noteID: Int64, read: Bool, onCompletion: (Error?) -> Void)
12+
case updateReadStatus(noteId: Int64, read: Bool, onCompletion: (Error?) -> Void)
13+
case updateMultipleReadStatus(noteIds: [Int64], read: Bool, onCompletion: (Error?) -> Void)
1314
}

Yosemite/Yosemite/Stores/NotificationStore.swift

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ public class NotificationStore: Store {
3636
synchronizeNotification(with: noteId, onCompletion: onCompletion)
3737
case .updateLastSeen(let timestamp, let onCompletion):
3838
updateLastSeen(timestamp: timestamp, onCompletion: onCompletion)
39-
case .updateReadStatus(let noteID, let read, let onCompletion):
40-
updateReadStatus(noteID: noteID, read: read, onCompletion: onCompletion)
39+
case .updateReadStatus(let noteId, let read, let onCompletion):
40+
updateReadStatus(for: [noteId], read: read, onCompletion: onCompletion)
41+
case .updateMultipleReadStatus(let noteIds, let read, let onCompletion):
42+
updateReadStatus(for: noteIds, read: read, onCompletion: onCompletion)
4143
}
4244
}
4345
}
@@ -116,18 +118,34 @@ private extension NotificationStore {
116118
}
117119

118120

119-
/// Updates the read status for the given notification ID
121+
/// Updates the read status for the given notification ID(s)
120122
///
121-
func updateReadStatus(noteID: Int64, read: Bool, onCompletion: @escaping (Error?) -> Void) {
123+
func updateReadStatus(for noteIds: [Int64], read: Bool, onCompletion: @escaping (Error?) -> Void) {
124+
/// Optimistically Update
125+
///
126+
updateLocalNoteReadStatus(for: noteIds, read: read)
127+
128+
/// On error we'll just mark the Note for Refresh
129+
///
122130
let remote = NotificationsRemote(network: network)
123-
remote.updateReadStatus(String(noteID), read: read) { [weak self] (error) in
124-
guard let `self` = self, error == nil else {
125-
onCompletion(error)
131+
remote.updateReadStatus(noteIds: noteIds, read: read) { error in
132+
guard let error = error else {
133+
134+
/// What is this about:
135+
/// Notice that there are few conditions in which the Network Request's callback may run *before*
136+
/// the Optimisitc Update, such as in Unit Tests.
137+
/// This may cause the Callback to run before the Read Flag has been toggled, which isn't cool.
138+
/// *FORGIVE ME*, this is a workaround: the onCompletion closure must run after a No-OP in the derived
139+
/// storage.
140+
///
141+
self.performSharedDerivedStorageNoOp {
142+
onCompletion(nil)
143+
}
126144
return
127145
}
128146

129-
self.updateLocalNoteReadStatus(for: noteID, read: read) {
130-
onCompletion(nil)
147+
self.invalidateCache(for: noteIds) {
148+
onCompletion(error)
131149
}
132150
}
133151
}
@@ -169,7 +187,7 @@ extension NotificationStore {
169187
/// - remoteNotes: Collection of Notes
170188
/// - completion: Callback to be executed on completion
171189
///
172-
func updateLocalNotes(with remoteNotes: [Note], completion: (() -> Void)? = nil) {
190+
func updateLocalNotes(with remoteNotes: [Note], onCompletion: (() -> Void)? = nil) {
173191
let derivedStorage = type(of: self).sharedDerivedStorage(with: storageManager)
174192

175193
derivedStorage.perform {
@@ -180,26 +198,33 @@ extension NotificationStore {
180198
}
181199

182200
storageManager.saveDerivedType(derivedStorage: derivedStorage) {
183-
DispatchQueue.main.async {
184-
completion?()
201+
guard let onCompletion = onCompletion else {
202+
return
185203
}
204+
205+
DispatchQueue.main.async(execute: onCompletion)
186206
}
187207
}
188208

189-
/// Updates the read status for the given noteID in the Storage layer. If the local note to update cannot be found,
190-
/// nothing is updated/created in storage.
209+
/// Updates the read status for the specified Notifications. The callback happens on the Main Thread.
191210
///
192-
func updateLocalNoteReadStatus(for noteID: Int64, read: Bool, completion: (() -> Void)? = nil) {
193-
assert(Thread.isMainThread)
194-
let storage = storageManager.viewStorage
195-
guard let storageNote = storage.loadNotification(noteID: noteID) else {
196-
completion?()
197-
return
211+
func updateLocalNoteReadStatus(for noteIDs: [Int64], read: Bool, onCompletion: (() -> Void)? = nil) {
212+
let derivedStorage = type(of: self).sharedDerivedStorage(with: storageManager)
213+
214+
derivedStorage.perform {
215+
let notifications = noteIDs.compactMap { derivedStorage.loadNotification(noteID: $0) }
216+
for note in notifications {
217+
note.read = read
218+
}
198219
}
199220

200-
storageNote.read = read
201-
storage.saveIfNeeded()
202-
completion?()
221+
storageManager.saveDerivedType(derivedStorage: derivedStorage) {
222+
guard let onCompletion = onCompletion else {
223+
return
224+
}
225+
226+
DispatchQueue.main.async(execute: onCompletion)
227+
}
203228
}
204229

205230
/// Given a collection of NoteHash Entities, this method will determine the `.noteID`'s of those entities that
@@ -229,6 +254,37 @@ extension NotificationStore {
229254
}
230255
}
231256
}
257+
258+
/// Invalidates the Hash for the specified Notifications.
259+
///
260+
func invalidateCache(for noteIds: [Int64], onCompletion: (() -> Void)? = nil) {
261+
let derivedStorage = type(of: self).sharedDerivedStorage(with: storageManager)
262+
263+
derivedStorage.perform {
264+
let notifications = noteIds.compactMap { derivedStorage.loadNotification(noteID: $0) }
265+
for note in notifications {
266+
note.noteHash = Int64.min
267+
}
268+
}
269+
270+
storageManager.saveDerivedType(derivedStorage: derivedStorage) {
271+
guard let onCompletion = onCompletion else {
272+
return
273+
}
274+
275+
DispatchQueue.main.async(execute: onCompletion)
276+
}
277+
}
278+
279+
/// Runs a No-OP in the Shared Derived Storage. On completion, the callback will be executed on the main thread.
280+
///
281+
func performSharedDerivedStorageNoOp(onCompletion: @escaping () -> Void) {
282+
let derivedStorage = type(of: self).sharedDerivedStorage(with: storageManager)
283+
284+
derivedStorage.perform {
285+
DispatchQueue.main.async(execute: onCompletion)
286+
}
287+
}
232288
}
233289

234290

0 commit comments

Comments
 (0)