diff --git a/Modules/Package.swift b/Modules/Package.swift index 59739e41bd59..82b0a8321e77 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -20,6 +20,10 @@ let package = Package( name: "\(jetpackStatsWidgetsCoreName)Tests", dependencies: [.target(name: jetpackStatsWidgetsCoreName)] ), + .testTarget( + name: "\(designSystemName)Tests", + dependencies: [.target(name: designSystemName)] + ), .target(name: designSystemName) ] ) diff --git a/Modules/Sources/DesignSystem/Components/Modifiers/Text+DesignSystem.swift b/Modules/Sources/DesignSystem/Components/Modifiers/Text+DesignSystem.swift index 303331bc7be5..42aaf9218bd4 100644 --- a/Modules/Sources/DesignSystem/Components/Modifiers/Text+DesignSystem.swift +++ b/Modules/Sources/DesignSystem/Components/Modifiers/Text+DesignSystem.swift @@ -1,68 +1,11 @@ import SwiftUI -// MARK: - SwiftUI.Font: TextStyle -extension TextStyle { - var font: Font { - switch self { - case .heading1: - return Font.DS.heading1 - - case .heading2: - return Font.DS.heading2 - - case .heading3: - return Font.DS.heading3 - - case .heading4: - return Font.DS.heading4 - - case .bodySmall(let weight): - switch weight { - case .regular: - return Font.DS.Body.small - case .emphasized: - return Font.DS.Body.Emphasized.small - } - - case .bodyMedium(let weight): - switch weight { - case .regular: - return Font.DS.Body.medium - case .emphasized: - return Font.DS.Body.Emphasized.medium - } - - case .bodyLarge(let weight): - switch weight { - case .regular: - return Font.DS.Body.large - case .emphasized: - return Font.DS.Body.Emphasized.large - } - - case .footnote: - return Font.DS.footnote - - case .caption: - return Font.DS.caption - } - } - - var `case`: Text.Case? { - switch self { - case .caption: - return .uppercase - default: - return nil - } - } -} - // MARK: - SwiftUI.Text public extension Text { @ViewBuilder func style(_ style: TextStyle) -> some View { - self.font(style.font) + let font = Font.DS.font(style) + self.font(font) .textCase(style.case) } } diff --git a/Modules/Sources/DesignSystem/Components/Modifiers/UILabel+DesignSystem.swift b/Modules/Sources/DesignSystem/Components/Modifiers/UILabel+DesignSystem.swift index 52406a190e56..0a31df9c1949 100644 --- a/Modules/Sources/DesignSystem/Components/Modifiers/UILabel+DesignSystem.swift +++ b/Modules/Sources/DesignSystem/Components/Modifiers/UILabel+DesignSystem.swift @@ -1,114 +1,11 @@ import UIKit -// MARK: - UIKit.UIFont: TextStyle -public extension TextStyle { - var uiFont: UIFont { - switch self { - case .heading1: - return UIFont.DS.heading1 - - case .heading2: - return UIFont.DS.heading2 - - case .heading3: - return UIFont.DS.heading3 - - case .heading4: - return UIFont.DS.heading4 - - case .bodySmall(let weight): - switch weight { - case .regular: - return UIFont.DS.Body.small - case .emphasized: - return UIFont.DS.Body.Emphasized.small - } - - case .bodyMedium(let weight): - switch weight { - case .regular: - return UIFont.DS.Body.medium - case .emphasized: - return UIFont.DS.Body.Emphasized.medium - } - - case .bodyLarge(let weight): - switch weight { - case .regular: - return UIFont.DS.Body.large - case .emphasized: - return UIFont.DS.Body.Emphasized.large - } - - case .footnote: - return UIFont.DS.footnote - - case .caption: - return UIFont.DS.caption - } - } -} - -// MARK: - SwiftUI.Text -public extension UILabel { - func setStyle(_ style: TextStyle) { - self.font = style.uiFont +extension UILabel { + func style(_ style: TextStyle) -> Self { + self.font = UIFont.DS.font(style) if style.case == .uppercase { self.text = self.text?.uppercased() } - } -} - -// MARK: - UIKit.UIFont -fileprivate extension UIFont { - enum DS { - static let heading1 = DynamicFontHelper.fontForTextStyle(.largeTitle, fontWeight: .bold) - static let heading2 = DynamicFontHelper.fontForTextStyle(.title1, fontWeight: .bold) - static let heading3 = DynamicFontHelper.fontForTextStyle(.title2, fontWeight: .bold) - static let heading4 = DynamicFontHelper.fontForTextStyle(.title3, fontWeight: .semibold) - - enum Body { - static let small = DynamicFontHelper.fontForTextStyle(.subheadline, fontWeight: .regular) - static let medium = DynamicFontHelper.fontForTextStyle(.callout, fontWeight: .regular) - static let large = DynamicFontHelper.fontForTextStyle(.body, fontWeight: .regular) - - enum Emphasized { - static let small = DynamicFontHelper.fontForTextStyle(.subheadline, fontWeight: .semibold) - static let medium = DynamicFontHelper.fontForTextStyle(.callout, fontWeight: .semibold) - static let large = DynamicFontHelper.fontForTextStyle(.body, fontWeight: .semibold) - } - } - - static let footnote = DynamicFontHelper.fontForTextStyle(.footnote, fontWeight: .regular) - static let caption = DynamicFontHelper.fontForTextStyle(.caption1, fontWeight: .regular) - } -} - -private enum DynamicFontHelper { - static func fontForTextStyle(_ style: UIFont.TextStyle, fontWeight weight: UIFont.Weight) -> UIFont { - /// WORKAROUND: Some font weights scale up well initially but they don't scale up well if dynamic type - /// is changed in real time. Creating a scaled font offers an alternative solution that works well - /// even in real time. - let weightsThatNeedScaledFont: [UIFont.Weight] = [.black, .bold, .heavy, .semibold] - - guard !weightsThatNeedScaledFont.contains(weight) else { - return scaledFont(for: style, weight: weight) - } - - var fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style) - - let traits = [UIFontDescriptor.TraitKey.weight: weight] - fontDescriptor = fontDescriptor.addingAttributes([.traits: traits]) - - return UIFont(descriptor: fontDescriptor, size: CGFloat(0.0)) - } - - static func scaledFont(for style: UIFont.TextStyle, weight: UIFont.Weight, design: UIFontDescriptor.SystemDesign = .default) -> UIFont { - let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style) - let fontDescriptorWithDesign = fontDescriptor.withDesign(design) ?? fontDescriptor - let traits = [UIFontDescriptor.TraitKey.weight: weight] - let finalDescriptor = fontDescriptorWithDesign.addingAttributes([.traits: traits]) - - return UIFont(descriptor: finalDescriptor, size: finalDescriptor.pointSize) + return self } } diff --git a/Modules/Sources/DesignSystem/Foundation/Font+DesignSystem.swift b/Modules/Sources/DesignSystem/Foundation/Font+DesignSystem.swift index 4f808eca0b9b..c216c7e4b16b 100644 --- a/Modules/Sources/DesignSystem/Foundation/Font+DesignSystem.swift +++ b/Modules/Sources/DesignSystem/Foundation/Font+DesignSystem.swift @@ -23,3 +23,51 @@ public extension Font { public static let caption = Font.caption } } + +public extension Font.DS { + static func font(_ style: DesignSystem.TextStyle) -> Font { + switch style { + case .heading1: + return Font.DS.heading1 + + case .heading2: + return Font.DS.heading2 + + case .heading3: + return Font.DS.heading3 + + case .heading4: + return Font.DS.heading4 + + case .bodySmall(let weight): + switch weight { + case .regular: + return Font.DS.Body.small + case .emphasized: + return Font.DS.Body.Emphasized.small + } + + case .bodyMedium(let weight): + switch weight { + case .regular: + return Font.DS.Body.medium + case .emphasized: + return Font.DS.Body.Emphasized.medium + } + + case .bodyLarge(let weight): + switch weight { + case .regular: + return Font.DS.Body.large + case .emphasized: + return Font.DS.Body.Emphasized.large + } + + case .footnote: + return Font.DS.footnote + + case .caption: + return Font.DS.caption + } + } +} diff --git a/Modules/Sources/DesignSystem/Foundation/IconName.swift b/Modules/Sources/DesignSystem/Foundation/IconName.swift new file mode 100644 index 000000000000..438c0fb7c4d0 --- /dev/null +++ b/Modules/Sources/DesignSystem/Foundation/IconName.swift @@ -0,0 +1,35 @@ +import UIKit +import SwiftUI + +/// `Icon` provides a namespace for icon identifiers. +/// +/// The naming convention follows the SF Symbols guidelines, enhancing consistency and readability. +/// Each icon name is a dot-syntax representation of the icon's hierarchy and style. +/// +/// For example, `ellipsis.vertical` represents a vertical ellipsis icon. +public enum IconName: String, CaseIterable { + case ellipsisHorizontal = "ellipsis.horizontal" + case checkmark + case gearshapeFill = "gearshape.fill" + case blockShare = "block.share" + case starFill = "star.fill" + case starOutline = "star.outline" +} + +// MARK: - Load Image + +public extension UIImage { + enum DS { + public static func icon(named name: IconName, with configuration: UIImage.Configuration? = nil) -> UIImage? { + return UIImage(named: name.rawValue, in: .module, with: configuration) + } + } +} + +public extension Image { + enum DS { + public static func icon(named name: IconName) -> Image { + return Image(name.rawValue, bundle: .module) + } + } +} diff --git a/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/Contents.json b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/block.share.imageset/Contents.json b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/block.share.imageset/Contents.json new file mode 100644 index 000000000000..2a0dbf1c9642 --- /dev/null +++ b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/block.share.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "block-share.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/block.share.imageset/block-share.pdf b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/block.share.imageset/block-share.pdf new file mode 100644 index 000000000000..abef7fd269c2 Binary files /dev/null and b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/block.share.imageset/block-share.pdf differ diff --git a/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/checkmark.imageset/Contents.json b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/checkmark.imageset/Contents.json new file mode 100644 index 000000000000..0adb4e6aa44c --- /dev/null +++ b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/checkmark.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "checkmark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/checkmark.imageset/checkmark.pdf b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/checkmark.imageset/checkmark.pdf new file mode 100644 index 000000000000..59c99b7964d2 Binary files /dev/null and b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/checkmark.imageset/checkmark.pdf differ diff --git a/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/ellipsis.horizontal.imageset/Contents.json b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/ellipsis.horizontal.imageset/Contents.json new file mode 100644 index 000000000000..4831d2e92646 --- /dev/null +++ b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/ellipsis.horizontal.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "more-horizontal-mobile.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/ellipsis.horizontal.imageset/more-horizontal-mobile.svg b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/ellipsis.horizontal.imageset/more-horizontal-mobile.svg new file mode 100644 index 000000000000..f8af22821782 --- /dev/null +++ b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/ellipsis.horizontal.imageset/more-horizontal-mobile.svg @@ -0,0 +1,3 @@ + + + diff --git a/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/gearshape.fill.imageset/Contents.json b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/gearshape.fill.imageset/Contents.json new file mode 100644 index 000000000000..28d8fff85ae8 --- /dev/null +++ b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/gearshape.fill.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "cog.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/gearshape.fill.imageset/cog.pdf b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/gearshape.fill.imageset/cog.pdf new file mode 100644 index 000000000000..6811e7bb5118 Binary files /dev/null and b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/gearshape.fill.imageset/cog.pdf differ diff --git a/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/star/Contents.json b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/star/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/star/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/star/star.fill.imageset/Contents.json b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/star/star.fill.imageset/Contents.json new file mode 100644 index 000000000000..14fcf2417703 --- /dev/null +++ b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/star/star.fill.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "star-fill.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/star/star.fill.imageset/star-fill.pdf b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/star/star.fill.imageset/star-fill.pdf new file mode 100644 index 000000000000..ca59ee19bdc6 Binary files /dev/null and b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/star/star.fill.imageset/star-fill.pdf differ diff --git a/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/star/star.outline.imageset/Contents.json b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/star/star.outline.imageset/Contents.json new file mode 100644 index 000000000000..0440b3ce58e2 --- /dev/null +++ b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/star/star.outline.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "star-outline.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/star/star.outline.imageset/star-outline.pdf b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/star/star.outline.imageset/star-outline.pdf new file mode 100644 index 000000000000..18c74ce5df62 Binary files /dev/null and b/Modules/Sources/DesignSystem/Foundation/Icons.xcassets/star/star.outline.imageset/star-outline.pdf differ diff --git a/Modules/Sources/DesignSystem/Components/Modifiers/TextStyle.swift b/Modules/Sources/DesignSystem/Foundation/TextStyle.swift similarity index 60% rename from Modules/Sources/DesignSystem/Components/Modifiers/TextStyle.swift rename to Modules/Sources/DesignSystem/Foundation/TextStyle.swift index 787a1571be62..b5b373425878 100644 --- a/Modules/Sources/DesignSystem/Components/Modifiers/TextStyle.swift +++ b/Modules/Sources/DesignSystem/Foundation/TextStyle.swift @@ -1,4 +1,7 @@ +import struct SwiftUI.Text + public enum TextStyle { + case heading1 case heading2 case heading3 @@ -13,4 +16,13 @@ public enum TextStyle { case regular case emphasized } + + var `case`: Text.Case? { + switch self { + case .caption: + return .uppercase + default: + return nil + } + } } diff --git a/Modules/Sources/DesignSystem/Foundation/UIFont+DesignSystem.swift b/Modules/Sources/DesignSystem/Foundation/UIFont+DesignSystem.swift new file mode 100644 index 000000000000..aa17810da813 --- /dev/null +++ b/Modules/Sources/DesignSystem/Foundation/UIFont+DesignSystem.swift @@ -0,0 +1,107 @@ +import UIKit + +public extension UIFont { + enum DS { + static let heading1 = DynamicFontHelper.fontForTextStyle(.largeTitle, fontWeight: .bold) + static let heading2 = DynamicFontHelper.fontForTextStyle(.title1, fontWeight: .bold) + static let heading3 = DynamicFontHelper.fontForTextStyle(.title2, fontWeight: .bold) + static let heading4 = DynamicFontHelper.fontForTextStyle(.title3, fontWeight: .semibold) + + enum Body { + static let small = DynamicFontHelper.fontForTextStyle(.subheadline, fontWeight: .regular) + static let medium = DynamicFontHelper.fontForTextStyle(.callout, fontWeight: .regular) + static let large = DynamicFontHelper.fontForTextStyle(.body, fontWeight: .regular) + + enum Emphasized { + static let small = DynamicFontHelper.fontForTextStyle(.subheadline, fontWeight: .semibold) + static let medium = DynamicFontHelper.fontForTextStyle(.callout, fontWeight: .semibold) + static let large = DynamicFontHelper.fontForTextStyle(.body, fontWeight: .semibold) + } + } + + static let footnote = DynamicFontHelper.fontForTextStyle(.footnote, fontWeight: .regular) + static let caption = DynamicFontHelper.fontForTextStyle(.caption1, fontWeight: .regular) + } +} + +// MARK: - UIFont + TextStyle + +public extension UIFont.DS { + + static func font(_ style: DesignSystem.TextStyle) -> UIFont { + switch style { + case .heading1: + return UIFont.DS.heading1 + + case .heading2: + return UIFont.DS.heading2 + + case .heading3: + return UIFont.DS.heading3 + + case .heading4: + return UIFont.DS.heading4 + + case .bodySmall(let weight): + switch weight { + case .regular: + return UIFont.DS.Body.small + case .emphasized: + return UIFont.DS.Body.Emphasized.small + } + + case .bodyMedium(let weight): + switch weight { + case .regular: + return UIFont.DS.Body.medium + case .emphasized: + return UIFont.DS.Body.Emphasized.medium + } + + case .bodyLarge(let weight): + switch weight { + case .regular: + return UIFont.DS.Body.large + case .emphasized: + return UIFont.DS.Body.Emphasized.large + } + + case .footnote: + return UIFont.DS.footnote + + case .caption: + return UIFont.DS.caption + } + } +} + +// MARK: - Private Helpers + +private enum DynamicFontHelper { + static func fontForTextStyle(_ style: UIFont.TextStyle, fontWeight weight: UIFont.Weight) -> UIFont { + /// WORKAROUND: Some font weights scale up well initially but they don't scale up well if dynamic type + /// is changed in real time. Creating a scaled font offers an alternative solution that works well + /// even in real time. + let weightsThatNeedScaledFont: [UIFont.Weight] = [.black, .bold, .heavy, .semibold] + + guard !weightsThatNeedScaledFont.contains(weight) else { + return scaledFont(for: style, weight: weight) + } + + var fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style) + + let traits = [UIFontDescriptor.TraitKey.weight: weight] + fontDescriptor = fontDescriptor.addingAttributes([.traits: traits]) + + return UIFont(descriptor: fontDescriptor, size: CGFloat(0.0)) + } + + static func scaledFont(for style: UIFont.TextStyle, weight: UIFont.Weight, design: UIFontDescriptor.SystemDesign = .default) -> UIFont { + let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style) + let fontDescriptorWithDesign = fontDescriptor.withDesign(design) ?? fontDescriptor + let traits = [UIFontDescriptor.TraitKey.weight: weight] + let finalDescriptor = fontDescriptorWithDesign.addingAttributes([.traits: traits]) + + return UIFont(descriptor: finalDescriptor, size: finalDescriptor.pointSize) + } +} diff --git a/Modules/Tests/DesignSystemTests/IconTests.swift b/Modules/Tests/DesignSystemTests/IconTests.swift new file mode 100644 index 000000000000..9d3fff211ed0 --- /dev/null +++ b/Modules/Tests/DesignSystemTests/IconTests.swift @@ -0,0 +1,12 @@ +import XCTest +import DesignSystem +import SwiftUI + +final class IconTests: XCTestCase { + + func testCanLoadAllIconsAsUIImage() throws { + for icon in IconName.allCases { + let _ = try XCTUnwrap(UIImage.DS.icon(named: icon)) + } + } +} diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 536241f56190..2b61cd562b35 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,5 +1,7 @@ 24.4 ----- +* [***] [Jetpack-only] Improved Notifications experience with richer UI elements and interactions + * [**] [Jetpack-only] Block editor: Introduce VideoPress v5 support, to fix issues using video block with dotcom and Jetpack sites [https://github.com/wordpress-mobile/gutenberg-mobile/pull/6634] * [**] [internal] Refactored .org REST API calls. [#22612] diff --git a/WordPress/Classes/Extensions/String+CondenseWhitespace.swift b/WordPress/Classes/Extensions/String+CondenseWhitespace.swift index 5cb2508d14d4..9f6c20150dd8 100644 --- a/WordPress/Classes/Extensions/String+CondenseWhitespace.swift +++ b/WordPress/Classes/Extensions/String+CondenseWhitespace.swift @@ -5,11 +5,12 @@ extension String { /// Attempts to remove excessive whitespace in text by replacing multiple new lines with just 2. /// This first trims whitespace and newlines from the ends /// Then normalizes the newlines by replacing {Space}{Newline} with a single newline char - /// Then finally it looks for any newlines that are 3 or more and replaces them with 2 newlines. + /// Then it looks for any newlines that are 3 or more and replaces them with 2 newlines. + /// Then finally it replaces multiple spaces on the same line with a single space. /// /// Example: /// ``` - /// This is the first line + /// This is the first line /// /// /// @@ -27,5 +28,6 @@ extension String { return self.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) .replacingOccurrences(of: "\\s\n", with: "\n", options: .regularExpression, range: nil) .replacingOccurrences(of: "[\n]{3,}", with: "\n\n", options: .regularExpression, range: nil) + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression, range: nil) } } diff --git a/WordPress/Classes/Extensions/String+Truncate.swift b/WordPress/Classes/Extensions/String+Truncate.swift new file mode 100644 index 000000000000..0ea09c3c1554 --- /dev/null +++ b/WordPress/Classes/Extensions/String+Truncate.swift @@ -0,0 +1,16 @@ +import Foundation + +extension String { + + /// Trims the trailing characters from the string to ensure the resulting string doesn't exceed the provided limit. + /// If the string is equal to or shorter than the limit, the string is returned without modifications + /// If the string is longer, the trailing characters are trimmed and replaced with an ellipsis character, + /// ensuring the length is equal to the limit + func truncate(with limit: Int) -> String { + guard count > limit else { + return self + } + let prefix = self.prefix(limit - 1) + return "\(prefix)…" + } +} diff --git a/WordPress/Classes/Models/Comment+CoreDataClass.swift b/WordPress/Classes/Models/Comment/Comment+CoreDataClass.swift similarity index 100% rename from WordPress/Classes/Models/Comment+CoreDataClass.swift rename to WordPress/Classes/Models/Comment/Comment+CoreDataClass.swift diff --git a/WordPress/Classes/Models/Comment+CoreDataProperties.swift b/WordPress/Classes/Models/Comment/Comment+CoreDataProperties.swift similarity index 100% rename from WordPress/Classes/Models/Comment+CoreDataProperties.swift rename to WordPress/Classes/Models/Comment/Comment+CoreDataProperties.swift diff --git a/WordPress/Classes/Models/Notifications/Notification.swift b/WordPress/Classes/Models/Notifications/Notification.swift index 4f1d3b156478..011f199484b2 100644 --- a/WordPress/Classes/Models/Notifications/Notification.swift +++ b/WordPress/Classes/Models/Notifications/Notification.swift @@ -170,6 +170,46 @@ class Notification: NSManagedObject { } } +// MARK: - Read / Write Body Objects + +extension Notification { + + private func indexOfBody(type: BodyType) -> Int? { + guard let body else { + return nil + } + return body.firstIndex { item in + guard let item = item as? [String: Any], let itemType = item[BodyKeys.type] as? String, itemType == type.rawValue else { + return false + } + return true + } + } + + func body(ofType type: BodyType) -> [String: Any]? { + guard let body, let index = indexOfBody(type: type) else { + return nil + } + return body[index] as? [String: Any] + } + + func updateBody(ofType type: BodyType, newValue: AnyObject) { + guard let index = indexOfBody(type: type) else { + return + } + self.body?[index] = newValue + } + + func updateBody(ofType type: BodyType, newValue: [String: Any]) { + self.updateBody(ofType: type, newValue: newValue as AnyObject) + } + + enum BodyType: String { + case post + case comment + } +} + // MARK: - Notification Computed Properties // extension Notification { @@ -363,6 +403,54 @@ extension Notification { } return content.last } + + var allAvatarURLs: [URL] { + let users = body?.filter({ element in + let type = element["type"] as? String + return type == "user" + }) ?? [] + + let avatars: [URL] = users.compactMap { + guard let allMedia = $0["media"] as? [AnyObject], + let firstMedia = allMedia.first, + let urlString = firstMedia["url"] as? String else { + return nil + } + return URL(string: urlString) + } + + return avatars + } +} + +// MARK: - Notification Subtypes + +extension Notification { + + /// Parses the meta data of the notification to extract key information like postID + /// Parsing logic and wrapper used depends on the notification kind + /// - Returns: An enum with it's associated value being a wrapper around the notification + func parsed() -> ParsedNotification { + switch kind { + case .newPost: + if let note = NewPostNotification(note: self) { + return .newPost(note) + } + case .comment: + if let note = CommentNotification(note: self) { + return .comment(note) + } + default: + break + } + return .other(self) + } + + enum ParsedNotification { + case newPost(NewPostNotification) + case comment(CommentNotification) + case other(Notification) + } } // MARK: - Update Helpers @@ -393,16 +481,30 @@ extension Notification { /// Meta Parsing Keys /// fileprivate enum MetaKeys { - static let Ids = "ids" - static let Links = "links" - static let Titles = "titles" - static let Site = "site" - static let Post = "post" - static let Comment = "comment" - static let User = "user" - static let Parent = "parent_comment" - static let Reply = "reply_comment" - static let Home = "home" + static let Ids = "ids" + static let Links = "links" + static let Titles = "titles" + static let Site = "site" + static let Post = "post" + static let Comment = "comment" + static let User = "user" + static let Parent = "parent_comment" + static let Reply = "reply_comment" + static let Home = "home" + } + + /// Body Parsing Keys + /// + enum BodyKeys { + static let type = "type" + static let actions = "actions" + } + + /// Actions Parsing Keys + /// + enum ActionsKeys { + static let likePost = "like-post" + static let likeComment = "like-comment" } } diff --git a/WordPress/Classes/Models/Notifications/Types/CommentNotification.swift b/WordPress/Classes/Models/Notifications/Types/CommentNotification.swift new file mode 100644 index 000000000000..dd31ff2f8e54 --- /dev/null +++ b/WordPress/Classes/Models/Notifications/Types/CommentNotification.swift @@ -0,0 +1,65 @@ +import Foundation + +struct CommentNotification: LikeableNotification { + + // MARK: - Properties + + private let note: Notification + private let commentID: UInt + private let siteID: UInt + + // MARK: - Init + + init?(note: Notification) { + guard let siteID = note.metaSiteID?.uintValue, + let commentID = note.metaCommentID?.uintValue + else { + return nil + } + self.note = note + self.siteID = siteID + self.commentID = commentID + } + + // MARK: LikeableNotification + + var liked: Bool { + get { + getCommentLikedStatus() + } set { + updateCommentLikedStatus(newValue) + } + } + + func toggleLike(using notificationMediator: NotificationSyncMediatorProtocol, + isLike: Bool, + completion: @escaping (Result) -> Void) { + notificationMediator.toggleLikeForCommentNotification(isLike: isLike, + commentID: commentID, + siteID: siteID, + completion: completion) + } + + // MARK: - Helpers + + private func getCommentLikedStatus() -> Bool { + guard let body = note.body(ofType: .comment), + let actions = body[Notification.BodyKeys.actions] as? [String: Bool], + let liked = actions[Notification.ActionsKeys.likeComment] + else { + return false + } + return liked + } + + private func updateCommentLikedStatus(_ newValue: Bool) { + guard var body = note.body(ofType: .comment), + var actions = body[Notification.BodyKeys.actions] as? [String: Bool] + else { + return + } + actions[Notification.ActionsKeys.likeComment] = newValue + body[Notification.BodyKeys.actions] = actions + self.note.updateBody(ofType: .comment, newValue: body) + } +} diff --git a/WordPress/Classes/Models/Notifications/Types/LikeableNotification.swift b/WordPress/Classes/Models/Notifications/Types/LikeableNotification.swift new file mode 100644 index 000000000000..60d1a5dd4c69 --- /dev/null +++ b/WordPress/Classes/Models/Notifications/Types/LikeableNotification.swift @@ -0,0 +1,8 @@ +import Foundation + +protocol LikeableNotification { + var liked: Bool { get set} + func toggleLike(using notificationMediator: NotificationSyncMediatorProtocol, + isLike: Bool, + completion: @escaping (Result) -> Void) +} diff --git a/WordPress/Classes/Models/Notifications/Types/NewPostNotification.swift b/WordPress/Classes/Models/Notifications/Types/NewPostNotification.swift new file mode 100644 index 000000000000..1afc4b3561e4 --- /dev/null +++ b/WordPress/Classes/Models/Notifications/Types/NewPostNotification.swift @@ -0,0 +1,63 @@ +import Foundation + +struct NewPostNotification: LikeableNotification { + + // MARK: - Properties + + private let note: Notification + private let postID: UInt + private let siteID: UInt + + // MARK: - Init + + init?(note: Notification) { + guard let postID = note.metaPostID?.uintValue, let siteID = note.metaSiteID?.uintValue else { + return nil + } + self.note = note + self.postID = postID + self.siteID = siteID + } + + // MARK: LikeableNotification + + var liked: Bool { + get { + getPostLikedStatus() + } set { + updatePostLikedStatus(newValue) + } + } + + func toggleLike(using notificationMediator: NotificationSyncMediatorProtocol, + isLike: Bool, + completion: @escaping (Result) -> Void) { + notificationMediator.toggleLikeForPostNotification(isLike: isLike, + postID: postID, + siteID: siteID, + completion: completion) + } + + // MARK: - Helpers + + private func getPostLikedStatus() -> Bool { + guard let body = note.body(ofType: .post), + let actions = body[Notification.BodyKeys.actions] as? [String: Bool], + let liked = actions[Notification.ActionsKeys.likePost] + else { + return false + } + return liked + } + + private func updatePostLikedStatus(_ newValue: Bool) { + guard var body = note.body(ofType: Notification.BodyType.post), + var actions = body[Notification.BodyKeys.actions] as? [String: Bool] + else { + return + } + actions[Notification.ActionsKeys.likePost] = newValue + body[Notification.BodyKeys.actions] = actions + self.note.updateBody(ofType: .post, newValue: body) + } +} diff --git a/WordPress/Classes/Services/CommentService.h b/WordPress/Classes/Services/CommentService.h index 2798219e8789..452fabf0d7f9 100644 --- a/WordPress/Classes/Services/CommentService.h +++ b/WordPress/Classes/Services/CommentService.h @@ -108,7 +108,7 @@ extern NSUInteger const WPTopLevelHierarchicalCommentsPerPage; - (NSArray *)topLevelComments:(NSUInteger)number forPost:(ReaderPost *)post; // Counts and returns the number of full pages of hierarchcial comments synced for a post. -// A partial set does not count toward the total number of pages. +// A partial set does not count toward the total number of pages. - (NSInteger)numberOfHierarchicalPagesSyncedforPost:(ReaderPost *)post; diff --git a/WordPress/Classes/Services/NotificationSyncMediator.swift b/WordPress/Classes/Services/NotificationSyncMediator.swift index 0a694509c8e6..2dff6a990ddc 100644 --- a/WordPress/Classes/Services/NotificationSyncMediator.swift +++ b/WordPress/Classes/Services/NotificationSyncMediator.swift @@ -20,6 +20,14 @@ let NotificationSyncMediatorDidUpdateNotifications = "NotificationSyncMediatorDi protocol NotificationSyncMediatorProtocol { func updateLastSeen(_ timestamp: String, completion: ((Error?) -> Void)?) + func toggleLikeForPostNotification(isLike: Bool, + postID: UInt, + siteID: UInt, + completion: @escaping (Result) -> Void) + func toggleLikeForCommentNotification(isLike: Bool, + commentID: UInt, + siteID: UInt, + completion: @escaping (Result) -> Void) } // MARK: - NotificationSyncMediator @@ -29,10 +37,24 @@ final class NotificationSyncMediator: NotificationSyncMediatorProtocol { /// private let contextManager: CoreDataStackSwift + /// API object used to make network requests + /// Used by remote services + /// + fileprivate let restAPI: WordPressComRestApi + /// Sync Service Remote /// fileprivate let remote: NotificationSyncServiceRemote + /// Reader Service Remote + /// Used for toggling like status for posts and comments + /// + fileprivate let readerRemoteService: ReaderPostServiceRemote + + /// Comment Remote Factory + /// Used to create a comment remote service by providing a siteID and restAPI + fileprivate let commentRemoteFactory: CommentServiceRemoteFactory + /// Maximum number of Notes to Sync /// fileprivate let maximumNotes = 100 @@ -78,7 +100,10 @@ final class NotificationSyncMediator: NotificationSyncMediatorProtocol { } contextManager = manager - remote = NotificationSyncServiceRemote(wordPressComRestApi: dotcomAPI) + restAPI = dotcomAPI + remote = NotificationSyncServiceRemote(wordPressComRestApi: restAPI) + readerRemoteService = ReaderPostServiceRemote(wordPressComRestApi: restAPI) + commentRemoteFactory = CommentServiceRemoteFactory() } /// Syncs the latest *maximumNotes*: @@ -150,43 +175,59 @@ final class NotificationSyncMediator: NotificationSyncMediatorProtocol { /// Marks a Notification as Read. /// - /// - Note: This method should only be used on the main thread. + /// - Note: This method is called on the main thread. /// /// - Parameters: /// - notification: The notification that was just read. /// - completion: Callback to be executed on completion. /// func markAsRead(_ notification: Notification, completion: ((Error?)-> Void)? = nil) { - mark([notification], asRead: true, completion: completion) + Task { @MainActor in + mark([notification], asRead: true, completion: completion) + } } /// Marks an array of notifications as Read. /// - /// - Note: This method should only be used on the main thread. + /// - Note: This method is called on the main thread. /// /// - Parameters: /// - notifications: Notifications that were marked as read. /// - completion: Callback to be executed on completion. /// func markAsRead(_ notifications: [Notification], completion: ((Error?)-> Void)? = nil) { - mark(notifications, asRead: true, completion: completion) + Task { @MainActor in + mark(notifications, asRead: true, completion: completion) + } } /// Marks a Notification as Unead. /// - /// - Note: This method should only be used on the main thread. + /// - Note: This method is called on the main thread. /// /// - Parameters: /// - notification: The notification that should be marked unread. /// - completion: Callback to be executed on completion. /// func markAsUnread(_ notification: Notification, completion: ((Error?)-> Void)? = nil) { - mark([notification], asRead: false, completion: completion) + markAsUnread([notification], completion: completion) } - private func mark(_ notifications: [Notification], asRead read: Bool = true, completion: ((Error?)-> Void)? = nil) { - assert(Thread.isMainThread) + /// Marks a Notification as Unread. + /// + /// - Note: This method is called on the main thread. + /// + /// - Parameters: + /// - notifications: The notifications that should be marked unread. + /// - completion: Callback to be executed on completion. + /// + func markAsUnread(_ notifications: [Notification], completion: ((Error?)-> Void)? = nil) { + Task { @MainActor in + mark(notifications, asRead: false, completion: completion) + } + } + @MainActor private func mark(_ notifications: [Notification], asRead read: Bool = true, completion: ((Error?)-> Void)? = nil) { let noteIDs = notifications.map { $0.notificationId } @@ -264,35 +305,77 @@ final class NotificationSyncMediator: NotificationSyncMediatorProtocol { /// Deletes the note with the given ID from Core Data. /// func deleteNote(noteID: String) { - Self.operationQueue.addOperation(AsyncBlockOperation { [contextManager] done in + Self.operationQueue.addOperation(AsyncBlockOperation { [contextManager] operationCompletion in contextManager.performAndSave({ context in let predicate = NSPredicate(format: "(notificationId == %@)", noteID) for orphan in context.allObjects(ofType: Notification.self, matching: predicate) { context.deleteObject(orphan) } - }, completion: done, on: .main) + }, completion: operationCompletion, on: .main) }) } /// Invalidates the local cache for the notification with the specified ID. /// - func invalidateCacheForNotification(_ noteID: String) { - invalidateCacheForNotifications([noteID]) + func invalidateCacheForNotification(_ noteID: String, + completion: (() -> Void)? = nil) { + invalidateCacheForNotifications([noteID], completion: completion) } /// Invalidates the local cache for all the notifications with specified ID's in the array. /// - func invalidateCacheForNotifications(_ noteIDs: [String]) { - Self.operationQueue.addOperation(AsyncBlockOperation { [contextManager] done in + func invalidateCacheForNotifications(_ noteIDs: [String], + completion: (() -> Void)? = nil) { + Self.operationQueue.addOperation(AsyncBlockOperation { [contextManager] operationCompletion in contextManager.performAndSave({ context in let predicate = NSPredicate(format: "(notificationId IN %@)", noteIDs) let notifications = context.allObjects(ofType: Notification.self, matching: predicate) notifications.forEach { $0.notificationHash = nil } - }, completion: done, on: .main) + }, completion: { + completion?() + operationCompletion() + }, on: .main) }) } + + func toggleLikeForPostNotification(isLike: Bool, + postID: UInt, + siteID: UInt, + completion: @escaping (Result) -> Void) { + let success = { [weak self] () -> Void in + self?.updatePostLikeStatusLocally(isLike: isLike, postID: postID, siteID: siteID, completion: completion) + } + if isLike { + readerRemoteService.likePost(postID, forSite: siteID, success: success, failure: { error in + completion(.failure(error ?? ServiceError.unknown)) + }) + } else { + readerRemoteService.unlikePost(postID, forSite: siteID, success: success, failure: { error in + completion(.failure(error ?? ServiceError.unknown)) + }) + } + } + + func toggleLikeForCommentNotification(isLike: Bool, + commentID: UInt, + siteID: UInt, + completion: @escaping (Result) -> Void) { + let commentService = commentRemoteFactory.restRemote(siteID: NSNumber(value: siteID), api: restAPI) + let success = { [weak self] () -> Void in + self?.updateCommentLikeStatusLocally(isLike: isLike, commentID: commentID, siteID: siteID, completion: completion) + } + if isLike { + commentService.likeComment(withID: NSNumber(value: commentID), success: success) { error in + completion(.failure(error ?? ServiceError.unknown)) + } + } else { + commentService.unlikeComment(withID: NSNumber(value: commentID), success: success) { error in + completion(.failure(error ?? ServiceError.unknown)) + } + } + } } // MARK: - Private Helpers @@ -306,7 +389,7 @@ private extension NotificationSyncMediator { /// - completion: Callback to be executed on completion /// func determineUpdatedNotes(with remoteHashes: [RemoteNotification], completion: @escaping (([String]) -> Void)) { - Self.operationQueue.addOperation(AsyncBlockOperation { [contextManager] done in + Self.operationQueue.addOperation(AsyncBlockOperation { [contextManager] operationCompletion in contextManager.performAndSave({ context in let remoteIds = remoteHashes.map { $0.notificationId } let predicate = NSPredicate(format: "(notificationId IN %@)", remoteIds) @@ -324,7 +407,7 @@ private extension NotificationSyncMediator { .map { $0.notificationId } }, completion: { outdatedIds in completion(outdatedIds) - done() + operationCompletion() }, on: .main) }) } @@ -337,7 +420,7 @@ private extension NotificationSyncMediator { /// - completion: Callback to be executed on completion /// func updateLocalNotes(with remoteNotes: [RemoteNotification], completion: (() -> Void)? = nil) { - Self.operationQueue.addOperation(AsyncBlockOperation { [contextManager] done in + Self.operationQueue.addOperation(AsyncBlockOperation { [contextManager] operationCompletion in contextManager.performAndSave({ context in for remoteNote in remoteNotes { let predicate = NSPredicate(format: "(notificationId == %@)", remoteNote.notificationId) @@ -346,7 +429,7 @@ private extension NotificationSyncMediator { localNote.update(with: remoteNote) } }, completion: { - done() + operationCompletion() DispatchQueue.main.async { completion?() } @@ -360,7 +443,7 @@ private extension NotificationSyncMediator { /// - Parameter remoteHashes: Collection of remoteNotifications. /// func deleteLocalMissingNotes(from remoteHashes: [RemoteNotification], completion: @escaping (() -> Void)) { - Self.operationQueue.addOperation(AsyncBlockOperation { [contextManager] done in + Self.operationQueue.addOperation(AsyncBlockOperation { [contextManager] operationCompletion in contextManager.performAndSave({ context in let remoteIds = remoteHashes.map { $0.notificationId } let predicate = NSPredicate(format: "NOT (notificationId IN %@)", remoteIds) @@ -369,7 +452,7 @@ private extension NotificationSyncMediator { context.deleteObject(orphan) } }, completion: { - done() + operationCompletion() DispatchQueue.main.async { completion() } @@ -415,4 +498,74 @@ private extension NotificationSyncMediator { let notificationCenter = NotificationCenter.default notificationCenter.post(name: Foundation.Notification.Name(rawValue: NotificationSyncMediatorDidUpdateNotifications), object: nil) } + + /// Attempts to fetch a `Comment` object matching the comment and site IDs from the local cache + /// If found, the like status is updated. If not found, nothing happens + /// - Parameters: + /// - isLike: Indicates whether this is a like or unlike + /// - commentID: Comment identifier used to fetch the comment + /// - siteID: Site identifier used to fetch the comment + /// - completion: Callback block which is called when the local comment is updated. + func updateCommentLikeStatusLocally(isLike: Bool, + commentID: UInt, + siteID: UInt, + completion: @escaping (Result) -> Void) { + contextManager.performAndSave({ context in + do { + let fetchRequest = NSFetchRequest(entityName: Comment.entityName()) + fetchRequest.fetchLimit = 1 + let commentIDPredicate = NSPredicate(format: "\(#keyPath(Comment.commentID)) == %d", commentID) + let siteIDPredicate = NSPredicate(format: "blog.blogID = %@", NSNumber(value: siteID)) + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [commentIDPredicate, siteIDPredicate]) + if let comment = try context.fetch(fetchRequest).first { + comment.isLiked = isLike + comment.likeCount = comment.likeCount + (comment.isLiked ? 1 : -1) + } + } + catch { + completion(.failure(ServiceError.localPersistenceError)) + } + }, completion: { + completion(.success(isLike)) + }, on: .main) + } + + /// Attempts to fetch a `ReaderPost` object matching the post and site IDs from the local cache + /// If found, the like status is updated. If not found, nothing happens + /// - Parameters: + /// - isLike: Indicates whether this is a like or unlike + /// - postID: Post identifier used to fetch the post + /// - siteID: Site identifier used to fetch the post + /// - completion: Callback block which is called when the local post is updated. + func updatePostLikeStatusLocally(isLike: Bool, + postID: UInt, + siteID: UInt, + completion: @escaping (Result) -> Void) { + contextManager.performAndSave({ context in + do { + let fetchRequest = NSFetchRequest(entityName: ReaderPost.entityName()) + fetchRequest.fetchLimit = 1 + let commentIDPredicate = NSPredicate(format: "\(#keyPath(ReaderPost.postID)) == %d", postID) + let siteIDPredicate = NSPredicate(format: "\(#keyPath(ReaderPost.siteID)) = %@", NSNumber(value: siteID)) + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [commentIDPredicate, siteIDPredicate]) + if let post = try context.fetch(fetchRequest).first { + post.isLiked = isLike + post.likeCount = NSNumber(value: post.likeCount.intValue + (post.isLiked ? 1 : -1)) + } + } + catch { + completion(.failure(ServiceError.localPersistenceError)) + } + }, completion: { + completion(.success(isLike)) + }, on: .main) + } +} + +extension NotificationSyncMediator { + + enum ServiceError: Error { + case unknown + case localPersistenceError + } } diff --git a/WordPress/Classes/Utility/Analytics/AnalyticsEventTracking.swift b/WordPress/Classes/Utility/Analytics/AnalyticsEventTracking.swift index 012840fd33ed..48bf6aecffa2 100644 --- a/WordPress/Classes/Utility/Analytics/AnalyticsEventTracking.swift +++ b/WordPress/Classes/Utility/Analytics/AnalyticsEventTracking.swift @@ -7,6 +7,7 @@ import WordPressShared protocol AnalyticsEventTracking { static func track(_ event: AnalyticsEvent) + static func track(_ event: WPAnalyticsEvent, properties: [AnyHashable: Any]) } extension WPAnalytics: AnalyticsEventTracking {} diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift index 5474b3f30ab7..4d3e26d11605 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift @@ -331,6 +331,8 @@ import Foundation case notificationsMarkAllReadTapped case notificationMarkAsReadTapped case notificationMarkAsUnreadTapped + case notificationMenuTapped + case notificationsInlineActionTapped // Sharing Buttons case sharingButtonsEditSharingButtonsToggled @@ -1142,6 +1144,10 @@ import Foundation return "notification_mark_as_read_tapped" case .notificationMarkAsUnreadTapped: return "notification_mark_as_unread_tapped" + case .notificationMenuTapped: + return "notification_menu_tapped" + case .notificationsInlineActionTapped: + return "notifications_inline_action_tapped" // Sharing case .sharingButtonsEditSharingButtonsToggled: diff --git a/WordPress/Classes/Utility/WPTableViewHandler.h b/WordPress/Classes/Utility/WPTableViewHandler.h index 2073dfe723fe..cf8bce7ad2f1 100644 --- a/WordPress/Classes/Utility/WPTableViewHandler.h +++ b/WordPress/Classes/Utility/WPTableViewHandler.h @@ -86,6 +86,10 @@ - (void)scrollViewWillEndDragging:(nonnull UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(nonnull inout CGPoint *)targetContentOffset; - (void)scrollViewDidEndDragging:(nonnull UIScrollView *)scrollView willDecelerate:(BOOL)decelerate; +#pragma mark - Customizing animations + +- (BOOL)shouldCancelUpdateAnimation; + @end diff --git a/WordPress/Classes/Utility/WPTableViewHandler.m b/WordPress/Classes/Utility/WPTableViewHandler.m index 27d453b898c7..0bdddd637889 100644 --- a/WordPress/Classes/Utility/WPTableViewHandler.m +++ b/WordPress/Classes/Utility/WPTableViewHandler.m @@ -705,8 +705,14 @@ - (void)controller:(NSFetchedResultsController *)controller break; case NSFetchedResultsChangeUpdate: { - [self invalidateCachedRowHeightAtIndexPath:indexPath]; - [self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:self.updateRowAnimation]; + BOOL shouldCancelUpdateAnimation = + [self.delegate respondsToSelector:@selector(shouldCancelUpdateAnimation)] + && [self.delegate shouldCancelUpdateAnimation]; + + if (!shouldCancelUpdateAnimation) { + [self invalidateCachedRowHeightAtIndexPath:indexPath]; + [self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:self.updateRowAnimation]; + } } break; case NSFetchedResultsChangeMove: diff --git a/WordPress/Classes/ViewRelated/Comments/CommentDetailViewController.swift b/WordPress/Classes/ViewRelated/Comments/CommentDetailViewController.swift index c16c2b5d8515..ed2939262de9 100644 --- a/WordPress/Classes/ViewRelated/Comments/CommentDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/CommentDetailViewController.swift @@ -390,7 +390,7 @@ private extension CommentDetailViewController { return rows } - func configureModeratationRows() -> [RowType] { + func configureModerationRows() -> [RowType] { var rows: [RowType] = [] rows.append(.status(status: .approved)) rows.append(.status(status: .pending)) @@ -406,7 +406,7 @@ private extension CommentDetailViewController { sections.append(.content(configureContentRows())) if comment.allowsModeration() { - sections.append(.moderation(configureModeratationRows())) + sections.append(.moderation(configureModerationRows())) } self.sections = sections } @@ -678,7 +678,15 @@ private extension CommentDetailViewController { CommentAnalytics.trackCommentLiked(comment: comment) } - commentService.toggleLikeStatus(for: comment, siteID: siteID, success: {}, failure: { _ in + commentService.toggleLikeStatus(for: comment, siteID: siteID, success: { [weak self] in + guard let self, let notification = self.notification else { + return + } + let mediator = NotificationSyncMediator() + mediator?.invalidateCacheForNotification(notification.notificationId, completion: { + mediator?.syncNote(with: notification.notificationId) + }) + }, failure: { _ in self.refreshData() // revert the like button state. }) } diff --git a/WordPress/Classes/ViewRelated/Comments/CommentTableHeaderView.swift b/WordPress/Classes/ViewRelated/Comments/CommentTableHeaderView.swift index f1a38a3410b7..cda553107489 100644 --- a/WordPress/Classes/ViewRelated/Comments/CommentTableHeaderView.swift +++ b/WordPress/Classes/ViewRelated/Comments/CommentTableHeaderView.swift @@ -30,10 +30,14 @@ class CommentTableHeaderView: UITableViewHeaderFooterView, Reusable { init(title: String, subtitle: Subtitle, showsDisclosureIndicator: Bool = false, - reuseIdentifier: String? = CommentTableHeaderView.defaultReuseID) { - let headerView = CommentHeaderView(title: title, - subtitle: subtitle, - showsDisclosureIndicator: showsDisclosureIndicator) + reuseIdentifier: String? = CommentTableHeaderView.defaultReuseID, + action: @escaping () -> Void) { + let headerView = CommentHeaderView( + title: title, + subtitle: subtitle, + showsDisclosureIndicator: showsDisclosureIndicator, + action: action + ) hostingController = .init(rootView: headerView) super.init(reuseIdentifier: reuseIdentifier) configureView() @@ -91,16 +95,20 @@ private extension CommentTableHeaderView { private struct CommentHeaderView: View { - @State var title = String() - @State var subtitle: CommentTableHeaderView.Subtitle = .post - @State var showsDisclosureIndicator = true + @State var title: String + @State var subtitle: CommentTableHeaderView.Subtitle + @State var showsDisclosureIndicator: Bool + + let action: () -> Void var body: some View { - HStack { - text - Spacer() - if showsDisclosureIndicator { - disclosureIndicator + Button(action: action) { + HStack { + text + Spacer() + if showsDisclosureIndicator { + disclosureIndicator + } } } .padding(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16)) diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationCommentDetailViewController.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationCommentDetailViewController.swift index c0f78c2a1a2f..c8ec7ca4ed8a 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationCommentDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationCommentDetailViewController.swift @@ -4,6 +4,8 @@ class NotificationCommentDetailViewController: UIViewController, NoResultsViewHo // MARK: - Properties + private var content: Content? + private var notification: Notification { didSet { title = notification.title @@ -31,7 +33,13 @@ class NotificationCommentDetailViewController: UIViewController, NoResultsViewHo // In this case, use the Post to obtain Comment information. private var post: ReaderPost? - private var commentDetailViewController: CommentDetailViewController? + private var commentDetailViewController: CommentDetailViewController? { + guard let content, case let .commentDetails(viewController) = content else { + return nil + } + return viewController + } + private weak var notificationDelegate: CommentDetailsNotificationDelegate? private let managedObjectContext = ContextManager.shared.mainContext @@ -156,21 +164,52 @@ private extension NotificationCommentDetailViewController { return } - if commentDetailViewController != nil { - commentDetailViewController?.refreshView(comment: comment, notification: notification) + // Refresh the current content if the underlying view controller supports it + // Else, remove the existing child view controller and add a new one. + let newContent = makeNewContent(with: comment, notification: notification) + if let commentDetailViewController, case .commentDetails = newContent { + commentDetailViewController.refreshView(comment: comment, notification: notification) } else { - let commentDetailViewController = CommentDetailViewController(comment: comment, - notification: notification, - notificationDelegate: notificationDelegate, - managedObjectContext: managedObjectContext) - - commentDetailViewController.view.translatesAutoresizingMaskIntoConstraints = false - add(commentDetailViewController) - view.pinSubviewToAllEdges(commentDetailViewController.view) - self.commentDetailViewController = commentDetailViewController + self.content?.viewController.remove() + let viewController = newContent.viewController + viewController.view.translatesAutoresizingMaskIntoConstraints = false + self.add(viewController) + self.view.pinSubviewToAllEdges(viewController.view) + self.content = newContent } - configureNavBarButtons() + self.configureNavBarButtons() + } + + /// Creates content based on a comment's moderation ability. + /// If the comment does not allow moderation, and the blog supports WordPress.com REST API capability, + /// it returns a `ReaderCommentsViewController`. Otherwise, it defaults to a `CommentDetailViewController`. + /// + /// - Parameters: + /// - comment: The comment object, used to check moderation capabilities. + /// - notification: The notification object, used for additional information like site ID. + /// + /// - Returns: Either `.readerComments` with a `ReaderCommentsViewController` or `.commentDetails` with a `CommentDetailViewController`. + private func makeNewContent(with comment: Comment, notification: Notification) -> Content { + let blogSupportsWpcomRestAPI: Bool = { + return blog?.supports(.wpComRESTAPI) ?? true + }() + guard !comment.allowsModeration(), + blogSupportsWpcomRestAPI, + let siteID = notification.metaSiteID, + let readerComments = ReaderCommentsViewController(postID: NSNumber(value: comment.postID), siteID: siteID, source: .commentNotification) + else { + let viewController = CommentDetailViewController( + comment: comment, + notification: notification, + notificationDelegate: notificationDelegate, + managedObjectContext: managedObjectContext + ) + return .commentDetails(viewController) + } + readerComments.navigateToCommentID = commentID + readerComments.allowsPushingPostDetails = true + return .readerComments(readerComments) } func loadComment() { @@ -326,4 +365,24 @@ private extension NotificationCommentDetailViewController { static let imageName = "wp-illustration-notifications" } + // MARK: - Types + + /// The `Content` enum defines the types of view controllers that can be presented in the `NotificationCommentDetailViewController`. + /// It differentiates the content based on the comment's moderation capabilities. + /// + /// - `commentDetails`: A view controller for comments that permit moderation actions. + /// - `readerComments`: A view controller for comments that do not allow moderation. + private enum Content { + + case commentDetails(CommentDetailViewController) + case readerComments(ReaderCommentsViewController) + + var viewController: UIViewController { + switch self { + case .commentDetails(let vc): return vc + case .readerComments(let vc): return vc + } + } + } + } diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationTableViewCell.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationTableViewCell.swift new file mode 100644 index 000000000000..63681d1f5d1d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationTableViewCell.swift @@ -0,0 +1,115 @@ +import UIKit +import SwiftUI + +final class NotificationTableViewCell: HostingTableViewCell { + + static let reuseIdentifier = String(describing: NotificationTableViewCell.self) + + // MARK: - API + + func configure(with notification: Notification, deletionRequest: NotificationDeletionRequest, parent: NotificationsViewController, onDeletionRequestCanceled: @escaping () -> Void) { + let style = NotificationsTableViewCellContent.Style.altered(.init(text: deletionRequest.kind.legendText, action: onDeletionRequestCanceled)) + self.host(.init(style: style), parent: parent) + } + + func configure(with viewModel: NotificationsViewModel, notification: Notification, parent: NotificationsViewController) { + let title: AttributedString? = { + guard let attributedSubject = notification.renderSubject() else { + return nil + } + return AttributedString(attributedSubject) + }() + let description = notification.renderSnippet()?.string + let inlineAction = inlineAction(viewModel: viewModel, notification: notification, parent: parent) + let avatarStyle = AvatarsView.Style(urls: notification.allAvatarURLs) ?? .single(notification.iconURL) + let style = NotificationsTableViewCellContent.Style.regular( + .init( + title: title, + description: description, + shouldShowIndicator: !notification.read, + avatarStyle: avatarStyle, + inlineAction: inlineAction + ) + ) + self.host(.init(style: style), parent: parent) + } + + // MARK: - Private Methods + + private func inlineAction(viewModel: NotificationsViewModel, notification: Notification, parent: NotificationsViewController) -> NotificationsTableViewCellContent.InlineAction.Configuration? { + let notification = notification.parsed() + switch notification { + case .comment(let notification): + return commentLikeInlineAction(viewModel: viewModel, notification: notification, parent: parent) + case .newPost(let notification): + return postLikeInlineAction(viewModel: viewModel, notification: notification, parent: parent) + case .other(let notification): + guard notification.kind == .like || notification.kind == .reblog else { + return nil + } + return shareInlineAction(viewModel: viewModel, notification: notification, parent: parent) + } + } + + private func shareInlineAction(viewModel: NotificationsViewModel, notification: Notification, parent: UIViewController) -> NotificationsTableViewCellContent.InlineAction.Configuration { + let action: () -> Void = { [weak self] in + guard let self, let content = viewModel.sharePostActionTapped(with: notification) else { + return + } + let sharingController = PostSharingController() + sharingController.sharePost( + content.title, + summary: nil, + link: content.url, + fromView: self, + inViewController: parent + ) + } + return .init( + icon: Image.DS.icon(named: .blockShare), + action: action + ) + } + + private func postLikeInlineAction(viewModel: NotificationsViewModel, + notification: NewPostNotification, + parent: NotificationsViewController) -> NotificationsTableViewCellContent.InlineAction.Configuration { + let action: () -> Void = { [weak self] in + guard let self, let content = self.content, case let .regular(style) = content.style, let config = style.inlineAction else { + return + } + parent.cancelNextUpdateAnimation() + viewModel.likeActionTapped(with: notification, action: .postLike) { liked in + let (image, color) = self.likeInlineActionIcon(filled: liked) + config.icon = image + config.color = color + } + } + let (image, color) = self.likeInlineActionIcon(filled: notification.liked) + return .init(icon: image, color: color, action: action) + } + + private func commentLikeInlineAction(viewModel: NotificationsViewModel, + notification: CommentNotification, + parent: NotificationsViewController) -> NotificationsTableViewCellContent.InlineAction.Configuration { + let action: () -> Void = { [weak self] in + guard let self, let content = self.content, case let .regular(style) = content.style, let config = style.inlineAction else { + return + } + parent.cancelNextUpdateAnimation() + viewModel.likeActionTapped(with: notification, action: .commentLike) { liked in + let (image, color) = self.likeInlineActionIcon(filled: liked) + config.icon = image + config.color = color + } + } + let (image, color) = self.likeInlineActionIcon(filled: notification.liked) + return .init(icon: image, color: color, action: action) + } + + private func likeInlineActionIcon(filled: Bool) -> (image: Image, color: Color?) { + let image: Image = Image.DS.icon(named: filled ? .starFill : .starOutline) + let color: Color? = filled ? Color.DS.Foreground.brand(isJetpack: true) : nil + return (image: image, color: color) + } +} diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsTableHeaderView.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsTableHeaderView.swift new file mode 100644 index 000000000000..13a92d545189 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsTableHeaderView.swift @@ -0,0 +1,81 @@ +import UIKit +import DesignSystem + +final class NotificationsTableHeaderView: UITableViewHeaderFooterView { + + static let reuseIdentifier: String = String(describing: NotificationsTableHeaderView.self) + + // MARK: - Properties + + var text: String? { + didSet { + self.update(text: text) + } + } + + // MARK: - Init + + override init(reuseIdentifier: String?) { + super.init(reuseIdentifier: reuseIdentifier) + self.setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setup() { + self.contentConfiguration = { + var config = super.defaultContentConfiguration() + config.textProperties.font = Appearance.textFont + config.textProperties.color = Appearance.textColor + config.directionalLayoutMargins = Appearance.layoutMarginsLeading + return config + }() + if #available(iOS 16.0, *) { + self.backgroundConfiguration = { + var config = self.defaultBackgroundConfiguration() + config.backgroundColor = Appearance.backgroundColor + config.visualEffect = nil + return config + }() + } else { + self.contentView.backgroundColor = Appearance.backgroundColor + } + } + + // MARK: - Update + + private func update(text: String?) { + guard var config = contentConfiguration as? UIListContentConfiguration else { + return + } + config.textProperties.transform = .capitalized + config.text = text + self.contentConfiguration = config + } + + private func update() { + guard var config = self.contentConfiguration as? UIListContentConfiguration else { + return + } + config.directionalLayoutMargins = Appearance.layoutMarginsLeading + self.contentConfiguration = config + } + + // MARK: - Constants + + private enum Appearance { + static let backgroundColor = UIColor.systemBackground + static let textColor = UIColor.DS.Foreground.primary + static let textFont = UIFont.DS.font(.bodyLarge(.emphasized)) + static let layoutMarginsLeading = NSDirectionalEdgeInsets( + top: Length.Padding.single, + leading: Length.Padding.double, + bottom: Length.Padding.single, + trailing: Length.Padding.double + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsViewController+Strings.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsViewController+Strings.swift new file mode 100644 index 000000000000..21ebdfeea41d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsViewController+Strings.swift @@ -0,0 +1,24 @@ +import Foundation + +extension NotificationsViewController { + + enum Strings { + enum NavigationBar { + static let notificationSettingsActionTitle = NSLocalizedString( + "notificationsViewController.navigationBar.action.settings", + value: "Notification Settings", + comment: "Link to Notification Settings section" + ) + static let markAllAsReadActionTitle = NSLocalizedString( + "notificationsViewController.navigationBar.action.markAllAsRead", + value: "Mark All As Read", + comment: "Marks all notifications under the filter as read" + ) + static let menuButtonAccessibilityLabel = NSLocalizedString( + "notificationsViewController.navigationBar.menu.accessibilityLabel", + value: "Navigation Bar Menu Button", + comment: "Accessibility label for the navigation bar menu button" + ) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsViewController.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsViewController.swift index 1dab60bdb6d1..d24c1e143609 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsViewController.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsViewController.swift @@ -7,6 +7,7 @@ import WordPressAuthenticator import Gridicons import UIKit import WordPressUI +import SwiftUI /// The purpose of this class is to render the collection of Notifications, associated to the main /// WordPress.com account. @@ -19,6 +20,8 @@ class NotificationsViewController: UIViewController, UIViewControllerRestoration @objc static let selectedNotificationRestorationIdentifier = "NotificationsSelectedNotificationKey" @objc static let selectedSegmentIndexRestorationIdentifier = "NotificationsSelectedSegmentIndexKey" + typealias TableViewCell = NotificationTableViewCell + // MARK: - Properties /// Table View @@ -91,6 +94,8 @@ class NotificationsViewController: UIViewController, UIViewControllerRestoration /// private var timestampBeforeUpdatesForSecondAlert: String? + private var shouldCancelNextUpdateAnimation = false + private lazy var notificationCommentDetailCoordinator: NotificationCommentDetailCoordinator = { return NotificationCommentDetailCoordinator(notificationsNavigationDataSource: self) }() @@ -103,35 +108,7 @@ class NotificationsViewController: UIViewController, UIViewControllerRestoration return indicator }() - /// Notification Settings button - private lazy var settingsBarButtonItem: UIBarButtonItem = { - let settingsButton = UIBarButtonItem( - image: .gridicon(.cog), - style: .plain, - target: self, - action: #selector(showNotificationSettings) - ) - settingsButton.accessibilityLabel = NSLocalizedString( - "Notification Settings", - comment: "Link to Notification Settings section" - ) - return settingsButton - }() - - /// Mark All As Read button - private lazy var markAllAsReadBarButtonItem: UIBarButtonItem = { - let markButton = UIBarButtonItem( - image: .gridicon(.checkmark), - style: .plain, - target: self, - action: #selector(showMarkAllAsReadConfirmation) - ) - markButton.accessibilityLabel = NSLocalizedString( - "Mark All As Read", - comment: "Marks all notifications under the filter as read" - ) - return markButton - }() + private let shouldPushDetailsViewController = UIDevice.current.userInterfaceIdiom != .pad /// Used by JPScrollViewDelegate to send scroll position internal let scrollViewTranslationPublisher = PassthroughSubject() @@ -199,7 +176,6 @@ class NotificationsViewController: UIViewController, UIViewControllerRestoration // Refresh the UI reloadResultsControllerIfNeeded() - updateMarkAllAsReadButton() if !splitViewControllerIsHorizontallyCompact { reloadTableViewPreservingSelection() @@ -243,11 +219,6 @@ class NotificationsViewController: UIViewController, UIViewControllerRestoration if shouldShowPrimeForPush { setupNotificationPrompt() } - // TODO: Remove this when In-App Rating project is shipped -// else if AppRatingUtility.shared.shouldPromptForAppReview(section: InlinePrompt.section) { -// setupAppRatings() -// self.showInlinePrompt() -// } showNotificationPrimerAlertIfNeeded() showSecondNotificationsAlertIfNeeded() @@ -275,6 +246,10 @@ class NotificationsViewController: UIViewController, UIViewControllerRestoration self.showNoResultsViewIfNeeded() } + if traitCollection.horizontalSizeClass != previousTraitCollection?.horizontalSizeClass { + tableView.reloadData() + } + if splitViewControllerIsHorizontallyCompact { tableView.deselectSelectedRowWithAnimation(true) } else { @@ -362,8 +337,24 @@ class NotificationsViewController: UIViewController, UIViewControllerRestoration } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: ListTableViewCell.defaultReuseID, for: indexPath) - configureCell(cell, at: indexPath) + guard let cell = tableView.dequeueReusableCell(withIdentifier: TableViewCell.reuseIdentifier) as? TableViewCell, + let note = tableViewHandler.resultsController?.managedObject(atUnsafe: indexPath) as? Notification else { + return UITableViewCell() + } + if splitViewControllerIsHorizontallyCompact { + cell.selectionStyle = .none + } else { + cell.selectionStyle = .default + } + cell.accessibilityHint = Self.accessibilityHint(for: note) + if let deletionRequest = notificationDeletionRequests[note.objectID] { + cell.configure(with: note, deletionRequest: deletionRequest, parent: self) { [weak self] in + self?.cancelDeletionRequestForNoteWithID(note.objectID) + } + } else { + cell.configure(with: viewModel, notification: note, parent: self) + } + cell.backgroundColor = .systemBackground return cell } @@ -379,12 +370,12 @@ class NotificationsViewController: UIViewController, UIViewControllerRestoration func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { guard let sectionInfo = tableViewHandler.resultsController?.sections?[section], - let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: ListTableHeaderView.defaultReuseID) as? ListTableHeaderView else { + let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: NotificationsTableHeaderView.reuseIdentifier) as? NotificationsTableHeaderView + else { return nil } - - headerView.title = Notification.descriptionForSectionIdentifier(sectionInfo.name) - return headerView + view.text = Notification.descriptionForSectionIdentifier(sectionInfo.name) + return view } func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { @@ -495,20 +486,9 @@ class NotificationsViewController: UIViewController, UIViewControllerRestoration return configuration } - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - guard let note = sender as? Notification else { - return - } - - guard let detailsViewController = segue.destination as? NotificationDetailsViewController else { - return - } - - configureDetailsViewController(detailsViewController, withNote: note) - } - fileprivate func configureDetailsViewController(_ detailsViewController: NotificationDetailsViewController, withNote note: Notification) { detailsViewController.navigationItem.largeTitleDisplayMode = .never + detailsViewController.hidesBottomBarWhenPushed = true detailsViewController.dataSource = self detailsViewController.notificationCommentDetailCoordinator = notificationCommentDetailCoordinator detailsViewController.note = note @@ -528,6 +508,7 @@ class NotificationsViewController: UIViewController, UIViewControllerRestoration // MARK: - User Interface Initialization // private extension NotificationsViewController { + func setupNavigationBar() { navigationController?.navigationBar.prefersLargeTitles = false navigationItem.largeTitleDisplayMode = .never @@ -540,15 +521,57 @@ private extension NotificationsViewController { } func updateNavigationItems() { - var barItems: [UIBarButtonItem] = [] + let moreMenuItems = UIDeferredMenuElement.uncached { [weak self] completion in + guard let self else { + completion([]) + return + } + WPAnalytics.track(.notificationMenuTapped) + completion(self.makeMoreMenuElements()) + } + self.navigationItem.rightBarButtonItem = { + let menu = UIMenu(children: [moreMenuItems]) + let button = UIBarButtonItem( + image: UIImage.DS.icon(named: .ellipsisHorizontal), + menu: menu + ) + button.accessibilityLabel = Strings.NavigationBar.menuButtonAccessibilityLabel + return button + }() + } - if shouldDisplaySettingsButton { - barItems.append(settingsBarButtonItem) - } + func makeMoreMenuElements() -> [UIAction] { + // Mark All As Read + let markAllAsRead: UIAction? = { () -> UIAction? in + guard let notes = tableViewHandler.resultsController?.fetchedObjects as? [Notification] else { + return nil + } + let isEnabled = notes.first { !$0.read } != nil + let attributes = isEnabled ? UIAction.Attributes(rawValue: 0) : .disabled + return UIAction( + title: Strings.NavigationBar.markAllAsReadActionTitle, + image: .DS.icon(named: .checkmark), + attributes: attributes + ) { [weak self] _ in + self?.showMarkAllAsReadConfirmation() + } + }() - barItems.append(markAllAsReadBarButtonItem) + // Notifications Settings + let settings: UIAction? = { () -> UIAction? in + guard shouldDisplaySettingsButton else { + return nil + } + return UIAction( + title: Strings.NavigationBar.notificationSettingsActionTitle, + image: .DS.icon(named: .gearshapeFill) + ) { [weak self] _ in + self?.showNotificationSettings() + } + }() - navigationItem.setRightBarButtonItems(barItems, animated: false) + // Return + return [markAllAsRead, settings].compactMap { $0 } } @objc func closeNotificationSettings() { @@ -575,15 +598,17 @@ private extension NotificationsViewController { func setupTableView() { // Register the cells - tableView.register(ListTableHeaderView.defaultNib, forHeaderFooterViewReuseIdentifier: ListTableHeaderView.defaultReuseID) - tableView.register(ListTableViewCell.defaultNib, forCellReuseIdentifier: ListTableViewCell.defaultReuseID) + tableView.register(NotificationsTableHeaderView.self, forHeaderFooterViewReuseIdentifier: NotificationsTableHeaderView.reuseIdentifier) + tableView.register(TableViewCell.self, forCellReuseIdentifier: TableViewCell.reuseIdentifier) // UITableView tableView.accessibilityIdentifier = "notifications-table" tableView.cellLayoutMarginsFollowReadableWidth = false tableView.estimatedSectionHeaderHeight = UITableView.automaticDimension + tableView.backgroundColor = .systemBackground + tableView.separatorStyle = .none + view.backgroundColor = .systemBackground WPStyleGuide.configureAutomaticHeightRows(for: tableView) - WPStyleGuide.configureColors(view: view, tableView: tableView) } func setupTableFooterView() { @@ -619,6 +644,7 @@ private extension NotificationsViewController { func setupFilterBar() { WPStyleGuide.configureFilterTabBar(filterTabBar) filterTabBar.superview?.backgroundColor = .systemBackground + filterTabBar.backgroundColor = .systemBackground filterTabBar.items = Filter.allCases filterTabBar.addTarget(self, action: #selector(selectedFilterDidChange(_:)), for: .valueChanged) @@ -811,8 +837,9 @@ extension NotificationsViewController { note.kind == .matcher || note.kind == .newPost { let readerViewController = ReaderDetailViewController.controllerWithPostID(postID, siteID: siteID) readerViewController.navigationItem.largeTitleDisplayMode = .never - showDetailViewController(readerViewController, sender: nil) - + readerViewController.hidesBottomBarWhenPushed = true + readerViewController.coordinator?.notificationID = note.notificationId + displayViewController(readerViewController) return } @@ -833,26 +860,48 @@ extension NotificationsViewController { guard let self = self else { return } - self.view.isUserInteractionEnabled = true - + let viewController: UIViewController? if note.kind == .comment { - guard let commentDetailViewController = self.notificationCommentDetailCoordinator.createViewController(with: note) else { - DDLogError("Notifications: failed creating Comment Detail view.") - return - } + viewController = getNotificationCommentDetailViewController(for: note) + } else { + viewController = getNotificationDetailsViewController(for: note) + } + if let viewController { + displayViewController(viewController) + } + } + } - self.notificationCommentDetailCoordinator.onSelectedNoteChange = { [weak self] note in - self?.selectRow(for: note) - } + private func getNotificationCommentDetailViewController(for note: Notification) -> NotificationCommentDetailViewController? { + guard let commentDetailViewController = self.notificationCommentDetailCoordinator.createViewController(with: note) else { + DDLogError("Notifications: failed creating Comment Detail view.") + return nil + } - commentDetailViewController.navigationItem.largeTitleDisplayMode = .never - self.showDetailViewController(commentDetailViewController, sender: nil) + self.notificationCommentDetailCoordinator.onSelectedNoteChange = { [weak self] note in + self?.selectRow(for: note) + } + commentDetailViewController.navigationItem.largeTitleDisplayMode = .never + commentDetailViewController.hidesBottomBarWhenPushed = true + return commentDetailViewController + } - return - } + private func getNotificationDetailsViewController(for note: Notification) -> NotificationDetailsViewController? { + let viewControllerID = NotificationDetailsViewController.classNameWithoutNamespaces() + let detailsViewController = storyboard?.instantiateViewController(withIdentifier: viewControllerID) + guard let detailsViewController = detailsViewController as? NotificationDetailsViewController else { + return nil + } + configureDetailsViewController(detailsViewController, withNote: note) + return detailsViewController + } - self.performSegue(withIdentifier: NotificationDetailsViewController.classNameWithoutNamespaces(), sender: note) + private func displayViewController(_ controller: UIViewController) { + if shouldPushDetailsViewController { + navigationController?.pushViewController(controller, animated: true) + } else { + showDetailViewController(controller, sender: nil) } } @@ -923,6 +972,10 @@ extension NotificationsViewController { present(navigationController, animated: true, completion: nil) } + + func cancelNextUpdateAnimation() { + shouldCancelNextUpdateAnimation = true + } } // MARK: - Notifications Deletion Mechanism @@ -1074,12 +1127,11 @@ private extension NotificationsViewController { !$0.read } - NotificationSyncMediator()?.markAsRead(unreadNotifications, completion: { [weak self] error in + NotificationSyncMediator()?.markAsRead(unreadNotifications, completion: { error in let notice = Notice( title: error != nil ? Localization.markAllAsReadNoticeFailure : Localization.markAllAsReadNoticeSuccess ) ActionDispatcherFacade().dispatch(NoticeAction.post(notice)) - self?.updateMarkAllAsReadButton() }) } @@ -1132,7 +1184,6 @@ private extension NotificationsViewController { } NotificationSyncMediator()?.markAsUnread(note) - updateMarkAllAsReadButton() } func markWelcomeNotificationAsSeenIfNeeded() { @@ -1142,17 +1193,6 @@ private extension NotificationsViewController { resetApplicationBadge() } } - - func updateMarkAllAsReadButton() { - guard let notes = tableViewHandler.resultsController?.fetchedObjects as? [Notification] else { - return - } - - let isEnabled = notes.first { !$0.read } != nil - - markAllAsReadBarButtonItem.tintColor = isEnabled ? .primary : .textTertiary - markAllAsReadBarButtonItem.isEnabled = isEnabled - } } // MARK: - Unread notifications caching @@ -1221,7 +1261,6 @@ private extension NotificationsViewController { // Don't overwork! lastReloadDate = Date() needsReloadResults = false - updateMarkAllAsReadButton() } func reloadRowForNotificationWithID(_ noteObjectID: NSManagedObjectID) { @@ -1434,6 +1473,10 @@ extension NotificationsViewController: WPTableViewHandlerDelegate { } func tableViewDidChangeContent(_ tableView: UITableView) { + guard shouldCancelNextUpdateAnimation == false else { + shouldCancelNextUpdateAnimation = false + return + } refreshUnreadNotifications() // Update NoResults View @@ -1456,6 +1499,10 @@ extension NotificationsViewController: WPTableViewHandlerDelegate { } } + func shouldCancelUpdateAnimation() -> Bool { + return shouldCancelNextUpdateAnimation + } + // counts the new notifications for the second alert private var newNotificationsForSecondAlert: Int { @@ -1922,7 +1969,7 @@ private extension NotificationsViewController { } enum Settings { - static let estimatedRowHeight = CGFloat(70) + static let estimatedRowHeight = CGFloat(60) } enum Stats { diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsViewModel.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsViewModel.swift index 6f558c7a0b8a..76a4b452bc47 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController/NotificationsViewModel.swift @@ -1,17 +1,51 @@ +import Foundation +import AutomatticTracks + final class NotificationsViewModel { + enum InlineAction: String { + case sharePost = "share_post" + case commentLike = "comment_like" + case postLike = "post_like" + } + enum Constants { static let lastSeenKey = "notifications_last_seen_time" + static let headerTextKey = "text" + static let actionAnalyticsKey = "inline_action" + static let likedAnalyticsKey = "liked" } + // MARK: - Type Aliases + + typealias ShareablePost = (url: String, title: String?) + typealias PostReadyForShareCallback = (ShareablePost, IndexPath) -> Void + + // MARK: - Depdencies + + private let contextManager: CoreDataStackSwift private let userDefaults: UserPersistentRepository private let notificationMediator: NotificationSyncMediatorProtocol? + private let analyticsTracker: AnalyticsEventTracking.Type + private let crashLogger: CrashLogging + + // MARK: - Callbacks + + var onPostReadyForShare: PostReadyForShareCallback? + + // MARK: - Init init( userDefaults: UserPersistentRepository, - notificationMediator: NotificationSyncMediatorProtocol? = NotificationSyncMediator() + notificationMediator: NotificationSyncMediatorProtocol? = NotificationSyncMediator(), + contextManager: CoreDataStackSwift = ContextManager.shared, + analyticsTracker: AnalyticsEventTracking.Type = WPAnalytics.self, + crashLogger: CrashLogging = CrashLogging.main ) { self.userDefaults = userDefaults self.notificationMediator = notificationMediator + self.analyticsTracker = analyticsTracker + self.crashLogger = crashLogger + self.contextManager = contextManager } /// The last time when user seen notifications @@ -73,4 +107,87 @@ final class NotificationsViewModel { .first(where: notMatcher) } } + + // MARK: - Handling Inline Actions + + func sharePostActionTapped(with notification: Notification) -> ShareablePost? { + guard let url = notification.url else { + self.crashLogger.logMessage("Failed to share a notification post due to null url", level: .error) + return nil + } + let content: ShareablePost = ( + url: url, + title: createSharingTitle(from: notification) + ) + self.trackInlineActionTapped(action: .sharePost) + return content + } + + func likeActionTapped(with notification: LikeableNotification, + action: InlineAction, + changes: @escaping (Bool) -> Void) { + guard let notificationMediator else { + return + } + // Optimistically update liked status + var notification = notification + let oldLikedStatus = notification.liked + let newLikedStatus = !notification.liked + changes(newLikedStatus) + + // Update liked status remotely + let mainContext = contextManager.mainContext + notification.toggleLike(using: notificationMediator, isLike: newLikedStatus) { result in + mainContext.perform { + do { + switch result { + case .success(let liked): + notification.liked = liked + try mainContext.save() + case .failure(let error): + throw error + } + } catch { + changes(oldLikedStatus) + } + } + } + + // Track analytics event + let properties = [Constants.likedAnalyticsKey: String(newLikedStatus)] + self.trackInlineActionTapped(action: action, extraProperties: properties) + } + + // MARK: - Helpers + + private func createSharingTitle(from notification: Notification) -> String { + guard notification.kind == .like, + let header = notification.header, + header.count == 2, + let titleDictionary = header[1] as? [String: String], + let postTitle = titleDictionary[Constants.headerTextKey] else { + crashLogger.logMessage("Failed to extract post title from like notification", level: .info) + return Strings.sharingMessageWithoutPost + } + return String(format: Strings.sharingMessageWithPostFormat, postTitle) + } +} + +// MARK: - Analytics Tracking + +private extension NotificationsViewModel { + func trackInlineActionTapped(action: InlineAction, extraProperties: [AnyHashable: Any] = [:]) { + var properties: [AnyHashable: Any] = [Constants.actionAnalyticsKey: action.rawValue] + properties.merge(extraProperties) { current, _ in current } + self.analyticsTracker.track(.notificationsInlineActionTapped, properties: properties) + } + + enum Strings { + static let sharingMessageWithPostFormat = NSLocalizedString("notifications.share.messageWithPost", + value: "Check out my post \"%@\":", + comment: "Message to use along with the post URL when sharing a post") + static let sharingMessageWithoutPost = NSLocalizedString("notifications.share.messageWithoutPost", + value: "Check out my post:", + comment: "Message to use along with the post URL when sharing a post") + } } diff --git a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Notifiable.swift b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Notifiable.swift index a4796a83d579..fe8af4d6c46a 100644 --- a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Notifiable.swift +++ b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Notifiable.swift @@ -18,6 +18,7 @@ enum NotificationKind: String { case login = "push_auth" case viewMilestone = "view_milestone" case unknown = "unknown" + case reblog = "reblog" } extension NotificationKind { diff --git a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/SubjectContentStyles.swift b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/SubjectContentStyles.swift index 322c4c1a7508..8463bb10f2f8 100644 --- a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/SubjectContentStyles.swift +++ b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/SubjectContentStyles.swift @@ -11,10 +11,10 @@ class SubjectContentStyles: FormattableContentStyles { var rangeStylesMap: [FormattableRangeKind: [NSAttributedString.Key: Any]]? { return [ - .user: WPStyleGuide.Notifications.subjectSemiBoldStyle, - .post: WPStyleGuide.Notifications.subjectSemiBoldStyle, - .site: WPStyleGuide.Notifications.subjectSemiBoldStyle, - .comment: WPStyleGuide.Notifications.subjectSemiBoldStyle, + .user: WPStyleGuide.Notifications.subjectRegularStyle, + .post: WPStyleGuide.Notifications.subjectRegularStyle, + .site: WPStyleGuide.Notifications.subjectRegularStyle, + .comment: WPStyleGuide.Notifications.subjectRegularStyle, .blockquote: WPStyleGuide.Notifications.subjectQuotedStyle, .noticon: WPStyleGuide.Notifications.subjectNoticonStyle ] diff --git a/WordPress/Classes/ViewRelated/Notifications/Notifications.storyboard b/WordPress/Classes/ViewRelated/Notifications/Notifications.storyboard index fd4aaa9028e7..02fd92e9d768 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Notifications.storyboard +++ b/WordPress/Classes/ViewRelated/Notifications/Notifications.storyboard @@ -1,9 +1,9 @@ - + - + @@ -23,10 +23,10 @@ - + - + - + diff --git a/WordPress/Classes/ViewRelated/Post/PostSharingController.swift b/WordPress/Classes/ViewRelated/Post/PostSharingController.swift index 8253ec7e1e90..ae862fc9e2e3 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSharingController.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSharingController.swift @@ -4,13 +4,12 @@ import SVProgressHUD @objc class PostSharingController: NSObject { @objc func shareController(_ title: String?, summary: String?, link: String?) -> UIActivityViewController { - var activityItems = [AnyObject]() let url = link.flatMap(URL.init(string:)) - let post = SharePost(title: title, summary: summary, url: url?.absoluteString) - activityItems.append(post) + let allItems: [Any?] = [title, summary, url] + let nonNilActivityItems = allItems.compactMap({ $0 }) let activities = WPActivityDefaults.defaultActivities() as! [UIActivity] - let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: activities) + let controller = UIActivityViewController(activityItems: nonNilActivityItems, applicationActivities: activities) if let str = title { controller.setValue(str, forKey: "subject") } @@ -43,11 +42,11 @@ import SVProgressHUD } } - @objc func sharePost(_ title: String, summary: String, link: String?, fromView anchorView: UIView, inViewController viewController: UIViewController) { + @objc func sharePost(_ title: String?, summary: String?, link: String?, fromView anchorView: UIView, inViewController viewController: UIViewController) { sharePost(title, summary: summary, link: link, fromAnchor: .view(anchorView), inViewController: viewController) } - private func sharePost(_ title: String, summary: String, link: String?, fromAnchor anchor: PopoverAnchor, inViewController viewController: UIViewController) { + private func sharePost(_ title: String?, summary: String?, link: String?, fromAnchor anchor: PopoverAnchor, inViewController viewController: UIViewController) { let controller = shareController( title, summary: summary, diff --git a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.swift b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.swift index a70e03683da0..72dc05cc5e98 100644 --- a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.swift @@ -32,24 +32,14 @@ extension NSNotification.Name { guard let post = post else { return .init() } - - if FeatureFlag.commentModerationUpdate.enabled { - let headerView = CommentTableHeaderView(title: post.titleForDisplay(), - subtitle: .commentThread, - showsDisclosureIndicator: true) - headerView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleHeaderTapped))) - return headerView + let headerView = CommentTableHeaderView( + title: post.titleForDisplay(), + subtitle: .commentThread, + showsDisclosureIndicator: allowsPushingPostDetails + ) { [weak self] in + self?.handleHeaderTapped() } - - let cell = CommentHeaderTableViewCell() - cell.backgroundColor = .systemBackground - cell.configure(for: .thread, subtitle: post.titleForDisplay(), showsDisclosureIndicator: allowsPushingPostDetails) - cell.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleHeaderTapped))) - - // the table view does not render separators for the section header views, so we need to create one. - cell.contentView.addBottomBorder(withColor: .separator, leadingMargin: tableView.separatorInset.left) - - return cell + return headerView } func configureContentCell( diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift index 48a8e3bf5d09..5f9193b5ab2a 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift @@ -36,6 +36,10 @@ class ReaderDetailCoordinator { return nil } + /// ID representing the notification the post details is triggered from + /// If post details is not related to a notification, this property is `nil` + var notificationID: String? + /// Called if the view controller's post fails to load var postLoadFailureBlock: (() -> Void)? = nil diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailToolbar.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailToolbar.swift index 89685114daf3..29056644b076 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailToolbar.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailToolbar.swift @@ -3,6 +3,7 @@ import WordPressUI protocol ReaderDetailToolbarDelegate: AnyObject { func didTapLikeButton(isLiked: Bool) + var notificationID: String? { get } } class ReaderDetailToolbar: UIView, NibLoadable { @@ -119,6 +120,12 @@ class ReaderDetailToolbar: UIView, NibLoadable { let service = ReaderPostService(coreDataStack: ContextManager.shared) service.toggleLiked(for: post, success: { [weak self] in + if let notificationID = self?.delegate?.notificationID { + let mediator = NotificationSyncMediator() + mediator?.invalidateCacheForNotification(notificationID, completion: { + mediator?.syncNote(with: notificationID) + }) + } self?.trackArticleDetailsLikedOrUnliked() }, failure: { [weak self] (error: Error?) in self?.trackArticleDetailsLikedOrUnliked() diff --git a/WordPress/Classes/ViewRelated/Stats/Extensions/WPStyleGuide+Stats.swift b/WordPress/Classes/ViewRelated/Stats/Extensions/WPStyleGuide+Stats.swift index 9c039ccd6e45..c5450c72bb82 100644 --- a/WordPress/Classes/ViewRelated/Stats/Extensions/WPStyleGuide+Stats.swift +++ b/WordPress/Classes/ViewRelated/Stats/Extensions/WPStyleGuide+Stats.swift @@ -88,12 +88,12 @@ extension WPStyleGuide { } static func configureLabelAsCellValueTitle(_ label: UILabel) { - label.font = TextStyle.footnote.uiFont + label.font = UIFont.DS.font(.footnote) label.textColor = UIColor.DS.Foreground.secondary } static func configureLabelAsCellValue(_ label: UILabel) { - label.font = TextStyle.heading2.uiFont.semibold() + label.font = UIFont.DS.font(.heading2).semibold() label.textColor = UIColor.DS.Foreground.primary } diff --git a/WordPress/Classes/ViewRelated/Stats/Traffic/Chart/StatsTrafficBarChartCell.swift b/WordPress/Classes/ViewRelated/Stats/Traffic/Chart/StatsTrafficBarChartCell.swift index a358d39ccf2e..615068c1c701 100644 --- a/WordPress/Classes/ViewRelated/Stats/Traffic/Chart/StatsTrafficBarChartCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Traffic/Chart/StatsTrafficBarChartCell.swift @@ -312,7 +312,7 @@ struct StatsTrafficBarChartTabData: FilterTabBarItem, Equatable { var attributedTitle: NSAttributedString? { let attributedTitle = NSMutableAttributedString(string: tabTitle) - attributedTitle.addAttributes([.font: TextStyle.footnote.uiFont], + attributedTitle.addAttributes([.font: UIFont.DS.font(.footnote)], range: NSMakeRange(0, attributedTitle.length)) let dataString: String = { @@ -320,7 +320,7 @@ struct StatsTrafficBarChartTabData: FilterTabBarItem, Equatable { }() let attributedData = NSMutableAttributedString(string: dataString) - attributedData.addAttributes([.font: TextStyle.bodyLarge(.emphasized).uiFont], + attributedData.addAttributes([.font: UIFont.DS.font(.bodyLarge(.emphasized))], range: NSMakeRange(0, attributedData.length)) attributedTitle.append(NSAttributedString(string: "\n")) diff --git a/WordPress/Classes/ViewRelated/System/WPTabBarController.m b/WordPress/Classes/ViewRelated/System/WPTabBarController.m index b493002cb3c8..c2d5bd91e03e 100644 --- a/WordPress/Classes/ViewRelated/System/WPTabBarController.m +++ b/WordPress/Classes/ViewRelated/System/WPTabBarController.m @@ -354,7 +354,9 @@ - (ReaderCoordinator *)readerCoordinator - (NSArray *)tabViewControllers { - if (self.shouldUseStaticScreens) { + BOOL isIPad = UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad; + + if (self.shouldUseStaticScreens || !isIPad) { return @[ self.mySitesCoordinator.rootViewController, self.readerNavigationController, diff --git a/WordPress/Classes/ViewRelated/Views/List/NotificationsList/AvatarsView.swift b/WordPress/Classes/ViewRelated/Views/List/NotificationsList/AvatarsView.swift new file mode 100644 index 000000000000..109aa8d84e77 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/List/NotificationsList/AvatarsView.swift @@ -0,0 +1,143 @@ +import SwiftUI +import DesignSystem +import WordPressUI + +struct AvatarsView: View { + private enum Constants { + static let doubleAvatarHorizontalOffset: CGFloat = 18 + } + + enum Style { + case single(URL?) + case double(URL?, URL?) + case triple(URL?, URL?, URL?) + + var diameter: CGFloat { + switch self { + case .single: + return 40 + case .double: + return 32 + case .triple: + return 28 + } + } + + var leadingOffset: CGFloat { + switch self { + case .single: + return 0 + case .double: + return 5 + case .triple: + return Length.Padding.split/2 + } + } + } + + private let style: Style + private let borderColor: Color + @ScaledMetric private var scale = 1 + + init(style: Style, borderColor: Color = .DS.Background.primary) { + self.style = style + self.borderColor = borderColor + } + + var body: some View { + switch style { + case let .single(primaryURL): + avatar(url: primaryURL) + case let .double(primaryURL, secondaryURL): + doubleAvatarView( + primaryURL: primaryURL, + secondaryURL: secondaryURL + ) + case let .triple(primaryURL, secondaryURL, tertiaryURL): + tripleAvatarView( + primaryURL: primaryURL, + secondaryURL: secondaryURL, + tertiaryURL: tertiaryURL + ) + } + } + + private func avatar(url: URL?) -> some View { + let processedURL: URL? + if let url, let gravatar = Gravatar(url) { + let size = Int(ceil(style.diameter * UIScreen.main.scale)) + processedURL = gravatar.urlWithSize(size) + } else { + processedURL = url + } + + return CachedAsyncImage(url: processedURL) { image in + image.resizable() + } placeholder: { + Image("gravatar") + .resizable() + } + .frame(width: style.diameter * scale, height: style.diameter * scale) + .clipShape(Circle()) + } + + private func doubleAvatarView(primaryURL: URL?, secondaryURL: URL?) -> some View { + ZStack { + avatar(url: secondaryURL) + .padding(.trailing, Constants.doubleAvatarHorizontalOffset * scale) + avatar(url: primaryURL) + .avatarBorderOverlay() + .padding(.leading, Constants.doubleAvatarHorizontalOffset * scale) + } + } + + private func tripleAvatarView( + primaryURL: URL?, + secondaryURL: URL?, + tertiaryURL: URL? + ) -> some View { + ZStack(alignment: .center) { + avatar(url: tertiaryURL) + .padding(.trailing, Length.Padding.medium * scale) + avatar(url: secondaryURL) + .avatarBorderOverlay() + .offset(y: -Length.Padding.split * scale) + .padding(.bottom, Length.Padding.split/2 * scale) + avatar(url: primaryURL) + .avatarBorderOverlay() + .padding(.leading, Length.Padding.medium * scale) + } + .padding(.top, Length.Padding.split) + } +} + +extension AvatarsView.Style { + init?(urls: [URL]) { + var tempURLs: [URL] + if urls.count > 3 { + tempURLs = Array(urls.prefix(3)) + } else { + tempURLs = urls + } + + switch UInt(tempURLs.count) { + case 0: + return nil + case 1: + self = AvatarsView.Style.single(tempURLs[0]) + case 2: + self = AvatarsView.Style.double(tempURLs[0], tempURLs[1]) + default: + self = AvatarsView.Style.triple(tempURLs[0], tempURLs[1], tempURLs[2]) + } + } +} + +private extension View { + func avatarBorderOverlay() -> some View { + self.overlay( + Circle() + .stroke(Color.DS.Background.primary, lineWidth: 1) + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Views/List/NotificationsList/HostingTableViewCell.swift b/WordPress/Classes/ViewRelated/Views/List/NotificationsList/HostingTableViewCell.swift new file mode 100644 index 000000000000..ab98c71053aa --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/List/NotificationsList/HostingTableViewCell.swift @@ -0,0 +1,30 @@ +import UIKit +import SwiftUI + +class HostingTableViewCell: UITableViewCell { + private weak var controller: UIHostingController? + + var content: Content? { + return controller?.rootView + } + + func host(_ view: Content, parent: UIViewController) { + if let controller = controller { + controller.rootView = view + controller.view.layoutIfNeeded() + } else { + let swiftUICellViewController = UIHostingController(rootView: view) + controller = swiftUICellViewController + swiftUICellViewController.view.backgroundColor = .clear + + parent.addChild(swiftUICellViewController) + contentView.addSubview(swiftUICellViewController.view) + swiftUICellViewController.view.translatesAutoresizingMaskIntoConstraints = false + contentView.pinSubviewToAllEdges(swiftUICellViewController.view) + + swiftUICellViewController.didMove(toParent: parent) + } + + self.controller?.view.invalidateIntrinsicContentSize() + } +} diff --git a/WordPress/Classes/ViewRelated/Views/List/NotificationsList/NotificationsTableViewCellContent.swift b/WordPress/Classes/ViewRelated/Views/List/NotificationsList/NotificationsTableViewCellContent.swift new file mode 100644 index 000000000000..1dbb46a2cd61 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/List/NotificationsList/NotificationsTableViewCellContent.swift @@ -0,0 +1,308 @@ +import SwiftUI +import DesignSystem + +struct NotificationsTableViewCellContent: View { + enum Style { + struct Regular { + let title: AttributedString? + let description: String? + let shouldShowIndicator: Bool + let avatarStyle: AvatarsView.Style + let inlineAction: InlineAction.Configuration? + } + + struct Altered { + let text: String + let action: (() -> Void)? + } + + case regular(Regular) + case altered(Altered) + } + + let style: Style + + init(style: Style) { + self.style = style + } + + var body: some View { + switch style { + case .regular(let regular): + Regular(info: regular) + .padding(.vertical, Length.Padding.split) + case .altered(let altered): + Altered(info: altered) + .padding(.vertical, Length.Padding.split) + } + } +} + +// MARK: - Regular Style +fileprivate extension NotificationsTableViewCellContent { + struct Regular: View { + + @State private var avatarSize: CGSize = .zero + @State private var textsSize: CGSize = .zero + @ScaledMetric(relativeTo: .subheadline) private var textScale = 1 + + private var rootStackAlignment: VerticalAlignment { + return textsSize.height >= avatarSize.height ? .top : .center + } + + private let info: Style.Regular + + fileprivate init(info: Style.Regular) { + self.info = info + } + + var body: some View { + HStack(alignment: rootStackAlignment, spacing: 0) { + avatarHStack + .saveSize(in: $avatarSize) + textsVStack + .offset( + x: -info.avatarStyle.leadingOffset * 2, + y: -3 * textScale + ) + .padding(.leading, Length.Padding.split) + .saveSize(in: $textsSize) + Spacer() + if let inlineAction = info.inlineAction { + InlineAction(configuration: inlineAction) + .padding(.top, actionIconTopPadding()) + } + } + .padding(.trailing, Length.Padding.double) + } + + private var avatarHStack: some View { + HStack(spacing: 0) { + if info.shouldShowIndicator { + indicator + .padding(.horizontal, Length.Padding.single) + AvatarsView(style: info.avatarStyle) + .offset(x: -info.avatarStyle.leadingOffset) + } else { + AvatarsView(style: info.avatarStyle) + .offset(x: -info.avatarStyle.leadingOffset) + .padding(.leading, Length.Padding.medium) + } + } + } + + private var indicator: some View { + Circle() + .fill(Color.DS.Background.brand(isJetpack: AppConfiguration.isJetpack)) + .frame(width: Length.Padding.single) + } + + private var textsVStack: some View { + VStack(alignment: .leading, spacing: 0) { + if let title = info.title { + Text(title) + .style(.bodySmall(.regular)) + .foregroundStyle(Color.DS.Foreground.primary) + .layoutPriority(1) + .lineLimit(2) + } + + if let description = info.description { + Text(description) + .style(.bodySmall(.regular)) + .foregroundStyle(Color.DS.Foreground.secondary) + .layoutPriority(2) + .lineLimit(1) + .padding(.top, Length.Padding.half) + } + } + } + + private func actionIconTopPadding() -> CGFloat { + rootStackAlignment == .center ? 0 : ((info.avatarStyle.diameter * textScale - Length.Padding.medium) / 2) + } + } +} + +// MARK: - Regular Style +fileprivate extension NotificationsTableViewCellContent { + private enum Strings { + static let undoButtonText = NSLocalizedString( + "Undo", + comment: "Revert an operation" + ) + static let undoButtonHint = NSLocalizedString( + "Reverts the action performed on this notification.", + comment: "Accessibility hint describing what happens if the undo button is tapped." + ) + } + struct Altered: View { + private let info: Style.Altered + + fileprivate init(info: Style.Altered) { + self.info = info + } + + var body: some View { + // To not pollute the init too much, colors are uncustomizable + // If a need arises, they can be added to the `Altered.Info` struct. + HStack(spacing: 0) { + Group { + Text(info.text) + .style(.bodySmall(.regular)) + .foregroundStyle(Color.white) + .lineLimit(2) + .padding(.leading, Length.Padding.medium) + + Spacer() + + Button(action: { + info.action?() + }, label: { + Text(Strings.undoButtonText) + .style(.bodySmall(.regular)) + .foregroundStyle(Color.white) + .accessibilityHint(Strings.undoButtonHint) + .padding(.trailing, Length.Padding.medium) + }) + } + } + .frame(height: 60) + .background(Color.DS.Foreground.error) + } + } +} + +// MARK: - Inline Action + +extension NotificationsTableViewCellContent { + + struct InlineAction: View { + + class Configuration: ObservableObject { + + @Published var icon: SwiftUI.Image + @Published var color: Color? + + let action: () -> Void + + init(icon: SwiftUI.Image, color: Color? = nil, action: @escaping () -> Void) { + self.icon = icon + self.color = color + self.action = action + } + } + + @ObservedObject var configuration: Configuration + + var body: some View { + Button { + configuration.action() + } label: { + configuration.icon + .imageScale(.small) + .foregroundStyle(configuration.color ?? Color.DS.Foreground.secondary) + .frame(width: Length.Padding.medium, height: Length.Padding.medium) + .transaction { transaction in + transaction.animation = nil + } + } + } + } +} + +// MARK: - Helpers + +private struct SizePreferenceKey: PreferenceKey { + static var defaultValue: CGSize = .zero + + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() + } +} + +private struct SizeModifier: ViewModifier { + @Binding var size: CGSize + + private var sizeView: some View { + GeometryReader { geometry in + Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size) + } + } + + func body(content: Content) -> some View { + content.background( + sizeView + .onPreferenceChange(SizePreferenceKey.self, perform: { value in + size = value + }) + ) + } +} + +private extension View { + func saveSize(in size: Binding) -> some View { + modifier(SizeModifier(size: size)) + } +} + +// MARK: - Preview + +#if DEBUG +#Preview { + VStack(alignment: .leading, spacing: Length.Padding.medium) { + NotificationsTableViewCellContent( + style: .regular( + .init( + title: "John Smith liked your comment more than all other comments as asdf", + description: "Here is what I think of all this: Lorem ipsum dolor sit amet, consectetur adipiscing elit", + shouldShowIndicator: true, + avatarStyle: .single( + URL(string: "https://i.pickadummy.com/index.php?imgsize=40x40")! + ), + inlineAction: .init(icon: .DS.icon(named: .ellipsisHorizontal), action: {}) + ) + ) + ) + + NotificationsTableViewCellContent( + style: .regular( + .init( + title: "Albert Einstein and Marie Curie liked your comment on Quantum Mechanical solution for Hydrogen", + description: "Mary Carpenter • marycarpenter.com", + shouldShowIndicator: true, + avatarStyle: .double( + URL(string: "https://i.pickadummy.com/index.php?imgsize=34x34")!, + URL(string: "https://i.pickadummy.com/index.php?imgsize=34x34")! + ), + inlineAction: .init(icon: .init(systemName: "plus"), action: {}) + ) + ) + ) + NotificationsTableViewCellContent( + style: .regular( + .init( + title: "New likes on Night Time in Tokyo", + description: nil, + shouldShowIndicator: true, + avatarStyle: .triple( + URL(string: "https://i.pickadummy.com/index.php?imgsize=28x28")!, + URL(string: "https://i.pickadummy.com/index.php?imgsize=28x28")!, + URL(string: "https://i.pickadummy.com/index.php?imgsize=28x28")! + ), + inlineAction: .init(icon: .init(systemName: "square.and.arrow.up"), action: {}) + ) + ) + ) + + NotificationsTableViewCellContent( + style: .altered( + .init( + text: "Comment has been marked as Spam", + action: nil + ) + ) + ) + } +} +#endif diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index b1203f91ac59..351a848e541c 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -313,6 +313,12 @@ 0857C2781CE5375F0014AE99 /* MenuItemInsertionView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0857C2721CE5375F0014AE99 /* MenuItemInsertionView.m */; }; 0857C2791CE5375F0014AE99 /* MenuItemsVisualOrderingView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0857C2741CE5375F0014AE99 /* MenuItemsVisualOrderingView.m */; }; 0857C27A1CE5375F0014AE99 /* MenuItemView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0857C2761CE5375F0014AE99 /* MenuItemView.m */; }; + 086023D42B73AFD0000D084A /* NotificationsTableViewCellContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086023D32B73AFD0000D084A /* NotificationsTableViewCellContent.swift */; }; + 086023D52B73AFD0000D084A /* NotificationsTableViewCellContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086023D32B73AFD0000D084A /* NotificationsTableViewCellContent.swift */; }; + 086023D72B73B44A000D084A /* HostingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086023D62B73B44A000D084A /* HostingTableViewCell.swift */; }; + 086023D82B73B44A000D084A /* HostingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086023D62B73B44A000D084A /* HostingTableViewCell.swift */; }; + 086023DB2B73BA67000D084A /* AvatarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086023DA2B73BA67000D084A /* AvatarsView.swift */; }; + 086023DC2B73BA67000D084A /* AvatarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086023DA2B73BA67000D084A /* AvatarsView.swift */; }; 086103961EE09C91004D7C01 /* MediaVideoExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086103951EE09C91004D7C01 /* MediaVideoExporter.swift */; }; 086C117C2A2F6451004A3821 /* CompliancePopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086C117B2A2F6451004A3821 /* CompliancePopover.swift */; }; 086C117D2A2F6451004A3821 /* CompliancePopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086C117B2A2F6451004A3821 /* CompliancePopover.swift */; }; @@ -331,6 +337,7 @@ 0885A3671E837AFE00619B4D /* URLIncrementalFilenameTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0885A3661E837AFE00619B4D /* URLIncrementalFilenameTests.swift */; }; 088B89891DA6F93B000E8DEF /* ReaderPostCardContentLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088B89881DA6F93B000E8DEF /* ReaderPostCardContentLabel.swift */; }; 088CC594282BEC41007B9421 /* TooltipPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088CC593282BEC41007B9421 /* TooltipPresenter.swift */; }; + 089D4EBE2B7BBE0D0009CF2F /* notifications-like-multiple-avatar.json in Resources */ = {isa = PBXBuildFile; fileRef = 089D4EBD2B7BBE0D0009CF2F /* notifications-like-multiple-avatar.json */; }; 08A250F828D9E87600F50420 /* CommentDetailInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A250F728D9E87600F50420 /* CommentDetailInfoViewController.swift */; }; 08A250F928D9E87600F50420 /* CommentDetailInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A250F728D9E87600F50420 /* CommentDetailInfoViewController.swift */; }; 08A250FC28D9F0E200F50420 /* CommentDetailInfoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A250FB28D9F0E200F50420 /* CommentDetailInfoViewModel.swift */; }; @@ -1857,6 +1864,12 @@ 8070EB3E28D807CB005C6513 /* InMemoryUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8070EB3D28D807CB005C6513 /* InMemoryUserDefaults.swift */; }; 8071390727D039E70012DB21 /* DashboardSingleStatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8071390627D039E70012DB21 /* DashboardSingleStatView.swift */; }; 8071390827D039E70012DB21 /* DashboardSingleStatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8071390627D039E70012DB21 /* DashboardSingleStatView.swift */; }; + 808D10332B88FC5E0082E64F /* LikeableNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808D10322B88FC5E0082E64F /* LikeableNotification.swift */; }; + 808D10342B88FC5E0082E64F /* LikeableNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808D10322B88FC5E0082E64F /* LikeableNotification.swift */; }; + 808D102E2B881BE20082E64F /* String+Truncate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808D102D2B881BE20082E64F /* String+Truncate.swift */; }; + 808D102F2B881BE20082E64F /* String+Truncate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808D102D2B881BE20082E64F /* String+Truncate.swift */; }; + 808D10302B8820920082E64F /* String+Truncate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808D102D2B881BE20082E64F /* String+Truncate.swift */; }; + 808D10312B8820950082E64F /* String+Truncate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808D102D2B881BE20082E64F /* String+Truncate.swift */; }; 808DB70C2A710EFE00EA1645 /* NUXTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808DB70B2A710EFE00EA1645 /* NUXTests.swift */; }; 808DB70D2A710EFE00EA1645 /* NUXTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808DB70B2A710EFE00EA1645 /* NUXTests.swift */; }; 8091019329078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8091019129078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.swift */; }; @@ -3673,6 +3686,8 @@ F41E4EEF28F247D3001880C6 /* white-on-green-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EEA28F247D3001880C6 /* white-on-green-icon-app-60@2x.png */; }; F41E4EF028F247D3001880C6 /* white-on-green-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EEB28F247D3001880C6 /* white-on-green-icon-app-76@2x.png */; }; F42A1D9729928B360059CC70 /* BlockedAuthor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F42A1D9629928B360059CC70 /* BlockedAuthor.swift */; }; + F42A9C662B7111BB0035CBCE /* NotificationsViewController+Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F42A9C652B7111BB0035CBCE /* NotificationsViewController+Strings.swift */; }; + F42A9C672B7111BB0035CBCE /* NotificationsViewController+Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F42A9C652B7111BB0035CBCE /* NotificationsViewController+Strings.swift */; }; F4394D1F2A3AB06F003955C6 /* WPCrashLoggingDataProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4394D1E2A3AB06F003955C6 /* WPCrashLoggingDataProviderTests.swift */; }; F4426FD3287E08C300218003 /* SuggestionServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4426FD2287E08C300218003 /* SuggestionServiceMock.swift */; }; F4426FD9287F02FD00218003 /* SiteSuggestionsServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4426FD8287F02FD00218003 /* SiteSuggestionsServiceMock.swift */; }; @@ -3685,6 +3700,14 @@ F44FB6CB287895AF0001E3CE /* SuggestionsListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F44FB6CA287895AF0001E3CE /* SuggestionsListViewModelTests.swift */; }; F44FB6D12878A1020001E3CE /* user-suggestions.json in Resources */ = {isa = PBXBuildFile; fileRef = F44FB6D02878A1020001E3CE /* user-suggestions.json */; }; F4552086299D147B00D9F6A8 /* BlockedSite.swift in Sources */ = {isa = PBXBuildFile; fileRef = F48D44B5298992C30051EAA6 /* BlockedSite.swift */; }; + F45EB5012B865AF4004E9053 /* NotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F45EB5002B865AF4004E9053 /* NotificationTableViewCell.swift */; }; + F45EB5022B865AF4004E9053 /* NotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F45EB5002B865AF4004E9053 /* NotificationTableViewCell.swift */; }; + F45EB5052B87E0F2004E9053 /* NewPostNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = F45EB5042B87E0F2004E9053 /* NewPostNotification.swift */; }; + F45EB5082B87E167004E9053 /* NewPostNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = F45EB5042B87E0F2004E9053 /* NewPostNotification.swift */; }; + F45EB50B2B883E6E004E9053 /* CommentNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = F45EB50A2B883E6E004E9053 /* CommentNotification.swift */; }; + F45EB50C2B883E6E004E9053 /* CommentNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = F45EB50A2B883E6E004E9053 /* CommentNotification.swift */; }; + F46039182B72A95D00D4FE12 /* NotificationsTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F46039172B72A95D00D4FE12 /* NotificationsTableHeaderView.swift */; }; + F46039192B72A95D00D4FE12 /* NotificationsTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F46039172B72A95D00D4FE12 /* NotificationsTableHeaderView.swift */; }; F46546292AED89790017E3D1 /* AllDomainsListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F46546282AED89790017E3D1 /* AllDomainsListEmptyView.swift */; }; F465462D2AEF22070017E3D1 /* AllDomainsListViewModel+Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F465462C2AEF22070017E3D1 /* AllDomainsListViewModel+Strings.swift */; }; F46546312AF2F8D30017E3D1 /* DomainsStateViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F46546302AF2F8D20017E3D1 /* DomainsStateViewModel.swift */; }; @@ -6034,6 +6057,9 @@ 0857C2741CE5375F0014AE99 /* MenuItemsVisualOrderingView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MenuItemsVisualOrderingView.m; sourceTree = ""; }; 0857C2751CE5375F0014AE99 /* MenuItemView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MenuItemView.h; sourceTree = ""; }; 0857C2761CE5375F0014AE99 /* MenuItemView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MenuItemView.m; sourceTree = ""; }; + 086023D32B73AFD0000D084A /* NotificationsTableViewCellContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableViewCellContent.swift; sourceTree = ""; }; + 086023D62B73B44A000D084A /* HostingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostingTableViewCell.swift; sourceTree = ""; }; + 086023DA2B73BA67000D084A /* AvatarsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarsView.swift; sourceTree = ""; }; 086103951EE09C91004D7C01 /* MediaVideoExporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaVideoExporter.swift; sourceTree = ""; }; 086C117B2A2F6451004A3821 /* CompliancePopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompliancePopover.swift; sourceTree = ""; }; 086C4D0F1E81F9240011D960 /* Media+Blog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Media+Blog.swift"; sourceTree = ""; }; @@ -6046,6 +6072,7 @@ 0885A3661E837AFE00619B4D /* URLIncrementalFilenameTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLIncrementalFilenameTests.swift; sourceTree = ""; }; 088B89881DA6F93B000E8DEF /* ReaderPostCardContentLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderPostCardContentLabel.swift; sourceTree = ""; }; 088CC593282BEC41007B9421 /* TooltipPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TooltipPresenter.swift; sourceTree = ""; }; + 089D4EBD2B7BBE0D0009CF2F /* notifications-like-multiple-avatar.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "notifications-like-multiple-avatar.json"; sourceTree = ""; }; 08A250F728D9E87600F50420 /* CommentDetailInfoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentDetailInfoViewController.swift; sourceTree = ""; }; 08A250FB28D9F0E200F50420 /* CommentDetailInfoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentDetailInfoViewModel.swift; sourceTree = ""; }; 08A2AD781CCED2A800E84454 /* PostTagServiceTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PostTagServiceTests.m; sourceTree = ""; }; @@ -7422,6 +7449,8 @@ 806E53E327E01CFE0064315E /* DashboardStatsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardStatsViewModelTests.swift; sourceTree = ""; }; 8070EB3D28D807CB005C6513 /* InMemoryUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryUserDefaults.swift; sourceTree = ""; }; 8071390627D039E70012DB21 /* DashboardSingleStatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardSingleStatView.swift; sourceTree = ""; }; + 808D10322B88FC5E0082E64F /* LikeableNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikeableNotification.swift; sourceTree = ""; }; + 808D102D2B881BE20082E64F /* String+Truncate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Truncate.swift"; sourceTree = ""; }; 808DB70B2A710EFE00EA1645 /* NUXTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NUXTests.swift; sourceTree = ""; }; 8091019129078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackFullscreenOverlayViewController.swift; sourceTree = ""; }; 8091019229078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = JetpackFullscreenOverlayViewController.xib; sourceTree = ""; }; @@ -9037,6 +9066,7 @@ F41E4EEA28F247D3001880C6 /* white-on-green-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-green-icon-app-60@2x.png"; sourceTree = ""; }; F41E4EEB28F247D3001880C6 /* white-on-green-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-green-icon-app-76@2x.png"; sourceTree = ""; }; F42A1D9629928B360059CC70 /* BlockedAuthor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedAuthor.swift; sourceTree = ""; }; + F42A9C652B7111BB0035CBCE /* NotificationsViewController+Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationsViewController+Strings.swift"; sourceTree = ""; }; F432964A287752690089C4F7 /* WordPress 144.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 144.xcdatamodel"; sourceTree = ""; }; F4394D1E2A3AB06F003955C6 /* WPCrashLoggingDataProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPCrashLoggingDataProviderTests.swift; sourceTree = ""; }; F4426FD2287E08C300218003 /* SuggestionServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionServiceMock.swift; sourceTree = ""; }; @@ -9047,6 +9077,10 @@ F44F6ABD2937428B00DC94A2 /* MigrationEmailService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationEmailService.swift; sourceTree = ""; }; F44FB6CA287895AF0001E3CE /* SuggestionsListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionsListViewModelTests.swift; sourceTree = ""; }; F44FB6D02878A1020001E3CE /* user-suggestions.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "user-suggestions.json"; sourceTree = ""; }; + F45EB5002B865AF4004E9053 /* NotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCell.swift; sourceTree = ""; }; + F45EB5042B87E0F2004E9053 /* NewPostNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewPostNotification.swift; sourceTree = ""; }; + F45EB50A2B883E6E004E9053 /* CommentNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommentNotification.swift; sourceTree = ""; }; + F46039172B72A95D00D4FE12 /* NotificationsTableHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableHeaderView.swift; sourceTree = ""; }; F46546282AED89790017E3D1 /* AllDomainsListEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDomainsListEmptyView.swift; sourceTree = ""; }; F465462C2AEF22070017E3D1 /* AllDomainsListViewModel+Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AllDomainsListViewModel+Strings.swift"; sourceTree = ""; }; F46546302AF2F8D20017E3D1 /* DomainsStateViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DomainsStateViewModel.swift; sourceTree = ""; }; @@ -10088,6 +10122,9 @@ children = ( B5DBE4FD1D21A700002E81D3 /* NotificationsViewController.swift */, 083683DC2B4859BB00331ED0 /* NotificationsViewModel.swift */, + F42A9C652B7111BB0035CBCE /* NotificationsViewController+Strings.swift */, + F46039172B72A95D00D4FE12 /* NotificationsTableHeaderView.swift */, + F45EB5002B865AF4004E9053 /* NotificationTableViewCell.swift */, ); path = NotificationsViewController; sourceTree = ""; @@ -10121,6 +10158,16 @@ name = JetpackOverlay; sourceTree = ""; }; + 086023D92B73BA47000D084A /* NotificationsList */ = { + isa = PBXGroup; + children = ( + 086023DA2B73BA67000D084A /* AvatarsView.swift */, + 086023D62B73B44A000D084A /* HostingTableViewCell.swift */, + 086023D32B73AFD0000D084A /* NotificationsTableViewCellContent.swift */, + ); + path = NotificationsList; + sourceTree = ""; + }; 086C4D0C1E81F7920011D960 /* Media */ = { isa = PBXGroup; children = ( @@ -11038,6 +11085,7 @@ 2F706A870DFB229B00B43086 /* Models */ = { isa = PBXGroup; children = ( + F460391A2B7A67A700D4FE12 /* Comment */, F48D44B4298992A90051EAA6 /* Blocking */, 46963F5724649509000D356D /* Gutenberg */, 9A38DC63218899E4006A409B /* Revisions */, @@ -11069,8 +11117,6 @@ 837B49D4283C2AE80061A657 /* BloggingPromptSettings+CoreDataProperties.swift */, 837B49D5283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataClass.swift */, 837B49D6283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataProperties.swift */, - 98AA6D1026B8CE7200920C8B /* Comment+CoreDataClass.swift */, - 9815D0B226B49A0600DF7226 /* Comment+CoreDataProperties.swift */, E14932B4130427B300154804 /* Coordinate.h */, E14932B5130427B300154804 /* Coordinate.m */, E690F6ED25E05D170015A777 /* InviteLinks+CoreDataClass.swift */, @@ -15322,6 +15368,7 @@ B545186718E9E08000AC3A54 /* Notifications */ = { isa = PBXGroup; children = ( + F45EB5032B87E0E8004E9053 /* Types */, D816C1EA20E0884100C4D82F /* Actions */, 982DDE1226320B4A002B3904 /* Likes */, B5722E401D51A28100F40C5E /* Notification.swift */, @@ -15480,6 +15527,7 @@ FAD1263B2A0CF2F50004E24C /* String+NonbreakingSpace.swift */, B55FFCF91F034F1A0070812C /* String+Ranges.swift */, B54C02231F38F50100574572 /* String+RegEx.swift */, + 808D102D2B881BE20082E64F /* String+Truncate.swift */, B5969E2120A49E86005E9DF1 /* UIAlertController+Helpers.swift */, 1707CE411F3121750020B7FE /* UICollectionViewCell+Tint.swift */, E1823E6B1E42231C00C19F53 /* UIEdgeInsets.swift */, @@ -15531,6 +15579,7 @@ B58CE5DD1DC1284C004AA81D /* Notifications */ = { isa = PBXGroup; children = ( + 089D4EBD2B7BBE0D0009CF2F /* notifications-like-multiple-avatar.json */, E150275E1E03E51500B847E3 /* notes-action-delete.json */, E150275F1E03E51500B847E3 /* notes-action-push.json */, E15027601E03E51500B847E3 /* notes-action-unsupported.json */, @@ -17572,6 +17621,25 @@ name = Suggestions; sourceTree = ""; }; + F45EB5032B87E0E8004E9053 /* Types */ = { + isa = PBXGroup; + children = ( + 808D10322B88FC5E0082E64F /* LikeableNotification.swift */, + F45EB50A2B883E6E004E9053 /* CommentNotification.swift */, + F45EB5042B87E0F2004E9053 /* NewPostNotification.swift */, + ); + path = Types; + sourceTree = ""; + }; + F460391A2B7A67A700D4FE12 /* Comment */ = { + isa = PBXGroup; + children = ( + 98AA6D1026B8CE7200920C8B /* Comment+CoreDataClass.swift */, + 9815D0B226B49A0600DF7226 /* Comment+CoreDataProperties.swift */, + ); + path = Comment; + sourceTree = ""; + }; F465976528E464DE00D5F49A /* Icons */ = { isa = PBXGroup; children = ( @@ -18570,6 +18638,7 @@ FEA087FB2696DDE900193358 /* List */ = { isa = PBXGroup; children = ( + 086023D92B73BA47000D084A /* NotificationsList */, FEA088002696E7F600193358 /* ListTableHeaderView.swift */, FEA088022696E81F00193358 /* ListTableHeaderView.xib */, FE39C134269C37C900EFB827 /* ListTableViewCell.swift */, @@ -19759,6 +19828,7 @@ 8554088A1A6F107D00DDBD79 /* app-review-prompt-global-disable.json in Resources */, D8A468E02181C6450094B82F /* site-segment.json in Resources */, 7E4A772B20F7E5FD001C706D /* activity-log-site-content.json in Resources */, + 089D4EBE2B7BBE0D0009CF2F /* notifications-like-multiple-avatar.json in Resources */, 8BEE845A27B1DC9D0001A93C /* dashboard-200-with-drafts-and-scheduled.json in Resources */, 7EF2EEA0210A67B60007A76B /* notifications-unapproved-comment.json in Resources */, 175CC1772721814C00622FB4 /* domain-service-updated-domains.json in Resources */, @@ -21390,6 +21460,7 @@ 4A9314E7297A0C5000360232 /* PostCategory+Lookup.swift in Sources */, 738B9A5C21B85EB00005062B /* UIView+ContentLayout.swift in Sources */, E1B9128F1BB05B1D003C25B9 /* PeopleCell.swift in Sources */, + 086023D42B73AFD0000D084A /* NotificationsTableViewCellContent.swift in Sources */, F15272FD243B27BC00C8DC7A /* AbstractPost+Local.swift in Sources */, 3F43603123F31E09001DEE70 /* MeScenePresenter.swift in Sources */, FA98B61629A3B76A0071AAE8 /* DashboardBlazeCardCell.swift in Sources */, @@ -21693,6 +21764,7 @@ 98563DDD21BF30C40006F5E9 /* TabbedTotalsCell.swift in Sources */, 82FC61241FA8ADAD00A1757E /* ActivityTableViewCell.swift in Sources */, 435D101A2130C2AB00BB2AA8 /* BlogDetailsViewController+FancyAlerts.swift in Sources */, + 086023D72B73B44A000D084A /* HostingTableViewCell.swift in Sources */, 8000361D292468D4007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel.swift in Sources */, 0C23F33E2AC4AEF600EE6117 /* SiteMediaPickerViewController.swift in Sources */, F1E72EBA267790110066FF91 /* UIViewController+Dismissal.swift in Sources */, @@ -21704,6 +21776,7 @@ 1790A4531E28F0ED00AE54C2 /* UINavigationController+Helpers.swift in Sources */, FEA088052696F7AA00193358 /* WPStyleGuide+List.swift in Sources */, 17C2FF0925D4852400CDB712 /* UnifiedProloguePages.swift in Sources */, + 808D10332B88FC5E0082E64F /* LikeableNotification.swift in Sources */, FE5096592A17A69F00DDD071 /* TwitterDeprecationTableFooterView.swift in Sources */, 08D345561CD7FBA900358E8C /* MenuHeaderViewController.m in Sources */, 088CC594282BEC41007B9421 /* TooltipPresenter.swift in Sources */, @@ -21756,6 +21829,7 @@ E1389ADB1C59F7C200FB2466 /* PlanListViewController.swift in Sources */, 80A2153D29C35197002FE8EB /* StaticScreensTabBarWrapper.swift in Sources */, 1E485A90249B61440000A253 /* GutenbergRequestAuthenticator.swift in Sources */, + F45EB5052B87E0F2004E9053 /* NewPostNotification.swift in Sources */, 084FC3BC299155C900A17BCF /* JetpackOverlayCoordinator.swift in Sources */, 0CAE8EF62A9E9EE30073EEB9 /* SiteMediaCollectionCellViewModel.swift in Sources */, 8370D10A11FA499A009D650F /* WPTableViewActivityCell.m in Sources */, @@ -22141,6 +22215,7 @@ B5EFB1C21B31B98E007608A3 /* NotificationSettingsService.swift in Sources */, 011896A829D5BBB400D34BA9 /* DomainsDashboardFactory.swift in Sources */, 011F52BD2A15327700B04114 /* BaseDashboardDomainsCardCell.swift in Sources */, + F45EB5012B865AF4004E9053 /* NotificationTableViewCell.swift in Sources */, 1717139F265FE59700F3A022 /* ButtonStyles.swift in Sources */, C3FF78E828354A91008FA600 /* SiteDesignSectionLoader.swift in Sources */, 803BB989295B80D300B3F6D6 /* RootViewPresenter+EditorNavigation.swift in Sources */, @@ -22288,6 +22363,7 @@ 80EF672527F3D63B0063B138 /* DashboardStatsStackView.swift in Sources */, E1D0D81616D3B86800E33F4C /* SafariActivity.m in Sources */, E603C7701BC94AED00AD49D7 /* WordPress-37-38.xcmappingmodel in Sources */, + 808D102E2B881BE20082E64F /* String+Truncate.swift in Sources */, 01E258022ACC36FA00F09666 /* PlanStep.swift in Sources */, 741E22461FC0CC55007967AB /* UploadOperation.swift in Sources */, 46C984682527863E00988BB9 /* LayoutPickerAnalyticsEvent.swift in Sources */, @@ -22374,6 +22450,7 @@ 7EBB4126206C388100012D98 /* StockPhotosService.swift in Sources */, E17FEADA221494B2006E1D2D /* Blog+Analytics.swift in Sources */, E69551F61B8B6AE200CB8E4F /* ReaderStreamViewController+Helper.swift in Sources */, + F46039182B72A95D00D4FE12 /* NotificationsTableHeaderView.swift in Sources */, E61084BE1B9B47BA008050C5 /* ReaderAbstractTopic.swift in Sources */, 8379669F299D51EC004A92B9 /* JetpackPlugin.swift in Sources */, 2F08ECFC2283A4FB000F8E11 /* PostService+UnattachedMedia.swift in Sources */, @@ -22866,6 +22943,7 @@ 4AFB1A812A9C08CE007CE165 /* StoppableProgressIndicatorView.swift in Sources */, B522C4F81B3DA79B00E47B59 /* NotificationSettingsViewController.swift in Sources */, FEC26033283FC902003D886A /* RootViewCoordinator+BloggingPrompt.swift in Sources */, + F45EB50B2B883E6E004E9053 /* CommentNotification.swift in Sources */, 93E63369272AC532009DACF8 /* LoginEpilogueChooseSiteTableViewCell.swift in Sources */, 9A51DA1522E9E8C7005CC335 /* ChangeUsernameViewController.swift in Sources */, 0CF0C4232AE98C13006FFAB4 /* AbstractPostHelper.swift in Sources */, @@ -22877,6 +22955,7 @@ 4319AADE2090F00C0025D68E /* FancyAlertViewController+NotificationPrimer.swift in Sources */, 73FF7032221F469100541798 /* Charts+Support.swift in Sources */, F16601C423E9E783007950AE /* SharingAuthorizationWebViewController.swift in Sources */, + F42A9C662B7111BB0035CBCE /* NotificationsViewController+Strings.swift in Sources */, 171963401D378D5100898E8B /* SearchWrapperView.swift in Sources */, 0830538C2B2732E400B889FE /* DynamicDashboardCard.swift in Sources */, 241E60B325CA0D2900912CEB /* UserSettings.swift in Sources */, @@ -23036,6 +23115,7 @@ F4CBE3D6292597E3004FFBB6 /* SupportTableViewControllerConfiguration.swift in Sources */, 8F22804451E5812433733348 /* TimeZoneSearchHeaderView.swift in Sources */, 8332DD2429259AE300802F7D /* DataMigrator.swift in Sources */, + 086023DB2B73BA67000D084A /* AvatarsView.swift in Sources */, 8F228F2923045666AE456D2C /* TimeZoneSelectorViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -23172,6 +23252,7 @@ 09DBEA55281336E10019724E /* AppLocalizedString.swift in Sources */, 433ADC1A223B2A7D00ED9DE1 /* TextBundleWrapper.m in Sources */, 7335AC6C21220F050012EF2D /* FormattableNoticonRange.swift in Sources */, + 808D10302B8820920082E64F /* String+Truncate.swift in Sources */, 73F6DD42212BA54700CE447D /* RichNotificationContentFormatter.swift in Sources */, 73ACDF9C2118AF7000233AD4 /* SFHFKeychainUtils.m in Sources */, 7335AC5921220AC40012EF2D /* FormattableContent.swift in Sources */, @@ -23446,6 +23527,7 @@ 80F6D03528EE866A00953C1A /* AppLocalizedString.swift in Sources */, 80F6D03628EE866A00953C1A /* TextBundleWrapper.m in Sources */, 80F6D03728EE866A00953C1A /* FormattableNoticonRange.swift in Sources */, + 808D10312B8820950082E64F /* String+Truncate.swift in Sources */, 80F6D03828EE866A00953C1A /* RichNotificationContentFormatter.swift in Sources */, 80F6D03928EE866A00953C1A /* SFHFKeychainUtils.m in Sources */, 80F6D03A28EE866A00953C1A /* FormattableContent.swift in Sources */, @@ -24220,6 +24302,7 @@ FABB21692602FC2C00C8785C /* StockPhotosStrings.swift in Sources */, FABB216A2602FC2C00C8785C /* StatsViewController.m in Sources */, 08AA64062A84FFF40076E38D /* DashboardGoogleDomainsViewModel.swift in Sources */, + F45EB5022B865AF4004E9053 /* NotificationTableViewCell.swift in Sources */, 011F52C42A153A3400B04114 /* FreeToPaidPlansDashboardCardHelper.swift in Sources */, DC8F61F827032B3F0087AC5D /* TimeZoneFormatter.swift in Sources */, FABB216B2602FC2C00C8785C /* WPRichTextEmbed.swift in Sources */, @@ -24511,6 +24594,7 @@ FAA9084D27BD60710093FFA8 /* MySiteViewController+QuickStart.swift in Sources */, FABB223C2602FC2C00C8785C /* EditCommentViewController.m in Sources */, FABB223E2602FC2C00C8785C /* PostAutoUploadMessageProvider.swift in Sources */, + F42A9C672B7111BB0035CBCE /* NotificationsViewController+Strings.swift in Sources */, FABB223F2602FC2C00C8785C /* GutenbergMediaPickerHelper.swift in Sources */, 0CB424EF2ADEE3CD0080B807 /* PostSearchTokenTableCell.swift in Sources */, FABB22402602FC2C00C8785C /* FeatureFlag.swift in Sources */, @@ -24789,6 +24873,7 @@ FABB23032602FC2C00C8785C /* CountriesMapCell.swift in Sources */, FABB23042602FC2C00C8785C /* NavigationTitleView.swift in Sources */, 3FE3D1FE26A6F4AC00F3CD10 /* ListTableHeaderView.swift in Sources */, + 086023D82B73B44A000D084A /* HostingTableViewCell.swift in Sources */, 8B15D27528009EBF0076628A /* BlogDashboardAnalytics.swift in Sources */, FABB23052602FC2C00C8785C /* WPAnalyticsEvent.swift in Sources */, FABB23062602FC2C00C8785C /* Coordinate.m in Sources */, @@ -24808,9 +24893,12 @@ FABB230F2602FC2C00C8785C /* RestoreStatusFailedView.swift in Sources */, FEFA263F26C5AE9A009CCB7E /* ShareAppContentPresenter.swift in Sources */, FABB23112602FC2C00C8785C /* PostingActivityLegend.swift in Sources */, + 808D10342B88FC5E0082E64F /* LikeableNotification.swift in Sources */, 8B92D69727CD51FA001F5371 /* DashboardGhostCardCell.swift in Sources */, FABB23122602FC2C00C8785C /* WPImmuTableRows.swift in Sources */, + 808D102F2B881BE20082E64F /* String+Truncate.swift in Sources */, FEFA6AC42A83F4BE004EE5E6 /* PostHelper+JetpackSocial.swift in Sources */, + 086023D52B73AFD0000D084A /* NotificationsTableViewCellContent.swift in Sources */, 800035BE291DD0D7007D2D26 /* JetpackFullscreenOverlayGeneralViewModel+Analytics.swift in Sources */, 08CBC77A29AE6CC4000026E7 /* SiteCreationEmptySiteTemplate.swift in Sources */, FABB23132602FC2C00C8785C /* WordPress-87-88.xcmappingmodel in Sources */, @@ -24872,6 +24960,7 @@ FABB23382602FC2C00C8785C /* WordPress-22-23.xcmappingmodel in Sources */, FED65D79293511E4008071BF /* SharedDataIssueSolver.swift in Sources */, FABB23392602FC2C00C8785C /* CountriesMap.swift in Sources */, + F45EB50C2B883E6E004E9053 /* CommentNotification.swift in Sources */, 98BC522527F6245700D6E8C2 /* BloggingPromptsFeatureIntroduction.swift in Sources */, FABB233A2602FC2C00C8785C /* RevisionsNavigationController.swift in Sources */, FE3D058426C419C7002A51B0 /* ShareAppContentPresenter+TableView.swift in Sources */, @@ -25077,9 +25166,11 @@ 0CB424F22ADEE52A0080B807 /* PostSearchToken.swift in Sources */, FABB23D52602FC2C00C8785C /* SiteCreationRequest+Validation.swift in Sources */, FABB23D62602FC2C00C8785C /* FormattableContentGroup.swift in Sources */, + F46039192B72A95D00D4FE12 /* NotificationsTableHeaderView.swift in Sources */, FABB23D72602FC2C00C8785C /* MenuItemSourceViewController.m in Sources */, FABB23D82602FC2C00C8785C /* WPReusableTableViewCells.swift in Sources */, FABB23D92602FC2C00C8785C /* HomepageSettingsService.swift in Sources */, + 086023DC2B73BA67000D084A /* AvatarsView.swift in Sources */, FABB23DA2602FC2C00C8785C /* ReaderSearchSuggestionsViewController.swift in Sources */, FABB23DB2602FC2C00C8785C /* SentryStartupEvent.swift in Sources */, FABB23DC2602FC2C00C8785C /* JetpackScanThreatCell.swift in Sources */, @@ -25175,6 +25266,7 @@ FABB24222602FC2C00C8785C /* Uploader.swift in Sources */, FABB24232602FC2C00C8785C /* JetpackRestoreService.swift in Sources */, FABB24242602FC2C00C8785C /* DateAndTimeFormatSettingsViewController.swift in Sources */, + F45EB5082B87E167004E9053 /* NewPostNotification.swift in Sources */, FABB24252602FC2C00C8785C /* WPContentSyncHelper.swift in Sources */, 830A58D92793AB4500CDE94F /* LoginEpilogueAnimator.swift in Sources */, FABB24262602FC2C00C8785C /* SiteAssemblyStep.swift in Sources */, diff --git a/WordPress/WordPressNotificationServiceExtension/Sources/NotificationService.swift b/WordPress/WordPressNotificationServiceExtension/Sources/NotificationService.swift index b3c4acb92b2a..1a26be4947e1 100644 --- a/WordPress/WordPressNotificationServiceExtension/Sources/NotificationService.swift +++ b/WordPress/WordPressNotificationServiceExtension/Sources/NotificationService.swift @@ -141,7 +141,7 @@ class NotificationService: UNNotificationServiceExtension { notificationContent.title = contentFormatter.attributedSubject?.string ?? apsAlert // Improve the notification body by trimming whitespace and reducing any multiple blank lines - notificationContent.body = contentFormatter.body?.condenseWhitespace() ?? "" + notificationContent.body = contentFormatter.attributedBody?.string.condenseWhitespace().truncate(with: 256) ?? "" } notificationContent.userInfo[CodingUserInfoKey.richNotificationViewModel.rawValue] = viewModel.data diff --git a/WordPress/WordPressTest/AnalyticsEventTrackingSpy.swift b/WordPress/WordPressTest/AnalyticsEventTrackingSpy.swift index 778117e7fc3c..e8af4f8c175b 100644 --- a/WordPress/WordPressTest/AnalyticsEventTrackingSpy.swift +++ b/WordPress/WordPressTest/AnalyticsEventTrackingSpy.swift @@ -8,6 +8,10 @@ class AnalyticsEventTrackingSpy: AnalyticsEventTracking { trackedEvents.append(event) } + static func track(_ event: WPAnalyticsEvent, properties: [AnyHashable: Any]) { + track(.init(name: event.value, properties: properties as? [String: String] ?? [:])) + } + static func reset() { trackedEvents = [] } diff --git a/WordPress/WordPressTest/NotificationTests.swift b/WordPress/WordPressTest/NotificationTests.swift index e23ac8182f5a..462c040d78ef 100644 --- a/WordPress/WordPressTest/NotificationTests.swift +++ b/WordPress/WordPressTest/NotificationTests.swift @@ -84,6 +84,11 @@ class NotificationTests: CoreDataTestCase { XCTAssertNotNil(note.metaPostID) } + func testAllAvatarURLsReturnMultipleURLs() throws { + let note = try loadLikeMultipleAvatarNotification() + XCTAssertEqual(note.allAvatarURLs.count, 3) + } + func testFollowerNotificationReturnsTheProperKindValue() throws { let note = try loadFollowerNotification() XCTAssert(note.kind == .follow) @@ -267,6 +272,10 @@ class NotificationTests: CoreDataTestCase { return try utility.loadLikeNotification() } + func loadLikeMultipleAvatarNotification() throws -> WordPress.Notification { + return try utility.loadLikeMultipleAvatarNotification() + } + func loadFollowerNotification() throws -> WordPress.Notification { return try utility.loadFollowerNotification() } diff --git a/WordPress/WordPressTest/NotificationUtility.swift b/WordPress/WordPressTest/NotificationUtility.swift index 437d96400b89..baf1c37ecc80 100644 --- a/WordPress/WordPressTest/NotificationUtility.swift +++ b/WordPress/WordPressTest/NotificationUtility.swift @@ -25,6 +25,10 @@ class NotificationUtility { return try .fixture(fromFile: "notifications-like.json", insertInto: context) } + func loadLikeMultipleAvatarNotification() throws -> WordPress.Notification { + return try .fixture(fromFile: "notifications-like-multiple-avatar.json", insertInto: context) + } + func loadFollowerNotification() throws -> WordPress.Notification { return try .fixture(fromFile: "notifications-new-follower.json", insertInto: context) } diff --git a/WordPress/WordPressTest/NotificationsViewModelTests.swift b/WordPress/WordPressTest/NotificationsViewModelTests.swift index d538b03d8cbb..b9a932cda799 100644 --- a/WordPress/WordPressTest/NotificationsViewModelTests.swift +++ b/WordPress/WordPressTest/NotificationsViewModelTests.swift @@ -81,4 +81,15 @@ final class MockNotificationSyncMediator: NotificationSyncMediatorProtocol { func updateLastSeen(_ timestamp: String, completion: ((Error?) -> Void)?) { completion?(nil) } + + func toggleLikeForPostNotification(isLike: Bool, postID: UInt, siteID: UInt, completion: @escaping (Result) -> Void) { + completion(.success(true)) + } + + func toggleLikeForCommentNotification(isLike: Bool, + commentID: UInt, + siteID: UInt, + completion: @escaping (Result) -> Void) { + completion(.success(true)) + } } diff --git a/WordPress/WordPressTest/Test Data/notifications-like-multiple-avatar.json b/WordPress/WordPressTest/Test Data/notifications-like-multiple-avatar.json new file mode 100644 index 000000000000..9cd2e1280c58 --- /dev/null +++ b/WordPress/WordPressTest/Test Data/notifications-like-multiple-avatar.json @@ -0,0 +1,232 @@ +{ + "notificationId": "11111", + "type": "like", + "read": 0, + "noticon": "\uf408", + "timestamp": "2015-03-28T00:23:41+00:00", + "icon": "https:\/\/2.gravatar.com\/avatar\/33333333", + "url": "https:\/\/somethingthatmightnotexist.wordpress.com\/2015\/03\/26\/something\/", + "subject": [ + { + "text": "XXXX and 2 others liked your post YYYYY", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 3 + ], + "url": "http:\/\/something.com", + "site_id": 4836651, + "email": "some@thing.com", + "id": 5073742 + }, + { + "type": "post", + "indices": [ + 33, + 49 + ], + "url": "https:\/\/somethingthatmightnotexist.wordpress.com\/2015\/03\/26\/something\/", + "site_id": 444444, + "id": 44 + } + ] + } + ], + "body": [ + { + "text": "XXX", + "ranges": [ + { + "email": "some@thing.com", + "url": "http:\/\/something.com", + "id": 44444, + "site_id": 55555, + "type": "user", + "indices": [ + 0, + 3 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https:\/\/2.gravatar.com\/avatar\/292929292929" + } + ], + "actions": { + "follow": true + }, + "meta": { + "links": { + "email": "some@thing.com", + "home": "http:\/\/something.com" + }, + "ids": { + "user": 1919191, + "site": 2929292 + }, + "titles": { + "home": "Something" + } + }, + "type": "user" + }, + { + "text": "XXX", + "ranges": [ + { + "email": "some@thing.com", + "url": "http:\/\/something.com", + "id": 44444, + "site_id": 55555, + "type": "user", + "indices": [ + 0, + 3 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https:\/\/2.gravatar.com\/avatar\/292929292929" + } + ], + "actions": { + "follow": true + }, + "meta": { + "links": { + "email": "some@thing.com", + "home": "http:\/\/something.com" + }, + "ids": { + "user": 1919191, + "site": 2929292 + }, + "titles": { + "home": "Something" + } + }, + "type": "user" + }, + { + "text": "XXX", + "ranges": [ + { + "email": "some@thing.com", + "url": "http:\/\/something.com", + "id": 44444, + "site_id": 55555, + "type": "user", + "indices": [ + 0, + 3 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https:\/\/2.gravatar.com\/avatar\/292929292929" + } + ], + "actions": { + "follow": true + }, + "meta": { + "links": { + "email": "some@thing.com", + "home": "http:\/\/something.com" + }, + "ids": { + "user": 1919191, + "site": 2929292 + }, + "titles": { + "home": "Something" + } + }, + "type": "user" + }, + { + "text": "View likes on your post.", + "ranges": [ + { + "type": "post", + "indices": [ + 14, + 23 + ], + "id": 16082, + "parent": null, + "url": "https:\/\/somethingthatmightnotexist.wordpress.com\/2015\/03\/26\/something\/", + "site_id": 180619633 + } + ] + } + ], + "meta": { + "ids": { + "site": 23123129321, + "post": 972 + }, + "links": { + "site": "https:\/\/public-api.wordpress.com\/rest\/v1\/sites\/321312", + "post": "https:\/\/public-api.wordpress.com\/rest\/v1\/posts\/292912" + } + }, + "title": "1 Like", + "header": [ + { + "text": "Your Name", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "email": "you@domain.com", + "id": 2929292939 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https:\/\/2.gravatar.com\/avatar\/1929230392012" + } + ] + }, + { + "text": "Some Title!" + } + ] +}