-
Notifications
You must be signed in to change notification settings - Fork 1.1k
/
Copy pathNotificationDetailsViewController.swift
1433 lines (1174 loc) · 53.9 KB
/
NotificationDetailsViewController.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import Foundation
import CoreData
import Gridicons
import SVProgressHUD
import WordPressShared
import WordPressUI
///
///
protocol NotificationsNavigationDataSource: AnyObject {
func notification(succeeding note: Notification) -> Notification?
func notification(preceding note: Notification) -> Notification?
}
// MARK: - Renders a given Notification entity, onscreen
//
class NotificationDetailsViewController: UIViewController, NoResultsViewHost {
// MARK: - Properties
let formatter = FormattableContentFormatter()
/// StackView: Top-Level Entity
///
@IBOutlet var stackView: UIStackView!
/// TableView
///
@IBOutlet var tableView: UITableView!
/// Pins the StackView to the top of the view (Relationship: = 0)
///
@IBOutlet var topLayoutConstraint: NSLayoutConstraint!
/// Pins the StackView to the bottom of the view (Relationship: = 0)
///
@IBOutlet var bottomLayoutConstraint: NSLayoutConstraint!
/// Pins the StackView to the top of the view (Relationship: >= 0)
///
@IBOutlet var badgeTopLayoutConstraint: NSLayoutConstraint!
/// Pins the StackView to the bottom of the view (Relationship: >= 0)
///
@IBOutlet var badgeBottomLayoutConstraint: NSLayoutConstraint!
/// Pins the StackView at the center of the view
///
@IBOutlet var badgeCenterLayoutConstraint: NSLayoutConstraint!
/// RelpyTextView
///
@IBOutlet var replyTextView: ReplyTextView!
/// Reply Suggestions
///
@IBOutlet var suggestionsTableView: SuggestionsTableView?
/// Embedded Media Downloader
///
fileprivate var mediaDownloader = NotificationMediaDownloader()
/// Keyboard Manager: Aids in the Interactive Dismiss Gesture
///
fileprivate var keyboardManager: KeyboardDismissHelper?
/// Cached values used for returning the estimated row heights of autosizing cells.
///
fileprivate let estimatedRowHeightsCache = NSCache<AnyObject, AnyObject>()
/// Previous NavBar Navigation Button
///
var previousNavigationButton: UIButton!
/// Next NavBar Navigation Button
///
var nextNavigationButton: UIButton!
/// Arrows Navigation Datasource
///
weak var dataSource: NotificationsNavigationDataSource?
/// Used to present CommentDetailViewController when previous/next notification is a Comment.
///
weak var notificationCommentDetailCoordinator: NotificationCommentDetailCoordinator?
/// Notification being displayed
///
var note: Notification! {
didSet {
guard oldValue != note && isViewLoaded else {
return
}
confettiWasShown = false
router = makeRouter()
setupTableDelegates()
refreshInterface()
markAsReadIfNeeded()
}
}
/// Whether a confetti animation was presented on this notification or not
///
private var confettiWasShown = false
lazy var coordinator: ContentCoordinator = {
return DefaultContentCoordinator(controller: self, context: mainContext)
}()
lazy var router: NotificationContentRouter = {
return makeRouter()
}()
/// Whenever the user performs a destructive action, the Deletion Request Callback will be called,
/// and a closure that will effectively perform the deletion action will be passed over.
/// In turn, the Deletion Action block also expects (yet another) callback as a parameter, to be called
/// in the eventuallity of a failure.
///
var onDeletionRequestCallback: ((NotificationDeletionRequest) -> Void)?
/// Closure to be executed whenever the notification that's being currently displayed, changes.
/// This happens due to Navigation Events (Next / Previous)
///
var onSelectedNoteChange: ((Notification) -> Void)?
var likesListController: LikesListController?
deinit {
// Failsafe: Manually nuke the tableView dataSource and delegate. Make sure not to force a loadView event!
guard isViewLoaded else {
return
}
tableView.delegate = nil
tableView.dataSource = nil
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
restorationClass = type(of: self)
}
override func viewDidLoad() {
super.viewDidLoad()
setupNavigationBar()
setupMainView()
setupTableView()
setupTableViewCells()
setupTableDelegates()
setupReplyTextView()
setupSuggestionsView()
setupKeyboardManager()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
tableView.deselectSelectedRowWithAnimation(true)
keyboardManager?.startListeningToKeyboardNotifications()
refreshInterface()
markAsReadIfNeeded()
setupNotificationListeners()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
showConfettiIfNeeded()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
keyboardManager?.stopListeningToKeyboardNotifications()
tearDownNotificationListeners()
storeNotificationReplyIfNeeded()
dismissNotice()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { context in
self.refreshInterfaceIfNeeded()
})
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
refreshNavigationBar()
adjustLayoutConstraintsIfNeeded()
}
private func makeRouter() -> NotificationContentRouter {
return NotificationContentRouter(activity: note, coordinator: coordinator)
}
fileprivate func markAsReadIfNeeded() {
guard !note.read else {
return
}
NotificationSyncMediator()?.markAsRead(note)
}
fileprivate func refreshInterfaceIfNeeded() {
guard isViewLoaded else {
return
}
refreshInterface()
}
fileprivate func refreshInterface() {
formatter.resetCache()
tableView.reloadData()
attachReplyViewIfNeeded()
attachSuggestionsViewIfNeeded()
adjustLayoutConstraintsIfNeeded()
refreshNavigationBar()
}
fileprivate func refreshNavigationBar() {
title = note.title
if splitViewControllerIsHorizontallyCompact {
enableNavigationRightBarButtonItems()
} else {
navigationItem.rightBarButtonItems = nil
}
}
fileprivate func enableNavigationRightBarButtonItems() {
// https://github.com/wordpress-mobile/WordPress-iOS/issues/6662#issue-207316186
let buttonSize = CGFloat(24)
let buttonSpacing = CGFloat(12)
let width = buttonSize + buttonSpacing + buttonSize
let height = buttonSize
let buttons = UIStackView(arrangedSubviews: [nextNavigationButton, previousNavigationButton])
buttons.axis = .horizontal
buttons.spacing = buttonSpacing
buttons.frame = CGRect(x: 0, y: 0, width: width, height: height)
UIView.performWithoutAnimation {
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: buttons)
}
previousNavigationButton.isEnabled = shouldEnablePreviousButton
nextNavigationButton.isEnabled = shouldEnableNextButton
previousNavigationButton.accessibilityLabel = NSLocalizedString("Previous notification", comment: "Accessibility label for the previous notification button")
nextNavigationButton.accessibilityLabel = NSLocalizedString("Next notification", comment: "Accessibility label for the next notification button")
}
}
// MARK: - State Restoration
//
extension NotificationDetailsViewController: UIViewControllerRestoration {
class func viewController(withRestorationIdentifierPath identifierComponents: [String],
coder: NSCoder) -> UIViewController? {
let context = AppEnvironment.current.mainContext
guard let noteURI = coder.decodeObject(forKey: Restoration.noteIdKey) as? URL,
let objectID = context.persistentStoreCoordinator?.managedObjectID(forURIRepresentation: noteURI) else {
return nil
}
let notification = try? context.existingObject(with: objectID)
guard let restoredNotification = notification as? Notification else {
return nil
}
let storyboard = coder.decodeObject(forKey: UIApplication.stateRestorationViewControllerStoryboardKey) as? UIStoryboard
guard let vc = storyboard?.instantiateViewController(withIdentifier: Restoration.restorationIdentifier) as? NotificationDetailsViewController else {
return nil
}
vc.note = restoredNotification
return vc
}
override func encodeRestorableState(with coder: NSCoder) {
super.encodeRestorableState(with: coder)
coder.encode(note.objectID.uriRepresentation(), forKey: Restoration.noteIdKey)
}
}
// MARK: - UITableView Methods
//
extension NotificationDetailsViewController: UITableViewDelegate, UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return Settings.numberOfSections
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return note.headerAndBodyContentGroups.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let group = contentGroup(for: indexPath)
let reuseIdentifier = reuseIdentifierForGroup(group)
guard let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier, for: indexPath) as? NoteBlockTableViewCell else {
DDLogError("Failed dequeueing NoteBlockTableViewCell.")
return UITableViewCell()
}
setup(cell, withContentGroupAt: indexPath)
return cell
}
func setup(_ cell: NoteBlockTableViewCell, withContentGroupAt indexPath: IndexPath) {
let group = contentGroup(for: indexPath)
setup(cell, with: group, at: indexPath)
downloadAndResizeMedia(at: indexPath, from: group)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
estimatedRowHeightsCache.setObject(cell.frame.height as AnyObject, forKey: indexPath as AnyObject)
guard let cell = cell as? NoteBlockTableViewCell else {
return
}
setupSeparators(cell, indexPath: indexPath)
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
if let height = estimatedRowHeightsCache.object(forKey: indexPath as AnyObject) as? CGFloat {
return height
}
return Settings.estimatedRowHeight
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableView.automaticDimension
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let group = contentGroup(for: indexPath)
displayContent(group)
}
func displayContent(_ contentGroup: FormattableContentGroup) {
switch contentGroup.kind {
case .header:
displayNotificationSource()
case .user:
let content: FormattableUserContent? = contentGroup.blockOfKind(.user)
let url = content?.metaLinksHome
displayURL(url)
case .footer:
let content: FormattableTextContent? = contentGroup.blockOfKind(.text)
let lastRange = content?.ranges.last as? LinkContentRange
let url = lastRange?.url
displayURL(url)
default:
tableView.deselectSelectedRowWithAnimation(true)
}
}
}
// MARK: - Setup Helpers
//
extension NotificationDetailsViewController {
func setupNavigationBar() {
// Don't show the notification title in the next-view's back button
let backButton = UIBarButtonItem(title: String(),
style: .plain,
target: nil,
action: nil)
navigationItem.backBarButtonItem = backButton
let next = UIButton(type: .custom)
next.setImage(.gridicon(.arrowUp), for: .normal)
next.addTarget(self, action: #selector(nextNotificationWasPressed), for: .touchUpInside)
let previous = UIButton(type: .custom)
previous.setImage(.gridicon(.arrowDown), for: .normal)
previous.addTarget(self, action: #selector(previousNotificationWasPressed), for: .touchUpInside)
previousNavigationButton = previous
nextNavigationButton = next
enableNavigationRightBarButtonItems()
}
func setupMainView() {
view.backgroundColor = note.isBadge ? .ungroupedListBackground : .listBackground
}
func setupTableView() {
tableView.separatorStyle = .none
tableView.keyboardDismissMode = .interactive
tableView.accessibilityIdentifier = .notificationDetailsTableAccessibilityId
tableView.accessibilityLabel = NSLocalizedString("Notification Details Table", comment: "Notifications Details Accessibility Identifier")
tableView.backgroundColor = note.isBadge ? .ungroupedListBackground : .listBackground
}
func setupTableViewCells() {
let cellClassNames: [NoteBlockTableViewCell.Type] = [
NoteBlockHeaderTableViewCell.self,
NoteBlockTextTableViewCell.self,
NoteBlockActionsTableViewCell.self,
NoteBlockCommentTableViewCell.self,
NoteBlockImageTableViewCell.self,
NoteBlockUserTableViewCell.self,
NoteBlockButtonTableViewCell.self
]
for cellClass in cellClassNames {
let classname = cellClass.classNameWithoutNamespaces()
let nib = UINib(nibName: classname, bundle: Bundle.main)
tableView.register(nib, forCellReuseIdentifier: cellClass.reuseIdentifier())
}
tableView.register(LikeUserTableViewCell.defaultNib,
forCellReuseIdentifier: LikeUserTableViewCell.defaultReuseID)
}
/// Configure the delegate and data source for the table view based on notification type.
/// This method may be called several times, especially upon previous/next button click
/// since notification kind may change.
func setupTableDelegates() {
if note.kind == .like || note.kind == .commentLike,
let likesListController = LikesListController(tableView: tableView, notification: note, delegate: self) {
tableView.delegate = likesListController
tableView.dataSource = likesListController
self.likesListController = likesListController
// always call refresh to ensure that the controller fetches the data.
likesListController.refresh()
} else {
tableView.delegate = self
tableView.dataSource = self
}
}
func setupReplyTextView() {
let previousReply = NotificationReplyStore.shared.loadReply(for: note.notificationId)
let replyTextView = ReplyTextView(width: view.frame.width)
replyTextView.placeholder = NSLocalizedString("Write a reply", comment: "Placeholder text for inline compose view")
replyTextView.text = previousReply
replyTextView.accessibilityIdentifier = .replyTextViewAccessibilityId
replyTextView.accessibilityLabel = NSLocalizedString("Reply Text", comment: "Notifications Reply Accessibility Identifier")
replyTextView.delegate = self
replyTextView.onReply = { [weak self] content in
let group = self?.note.contentGroup(ofKind: .comment)
guard let block: FormattableCommentContent = group?.blockOfKind(.comment) else {
return
}
self?.replyCommentWithBlock(block, content: content)
}
replyTextView.setContentCompressionResistancePriority(.required, for: .vertical)
self.replyTextView = replyTextView
}
func setupSuggestionsView() {
guard let siteID = note.metaSiteID else {
return
}
suggestionsTableView = SuggestionsTableView(siteID: siteID, suggestionType: .mention, delegate: self)
suggestionsTableView?.prominentSuggestionsIds = SuggestionsTableView.prominentSuggestions(
fromPostAuthorId: nil,
commentAuthorId: note.metaCommentAuthorID,
defaultAccountId: try? WPAccount.lookupDefaultWordPressComAccount(in: self.mainContext)?.userID
)
suggestionsTableView?.translatesAutoresizingMaskIntoConstraints = false
}
func setupKeyboardManager() {
precondition(replyTextView != nil)
precondition(bottomLayoutConstraint != nil)
keyboardManager = KeyboardDismissHelper(parentView: view,
scrollView: tableView,
dismissableControl: replyTextView,
bottomLayoutConstraint: bottomLayoutConstraint)
}
func setupNotificationListeners() {
let nc = NotificationCenter.default
nc.addObserver(self,
selector: #selector(notificationWasUpdated),
name: .NSManagedObjectContextObjectsDidChange,
object: note.managedObjectContext)
}
func tearDownNotificationListeners() {
let nc = NotificationCenter.default
nc.removeObserver(self,
name: .NSManagedObjectContextObjectsDidChange,
object: note.managedObjectContext)
}
}
// MARK: - Reply View Helpers
//
extension NotificationDetailsViewController {
func attachReplyViewIfNeeded() {
guard shouldAttachReplyView else {
replyTextView.removeFromSuperview()
return
}
stackView.addArrangedSubview(replyTextView)
}
var shouldAttachReplyView: Bool {
// Attach the Reply component only if the notification has a comment, and it can be replied to.
//
guard let block: FormattableCommentContent = note.contentGroup(ofKind: .comment)?.blockOfKind(.comment) else {
return false
}
return block.action(id: ReplyToCommentAction.actionIdentifier())?.on ?? false
}
func storeNotificationReplyIfNeeded() {
guard let reply = replyTextView?.text, let notificationId = note?.notificationId, !reply.isEmpty else {
return
}
NotificationReplyStore.shared.store(reply: reply, for: notificationId)
}
}
// MARK: - Suggestions View Helpers
//
private extension NotificationDetailsViewController {
func attachSuggestionsViewIfNeeded() {
guard shouldAttachSuggestionsView, let suggestionsTableView = self.suggestionsTableView else {
self.suggestionsTableView?.removeFromSuperview()
return
}
view.addSubview(suggestionsTableView)
NSLayoutConstraint.activate([
suggestionsTableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
suggestionsTableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
suggestionsTableView.topAnchor.constraint(equalTo: view.topAnchor),
suggestionsTableView.bottomAnchor.constraint(equalTo: replyTextView.topAnchor)
])
}
var shouldAttachSuggestionsView: Bool {
guard let siteID = note.metaSiteID,
let blog = Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) else {
return false
}
return shouldAttachReplyView && SuggestionService.shared.shouldShowSuggestions(for: blog)
}
}
// MARK: - Layout Helpers
//
private extension NotificationDetailsViewController {
func adjustLayoutConstraintsIfNeeded() {
// Badge Notifications:
// - Should be vertically centered
// - Don't need cell separators
// - Should have Vertical Scroll enabled only if the Table Content falls off the screen.
//
let requiresVerticalAlignment = note.isBadge
topLayoutConstraint.isActive = !requiresVerticalAlignment
bottomLayoutConstraint.isActive = !requiresVerticalAlignment
badgeTopLayoutConstraint.isActive = requiresVerticalAlignment
badgeBottomLayoutConstraint.isActive = requiresVerticalAlignment
badgeCenterLayoutConstraint.isActive = requiresVerticalAlignment
if requiresVerticalAlignment {
tableView.isScrollEnabled = tableView.intrinsicContentSize.height > view.bounds.height
} else {
tableView.isScrollEnabled = true
}
}
func reuseIdentifierForGroup(_ blockGroup: FormattableContentGroup) -> String {
switch blockGroup.kind {
case .header:
return NoteBlockHeaderTableViewCell.reuseIdentifier()
case .footer:
return NoteBlockTextTableViewCell.reuseIdentifier()
case .subject:
fallthrough
case .text:
return NoteBlockTextTableViewCell.reuseIdentifier()
case .comment:
return NoteBlockCommentTableViewCell.reuseIdentifier()
case .actions:
return NoteBlockActionsTableViewCell.reuseIdentifier()
case .image:
return NoteBlockImageTableViewCell.reuseIdentifier()
case .user:
return NoteBlockUserTableViewCell.reuseIdentifier()
case .button:
return NoteBlockButtonTableViewCell.reuseIdentifier()
default:
assertionFailure("Unmanaged group kind: \(blockGroup.kind)")
return NoteBlockTextTableViewCell.reuseIdentifier()
}
}
func setupSeparators(_ cell: NoteBlockTableViewCell, indexPath: IndexPath) {
cell.isBadge = note.isBadge
cell.isLastRow = (indexPath.row >= note.headerAndBodyContentGroups.count - 1)
cell.refreshSeparators()
}
}
// MARK: - UITableViewCell Subclass Setup
//
private extension NotificationDetailsViewController {
func setup(_ cell: NoteBlockTableViewCell, with blockGroup: FormattableContentGroup, at indexPath: IndexPath) {
switch cell {
case let cell as NoteBlockHeaderTableViewCell:
setupHeaderCell(cell, blockGroup: blockGroup)
case let cell as NoteBlockTextTableViewCell where blockGroup is FooterContentGroup:
setupFooterCell(cell, blockGroup: blockGroup)
case let cell as NoteBlockUserTableViewCell:
setupUserCell(cell, blockGroup: blockGroup)
case let cell as NoteBlockCommentTableViewCell:
setupCommentCell(cell, blockGroup: blockGroup, at: indexPath)
case let cell as NoteBlockActionsTableViewCell:
setupActionsCell(cell, blockGroup: blockGroup)
case let cell as NoteBlockImageTableViewCell:
setupImageCell(cell, blockGroup: blockGroup)
case let cell as NoteBlockTextTableViewCell:
setupTextCell(cell, blockGroup: blockGroup, at: indexPath)
case let cell as NoteBlockButtonTableViewCell:
setupButtonCell(cell, blockGroup: blockGroup)
default:
assertionFailure("NotificationDetails: Please, add support for \(cell)")
}
}
func setupHeaderCell(_ cell: NoteBlockHeaderTableViewCell, blockGroup: FormattableContentGroup) {
// Note:
// We're using a UITableViewCell as a Header, instead of UITableViewHeaderFooterView, because:
// - UITableViewCell automatically handles highlight / unhighlight for us
// - UITableViewCell's taps don't require a Gestures Recognizer. No big deal, but less code!
//
cell.attributedHeaderTitle = nil
cell.attributedHeaderDetails = nil
guard let gravatarBlock: NotificationTextContent = blockGroup.blockOfKind(.image),
let snippetBlock: NotificationTextContent = blockGroup.blockOfKind(.text) else {
return
}
cell.attributedHeaderTitle = formatter.render(content: gravatarBlock, with: HeaderContentStyles())
cell.attributedHeaderDetails = formatter.render(content: snippetBlock, with: HeaderDetailsContentStyles())
// Download the Gravatar
let mediaURL = gravatarBlock.media.first?.mediaURL
cell.downloadAuthorAvatar(with: mediaURL)
}
func setupFooterCell(_ cell: NoteBlockTextTableViewCell, blockGroup: FormattableContentGroup) {
guard let textBlock = blockGroup.blocks.first else {
assertionFailure("Missing Text Block for Notification [\(note.notificationId)")
return
}
cell.attributedText = formatter.render(content: textBlock, with: FooterContentStyles())
cell.isTextViewSelectable = false
cell.isTextViewClickable = false
}
func setupUserCell(_ cell: NoteBlockUserTableViewCell, blockGroup: FormattableContentGroup) {
guard let userBlock = blockGroup.blocks.first as? FormattableUserContent else {
assertionFailure("Missing User Block for Notification [\(note.notificationId)]")
return
}
let hasHomeURL = userBlock.metaLinksHome != nil
let hasHomeTitle = userBlock.metaTitlesHome?.isEmpty == false
cell.accessoryType = hasHomeURL ? .disclosureIndicator : .none
cell.name = userBlock.text
cell.blogTitle = hasHomeTitle ? userBlock.metaTitlesHome : userBlock.metaLinksHome?.host
cell.isFollowEnabled = userBlock.isActionEnabled(id: FollowAction.actionIdentifier())
cell.isFollowOn = userBlock.isActionOn(id: FollowAction.actionIdentifier())
cell.onFollowClick = { [weak self] in
self?.followSiteWithBlock(userBlock)
}
cell.onUnfollowClick = { [weak self] in
self?.unfollowSiteWithBlock(userBlock)
}
// Download the Gravatar
let mediaURL = userBlock.media.first?.mediaURL
cell.downloadGravatarWithURL(mediaURL)
}
func setupCommentCell(_ cell: NoteBlockCommentTableViewCell, blockGroup: FormattableContentGroup, at indexPath: IndexPath) {
// Note:
// The main reason why it's a very good idea *not* to reuse NoteBlockHeaderTableViewCell, just to display the
// gravatar, is because we're implementing a custom behavior whenever the user approves/ unapproves the comment.
//
// - Font colors are updated.
// - A left separator is displayed.
//
guard let commentBlock: FormattableCommentContent = blockGroup.blockOfKind(.comment) else {
assertionFailure("Missing Comment Block for Notification [\(note.notificationId)]")
return
}
guard let userBlock: FormattableUserContent = blockGroup.blockOfKind(.user) else {
assertionFailure("Missing User Block for Notification [\(note.notificationId)]")
return
}
// Merge the Attachments with their ranges: [NSRange: UIImage]
let mediaMap = mediaDownloader.imagesForUrls(commentBlock.imageUrls)
let mediaRanges = commentBlock.buildRangesToImagesMap(mediaMap)
let styles = RichTextContentStyles(key: "RichText-\(indexPath)")
let text = formatter.render(content: commentBlock, with: styles).stringByEmbeddingImageAttachments(mediaRanges)
// Setup: Properties
cell.name = userBlock.text
cell.timestamp = (note.timestampAsDate as NSDate).mediumString()
cell.site = userBlock.metaTitlesHome ?? userBlock.metaLinksHome?.host
cell.attributedCommentText = text.trimNewlines()
cell.isApproved = commentBlock.isCommentApproved
// Add comment author's name to Reply placeholder.
let placeholderFormat = NSLocalizedString("Reply to %1$@",
comment: "Placeholder text for replying to a comment. %1$@ is a placeholder for the comment author's name.")
replyTextView.placeholder = String(format: placeholderFormat, cell.name ?? String())
// Setup: Callbacks
cell.onUserClick = { [weak self] in
guard let homeURL = userBlock.metaLinksHome else {
return
}
self?.displayURL(homeURL)
}
cell.onUrlClick = { [weak self] url in
self?.displayURL(url as URL)
}
cell.onAttachmentClick = { [weak self] attachment in
guard let image = attachment.image else {
return
}
self?.router.routeTo(image)
}
cell.onTimeStampLongPress = { [weak self] in
guard let urlString = self?.note.url,
let url = URL(string: urlString) else {
return
}
UIAlertController.presentAlertAndCopyCommentURLToClipboard(url: url)
}
// Download the Gravatar
let mediaURL = userBlock.media.first?.mediaURL
cell.downloadGravatarWithURL(mediaURL)
}
func setupActionsCell(_ cell: NoteBlockActionsTableViewCell, blockGroup: FormattableContentGroup) {
guard let commentBlock: FormattableCommentContent = blockGroup.blockOfKind(.comment) else {
assertionFailure("Missing Comment Block for Notification \(note.notificationId)")
return
}
// Setup: Properties
// Note: Approve Action is actually a synonym for 'Edit' (Based on Calypso's basecode)
//
cell.isReplyEnabled = UIDevice.isPad() && commentBlock.isActionOn(id: ReplyToCommentAction.actionIdentifier())
cell.isLikeEnabled = commentBlock.isActionEnabled(id: LikeCommentAction.actionIdentifier())
cell.isApproveEnabled = commentBlock.isActionEnabled(id: ApproveCommentAction.actionIdentifier())
cell.isTrashEnabled = commentBlock.isActionEnabled(id: TrashCommentAction.actionIdentifier())
cell.isSpamEnabled = commentBlock.isActionEnabled(id: MarkAsSpamAction.actionIdentifier())
cell.isEditEnabled = commentBlock.isActionOn(id: ApproveCommentAction.actionIdentifier())
cell.isLikeOn = commentBlock.isActionOn(id: LikeCommentAction.actionIdentifier())
cell.isApproveOn = commentBlock.isActionOn(id: ApproveCommentAction.actionIdentifier())
// Setup: Callbacks
cell.onReplyClick = { [weak self] _ in
self?.focusOnReplyTextViewWithBlock(commentBlock)
}
cell.onLikeClick = { [weak self] _ in
self?.likeCommentWithBlock(commentBlock)
}
cell.onUnlikeClick = { [weak self] _ in
self?.unlikeCommentWithBlock(commentBlock)
}
cell.onApproveClick = { [weak self] _ in
self?.approveCommentWithBlock(commentBlock)
}
cell.onUnapproveClick = { [weak self] _ in
self?.unapproveCommentWithBlock(commentBlock)
}
cell.onTrashClick = { [weak self] _ in
self?.trashCommentWithBlock(commentBlock)
}
cell.onSpamClick = { [weak self] _ in
self?.spamCommentWithBlock(commentBlock)
}
cell.onEditClick = { [weak self] _ in
self?.displayCommentEditorWithBlock(commentBlock)
}
}
func setupImageCell(_ cell: NoteBlockImageTableViewCell, blockGroup: FormattableContentGroup) {
guard let imageBlock = blockGroup.blocks.first as? NotificationTextContent else {
assertionFailure("Missing Image Block for Notification [\(note.notificationId)")
return
}
let mediaURL = imageBlock.media.first?.mediaURL
cell.downloadImage(mediaURL)
if note.isViewMilestone {
cell.backgroundImage = UIImage(named: Assets.confettiBackground)
}
}
func setupTextCell(_ cell: NoteBlockTextTableViewCell, blockGroup: FormattableContentGroup, at indexPath: IndexPath) {
guard let textBlock = blockGroup.blocks.first as? NotificationTextContent else {
assertionFailure("Missing Text Block for Notification \(note.notificationId)")
return
}
// Merge the Attachments with their ranges: [NSRange: UIImage]
let mediaMap = mediaDownloader.imagesForUrls(textBlock.imageUrls)
let mediaRanges = textBlock.buildRangesToImagesMap(mediaMap)
// Load the attributedText
let text: NSAttributedString
if note.isBadge {
let isFirstTextGroup = indexPath.row == indexOfFirstContentGroup(ofKind: .text)
text = formatter.render(content: textBlock, with: BadgeContentStyles(cachingKey: "Badge-\(indexPath)", isTitle: isFirstTextGroup))
cell.isTitle = isFirstTextGroup
} else {
text = formatter.render(content: textBlock, with: RichTextContentStyles(key: "Rich-Text-\(indexPath)"))
}
// Setup: Properties
cell.attributedText = text.stringByEmbeddingImageAttachments(mediaRanges)
// Setup: Callbacks
cell.onUrlClick = { [weak self] url in
guard let `self` = self, self.isViewOnScreen() else {
return
}
self.displayURL(url)
}
}
func setupButtonCell(_ cell: NoteBlockButtonTableViewCell, blockGroup: FormattableContentGroup) {
guard let textBlock = blockGroup.blocks.first as? NotificationTextContent else {
assertionFailure("Missing Text Block for Notification \(note.notificationId)")
return
}
cell.title = textBlock.text
if let linkRange = textBlock.ranges.map({ $0 as? LinkContentRange }).first,
let url = linkRange?.url {
cell.action = { [weak self] in
guard let `self` = self, self.isViewOnScreen() else {
return
}
self.displayURL(url)
}
}
}
}
// MARK: - Notification Helpers
//
extension NotificationDetailsViewController {
@objc func notificationWasUpdated(_ notification: Foundation.Notification) {
let updated = notification.userInfo?[NSUpdatedObjectsKey] as? Set<NSManagedObject> ?? Set()
let refreshed = notification.userInfo?[NSRefreshedObjectsKey] as? Set<NSManagedObject> ?? Set()
let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject> ?? Set()
// Reload the table, if *our* notification got updated + Mark as Read since it's already onscreen!
if updated.contains(note) || refreshed.contains(note) {
refreshInterface()
// We're being called when the managed context is saved
// Let's defer any data changes or we will try to save within a save
// and crash 💥
DispatchQueue.main.async { [weak self] in
self?.markAsReadIfNeeded()
}
} else {
// Otherwise, refresh the navigation bar as the notes list might have changed
refreshNavigationBar()
}
// Dismiss this ViewController if *our* notification... just got deleted
if deleted.contains(note) {
_ = navigationController?.popToRootViewController(animated: true)
}
}
}
// MARK: - Resources
//
private extension NotificationDetailsViewController {
func displayURL(_ url: URL?) {
guard let url = url else {
tableView.deselectSelectedRowWithAnimation(true)
return
}
router.routeTo(url)
}
func displayNotificationSource() {
do {
try router.routeToNotificationSource()
} catch {
tableView.deselectSelectedRowWithAnimation(true)
}
}
func displayUserProfile(_ user: LikeUser, from indexPath: IndexPath) {
let userProfileVC = UserProfileSheetViewController(user: user)
userProfileVC.blogUrlPreviewedSource = "notif_like_list_user_profile"
let bottomSheet = BottomSheetViewController(childViewController: userProfileVC)
let sourceView = tableView.cellForRow(at: indexPath) ?? view
bottomSheet.show(from: self, sourceView: sourceView)
WPAnalytics.track(.userProfileSheetShown, properties: ["source": "like_notification_list"])
}
}
// MARK: - Helpers
//
private extension NotificationDetailsViewController {
func contentGroup(for indexPath: IndexPath) -> FormattableContentGroup {
return note.headerAndBodyContentGroups[indexPath.row]
}
func indexOfFirstContentGroup(ofKind kind: FormattableContentGroup.Kind) -> Int? {
return note.headerAndBodyContentGroups.firstIndex(where: { $0.kind == kind })
}
}
// MARK: - Media Download Helpers
//
private extension NotificationDetailsViewController {
/// Extracts all of the imageUrl's for the blocks of the specified kinds
///
func imageUrls(from content: [FormattableContent], inKindSet kindSet: Set<FormattableContentKind>) -> Set<URL> {
let filtered = content.filter { kindSet.contains($0.kind) }
let imageUrls = filtered.compactMap { ($0 as? NotificationTextContent)?.imageUrls }.flatMap { $0 }
return Set(imageUrls)
}
func downloadAndResizeMedia(at indexPath: IndexPath, from contentGroup: FormattableContentGroup) {
// Notes:
// - We'll *only* download Media for Text and Comment Blocks
// - Plus, we'll also resize the downloaded media cache *if needed*. This is meant to adjust images to
// better fit onscreen, whenever the device orientation changes (and in turn, the maxMediaEmbedWidth changes too).
//
let urls = imageUrls(from: contentGroup.blocks, inKindSet: ContentMedia.richBlockTypes)
let completion = {
// Workaround: Performing the reload call, multiple times, without the .BeginFromCurrentState might
// lead to a state in which the cell remains not visible.