Skip to content

Commit 6331260

Browse files
committed
Add plugin installation prompt
1 parent fb5bf91 commit 6331260

File tree

9 files changed

+706
-6
lines changed

9 files changed

+706
-6
lines changed

Modules/Package.resolved

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Modules/Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ let package = Package(
2020
.library(name: "WordPressShared", targets: ["WordPressShared"]),
2121
.library(name: "WordPressUI", targets: ["WordPressUI"]),
2222
.library(name: "WordPressReader", targets: ["WordPressReader"]),
23+
.library(name: "WordPressCore", targets: ["WordPressCore"]),
2324
],
2425
dependencies: [
2526
.package(url: "https://github.com/airbnb/lottie-ios", from: "4.4.0"),
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import Foundation
2+
import WordPressAPI
3+
import WordPressAPIInternal
4+
5+
public struct RecommendedPlugin {
6+
7+
/// The plugin name – this will be inserted into headers and buttons
8+
public let name: String
9+
10+
/// The plugin slug – this is its identifier in the WordPress.org Plugins Directory
11+
public let slug: String
12+
13+
/// An explanation of what you're asking the user to do.
14+
///
15+
/// For example:
16+
/// - Gutenberg Required
17+
/// - Install Jetpack for a better experience
18+
public let usageTitle: String
19+
20+
/// An explanation for how installing this plugin will help the user.
21+
///
22+
/// This is _not_ the plugin's description from the WP.org directory.
23+
public let usageDescription: String
24+
25+
/// An explanation for the new capabilities the user has because this plugin was installed.
26+
public let successMessage: String
27+
28+
/// The banner image for this plugin
29+
public let imageUrl: URL?
30+
31+
/// URL to a help article explaining why this is needed
32+
public let helpUrl: URL
33+
34+
public init(
35+
name: String,
36+
slug: String,
37+
usageTitle: String,
38+
usageDescription: String,
39+
successMessage: String,
40+
imageUrl: URL?,
41+
helpUrl: URL
42+
) {
43+
self.name = name
44+
self.slug = slug
45+
self.usageTitle = usageTitle
46+
self.usageDescription = usageDescription
47+
self.successMessage = successMessage
48+
self.imageUrl = imageUrl
49+
self.helpUrl = helpUrl
50+
}
51+
}
52+
53+
public actor PluginRecommendationService {
54+
55+
public enum Feature: CaseIterable {
56+
case themeStyles
57+
case postPreviews
58+
case editorCompatibility
59+
60+
var explanation: String {
61+
switch self {
62+
case .themeStyles: NSLocalizedString(
63+
"org.wordpress.plugin-recommendations.explanations.gutenberg-for-theme-styles",
64+
value: "The Gutenberg Plugin is required to use your theme's styles in the editor.",
65+
comment: "A short message explaining why we're recommending this plugin"
66+
)
67+
case .postPreviews: NSLocalizedString(
68+
"org.wordpress.plugin-recommendations.explanations.jetpack-for-post-previews",
69+
value: "The Jetpack Plugin is required for post previews.",
70+
comment: "A short message explaining why we're recommending this plugin"
71+
)
72+
case .editorCompatibility: NSLocalizedString(
73+
"org.wordpress.plugin-recommendations.explanations.jetpack-for-editor-compatibility",
74+
value: "The Jetpack Plugin improves compatibility with plugins that provide blocks.",
75+
comment: "A short message explaining why we're recommending this plugin"
76+
)
77+
}
78+
}
79+
80+
var successMessage: String {
81+
return switch self {
82+
case .themeStyles: NSLocalizedString(
83+
"org.wordpress.plugin-recommendations.success.theme-styles",
84+
value: "The editor will now display content exactly how it appears on your site.",
85+
comment: "A short message explaining what the user can do now that they've installed this plugin"
86+
)
87+
case .postPreviews: NSLocalizedString(
88+
"org.wordpress.plugin-recommendations.success.post-previews",
89+
value: "You can now preview posts within the app.",
90+
comment: "A short message explaining what the user can do now that they've installed this plugin"
91+
)
92+
case .editorCompatibility: NSLocalizedString(
93+
"org.wordpress.plugin-recommendations.success.editor-compatibility",
94+
value: "Your blocks will render correctly in the editor.",
95+
comment: "A short message explaining what the user can do now that they've installed this plugin"
96+
)
97+
}
98+
}
99+
100+
var helpArticleUrl: URL {
101+
// TODO: We need to write these articles and update the URLs
102+
let url = switch self {
103+
case .themeStyles: "https://wordpress.com/support/plugins/install-a-plugin/"
104+
case .postPreviews: "https://wordpress.com/support/plugins/install-a-plugin/"
105+
case .editorCompatibility: "https://wordpress.com/support/plugins/install-a-plugin/"
106+
}
107+
108+
return URL(string: url)!
109+
}
110+
111+
var recommendedPlugin: PluginWpOrgDirectorySlug {
112+
let slug = switch self {
113+
case .themeStyles: "gutenberg"
114+
case .postPreviews: "jetpack"
115+
case .editorCompatibility: "jetpack"
116+
}
117+
118+
return PluginWpOrgDirectorySlug(slug: slug)
119+
}
120+
121+
fileprivate var cacheKey: String {
122+
return "plugin-recommendation-\(self)-\(recommendedPlugin.slug)"
123+
}
124+
}
125+
126+
public enum Frequency {
127+
case daily
128+
case weekly
129+
case monthly
130+
131+
var timeInterval: TimeInterval {
132+
return switch self {
133+
case .daily: 86_400
134+
case .weekly: 604_800
135+
case .monthly: 14_515_200
136+
}
137+
}
138+
}
139+
140+
private let dotOrgClient: WordPressOrgApiClient
141+
private let userDefaults: UserDefaults
142+
143+
public init(
144+
dotOrgClient: WordPressOrgApiClient = WordPressOrgApiClient(urlSession: .shared),
145+
userDefaults: UserDefaults = .standard
146+
) {
147+
self.dotOrgClient = dotOrgClient
148+
self.userDefaults = userDefaults
149+
}
150+
151+
public func recommendedPluginSlug(for feature: Feature) async throws -> PluginWpOrgDirectorySlug {
152+
feature.recommendedPlugin
153+
}
154+
155+
public func recommendPlugin(for feature: Feature) async throws -> RecommendedPlugin {
156+
let plugin = try await dotOrgClient.pluginInformation(slug: feature.recommendedPlugin)
157+
158+
return RecommendedPlugin(
159+
name: plugin.name,
160+
slug: plugin.slug.slug,
161+
usageTitle: "Install \(plugin.name)",
162+
usageDescription: feature.explanation,
163+
successMessage: feature.successMessage,
164+
imageUrl: try await cachePluginHeader(for: plugin),
165+
helpUrl: feature.helpArticleUrl
166+
)
167+
}
168+
169+
public func shouldRecommendPlugin(for feature: Feature, frequency: Frequency) -> Bool {
170+
let featureTimestamp = self.userDefaults.double(forKey: feature.cacheKey)
171+
let globalTimestamp = self.userDefaults.double(forKey: "plugin-last-recommended")
172+
173+
if featureTimestamp == 0 && globalTimestamp == 0 {
174+
return true
175+
}
176+
177+
let earliestFeatureDate = Date().timeIntervalSince1970 - frequency.timeInterval
178+
let earliestGlobalDate = Date().timeIntervalSince1970 - 86_400
179+
180+
return earliestFeatureDate > featureTimestamp && earliestGlobalDate > globalTimestamp
181+
}
182+
183+
public func didRecommendPlugin(for feature: Feature, at date: Date = Date()) {
184+
self.userDefaults.set(date.timeIntervalSince1970, forKey: feature.cacheKey)
185+
self.userDefaults.set(date.timeIntervalSince1970, forKey: "plugin-last-recommended")
186+
}
187+
188+
public func resetRecommendations() {
189+
for feature in Feature.allCases {
190+
self.userDefaults.removeObject(forKey: feature.cacheKey)
191+
}
192+
self.userDefaults.removeObject(forKey: "plugin-last-recommended")
193+
}
194+
195+
private func cachePluginHeader(for plugin: PluginInformation) async throws -> URL? {
196+
guard let pluginUrl = plugin.bannerUrl, let bannerFileName = plugin.bannerFileName else {
197+
return nil
198+
}
199+
200+
let cachePath = self.storagePath(for: plugin, filename: bannerFileName)
201+
return try await cacheAsset(pluginUrl, at: cachePath)
202+
}
203+
204+
private func cacheAsset(_ url: URL, at path: URL) async throws -> URL {
205+
if FileManager.default.fileExists(at: path) {
206+
return path
207+
}
208+
209+
let (tempPath, _) = try await URLSession.shared.download(from: url)
210+
try FileManager.default.moveItem(at: tempPath, to: path)
211+
212+
return path
213+
}
214+
215+
private func storagePath(for plugin: PluginInformation, filename: String) -> URL {
216+
URL.cachesDirectory
217+
.appendingPathComponent("plugin-assets")
218+
.appendingPathComponent(plugin.slug.slug)
219+
.appendingPathComponent(filename)
220+
}
221+
}
222+
223+
fileprivate extension PluginInformation {
224+
var bannerFileName: String? {
225+
bannerUrl?.lastPathComponent
226+
}
227+
228+
var bannerUrl: URL? {
229+
URL(string: self.banners.high)
230+
}
231+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Testing
2+
import Foundation
3+
import WordPressCore
4+
5+
@Suite(.serialized)
6+
struct PluginRecommendationServiceTests {
7+
let service: PluginRecommendationService
8+
9+
init() async {
10+
self.service = PluginRecommendationService(userDefaults: UserDefaults())
11+
await self.service.resetRecommendations()
12+
}
13+
14+
@Test func `test recommendations should always be shown if none have been shown before`() async throws {
15+
#expect(await service.shouldRecommendPlugin(for: .themeStyles, frequency: .monthly))
16+
}
17+
18+
@Test func `test recommendations shouldn't be shown if they have been shown within the given frequency`() async throws {
19+
await service.didRecommendPlugin(for: .themeStyles)
20+
#expect(await service.shouldRecommendPlugin(for: .themeStyles, frequency: .daily) == false)
21+
}
22+
23+
@Test func `test recommendations should be shown again once the cooldown period has passed`() async throws {
24+
await service.didRecommendPlugin(for: .themeStyles, at: Date().addingTimeInterval(-100_000))
25+
#expect(await service.shouldRecommendPlugin(for: .themeStyles, frequency: .daily))
26+
}
27+
28+
@Test func `test recommendations can be reset`() async throws {
29+
await service.didRecommendPlugin(for: .themeStyles)
30+
await service.resetRecommendations()
31+
#expect(await service.shouldRecommendPlugin(for: .themeStyles, frequency: .daily))
32+
}
33+
34+
@Test func `test that only one notification type is shown per day`() async throws {
35+
await service.didRecommendPlugin(for: .themeStyles)
36+
#expect(await service.shouldRecommendPlugin(for: .editorCompatibility, frequency: .daily) == false)
37+
}
38+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import WordPressAPI
2+
import WordPressCore
3+
4+
extension WordPressClient: PluginInstallerProtocol {
5+
func installAndActivatePlugin(slug: String) async throws {
6+
let params = PluginCreateParams(
7+
slug: PluginWpOrgDirectorySlug(slug: slug),
8+
status: .active
9+
)
10+
11+
_ = try await self.api.plugins.create(params: params)
12+
}
13+
}

0 commit comments

Comments
 (0)