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 @@
-
+
-
+
@@ -119,7 +119,7 @@
-
+
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!"
+ }
+ ]
+}