Skip to content

Commit fc82c2d

Browse files
committed
Add App Shortcuts with navigation intents to the Jetpack app
Four App Intents (new post, notifications, stats, reader) open their screens through RootViewCoordinator, mirroring the 3D Touch quick actions; each requires a signed-in account because without one the shared presenter builds a detached view hierarchy and the intent silently does nothing while reporting success. The stats intent takes an optional site parameter backed by the widget SiteEntity, and an explicit but unresolvable site reports an error rather than opening the wrong site. The AppShortcutsProvider and intents live in the Jetpack-only sources folder so the WordPress app does not expose them. Strings are keyed with the ios-appintents. prefix backed by a hand-maintained GlotPress source file, and Siri invocation phrases get an AppShortcuts.strings table in the app bundle, following the ios-widget. precedent.
1 parent fa8a9ed commit fc82c2d

11 files changed

Lines changed: 332 additions & 1 deletion

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import AppIntents
2+
import Foundation
3+
4+
/// Errors surfaced to the system when an open intent cannot proceed.
5+
enum AppIntentOpenError: Error, CustomLocalizedStringResourceConvertible {
6+
case notLoggedIn
7+
case siteNotFound
8+
9+
var localizedStringResource: LocalizedStringResource {
10+
switch self {
11+
case .notLoggedIn:
12+
return LocalizedStringResource(
13+
"ios-appintents.openError.notLoggedIn",
14+
defaultValue: "Sign in to the app first to use this action."
15+
)
16+
case .siteNotFound:
17+
return LocalizedStringResource(
18+
"ios-appintents.openError.siteNotFound",
19+
defaultValue: "The site could not be found."
20+
)
21+
}
22+
}
23+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import AppIntents
2+
3+
/// Surfaces the navigation intents in the Shortcuts app and Siri without any user setup.
4+
struct JetpackAppShortcuts: AppShortcutsProvider {
5+
static var appShortcuts: [AppShortcut] {
6+
AppShortcut(
7+
intent: NewPostIntent(),
8+
phrases: [
9+
"Create a new post in \(.applicationName)",
10+
"Write a new post in \(.applicationName)",
11+
"New \(.applicationName) post"
12+
],
13+
shortTitle: LocalizedStringResource("ios-appintents.newPost.shortTitle", defaultValue: "New Post"),
14+
systemImageName: "square.and.pencil"
15+
)
16+
AppShortcut(
17+
intent: OpenNotificationsIntent(),
18+
phrases: [
19+
"Open my \(.applicationName) notifications",
20+
"Show my \(.applicationName) notifications"
21+
],
22+
shortTitle: LocalizedStringResource(
23+
"ios-appintents.openNotifications.shortTitle",
24+
defaultValue: "Notifications"
25+
),
26+
systemImageName: "bell"
27+
)
28+
AppShortcut(
29+
intent: OpenStatsIntent(),
30+
phrases: [
31+
"Open my \(.applicationName) stats",
32+
"Show my site stats in \(.applicationName)"
33+
],
34+
shortTitle: LocalizedStringResource("ios-appintents.openStats.shortTitle", defaultValue: "Stats"),
35+
systemImageName: "chart.bar"
36+
)
37+
AppShortcut(
38+
intent: OpenReaderIntent(),
39+
phrases: [
40+
"Open the \(.applicationName) Reader",
41+
"Open Reader in \(.applicationName)"
42+
],
43+
shortTitle: LocalizedStringResource("ios-appintents.openReader.shortTitle", defaultValue: "Reader"),
44+
systemImageName: "book"
45+
)
46+
}
47+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import AppIntents
2+
3+
/// Opens the post editor for the last used site.
4+
struct NewPostIntent: AppIntent {
5+
static let title = LocalizedStringResource("ios-appintents.newPost.title", defaultValue: "New Post")
6+
static let description = IntentDescription(
7+
LocalizedStringResource(
8+
"ios-appintents.newPost.description",
9+
defaultValue: "Opens the editor to write a new post."
10+
)
11+
)
12+
static let openAppWhenRun = true
13+
14+
@MainActor
15+
func perform() async throws -> some IntentResult {
16+
guard AccountHelper.isLoggedIn else {
17+
throw AppIntentOpenError.notLoggedIn
18+
}
19+
RootViewCoordinator.sharedPresenter.showPostEditor(animated: false)
20+
return .result()
21+
}
22+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import AppIntents
2+
3+
/// Opens the Notifications tab.
4+
struct OpenNotificationsIntent: AppIntent {
5+
static let title = LocalizedStringResource(
6+
"ios-appintents.openNotifications.title",
7+
defaultValue: "Open Notifications"
8+
)
9+
static let description = IntentDescription(
10+
LocalizedStringResource(
11+
"ios-appintents.openNotifications.description",
12+
defaultValue: "Opens your notifications."
13+
)
14+
)
15+
static let openAppWhenRun = true
16+
17+
@MainActor
18+
func perform() async throws -> some IntentResult {
19+
guard AccountHelper.isLoggedIn else {
20+
throw AppIntentOpenError.notLoggedIn
21+
}
22+
let presenter = RootViewCoordinator.sharedPresenter
23+
presenter.rootViewController.dismiss(animated: false)
24+
presenter.showNotificationsTab()
25+
return .result()
26+
}
27+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import AppIntents
2+
3+
/// Opens the Reader tab.
4+
struct OpenReaderIntent: AppIntent {
5+
static let title = LocalizedStringResource("ios-appintents.openReader.title", defaultValue: "Open Reader")
6+
static let description = IntentDescription(
7+
LocalizedStringResource(
8+
"ios-appintents.openReader.description",
9+
defaultValue: "Opens the Reader to browse blogs you follow."
10+
)
11+
)
12+
static let openAppWhenRun = true
13+
14+
@MainActor
15+
func perform() async throws -> some IntentResult {
16+
guard AccountHelper.isLoggedIn else {
17+
throw AppIntentOpenError.notLoggedIn
18+
}
19+
let presenter = RootViewCoordinator.sharedPresenter
20+
presenter.rootViewController.dismiss(animated: false)
21+
presenter.showReader(path: nil)
22+
return .result()
23+
}
24+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import AppIntents
2+
import JetpackStatsWidgetsCore
3+
import WordPressData
4+
5+
/// Opens Stats for a chosen site, or the last used site when none is picked.
6+
struct OpenStatsIntent: AppIntent {
7+
static let title = LocalizedStringResource("ios-appintents.openStats.title", defaultValue: "Open Stats")
8+
static let description = IntentDescription(
9+
LocalizedStringResource(
10+
"ios-appintents.openStats.description",
11+
defaultValue: "Opens the stats for one of your sites."
12+
)
13+
)
14+
static let openAppWhenRun = true
15+
16+
@Parameter(title: LocalizedStringResource("ios-appintents.openStats.siteParameter", defaultValue: "Site"))
17+
var site: SiteEntity?
18+
19+
@MainActor
20+
func perform() async throws -> some IntentResult {
21+
guard AccountHelper.isLoggedIn else {
22+
throw AppIntentOpenError.notLoggedIn
23+
}
24+
guard let blog = Blog.forAppIntent(siteIdentifier: site?.id, in: ContextManager.shared.mainContext) else {
25+
throw AppIntentOpenError.siteNotFound
26+
}
27+
let presenter = RootViewCoordinator.sharedPresenter
28+
presenter.rootViewController.dismiss(animated: false)
29+
presenter.showStats(for: blog, source: .shortcut)
30+
return .result()
31+
}
32+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/* Siri invocation phrases for the App Shortcuts declared in
2+
Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift.
3+
4+
Each key must match a phrase in code character for character, with ${applicationName} in
5+
place of the \(.applicationName) interpolation; the system looks phrases up by the English
6+
text, so a mismatched key silently disables that phrase's localization. Translations must
7+
keep the ${applicationName} placeholder as-is.
8+
9+
This file ships in the Jetpack app bundle. It is uploaded to GlotPress under the
10+
"ios-appintents-phrases." prefix (see MANUALLY_MAINTAINED_STRINGS_FILES in
11+
fastlane/lanes/localization.rb), and the per-locale sibling files are generated by the
12+
release tooling when translations are downloaded. */
13+
14+
/* Siri phrase to open the post editor. ${applicationName} is the app's name. */
15+
"Create a new post in ${applicationName}" = "Create a new post in ${applicationName}";
16+
17+
/* Siri phrase to open the post editor. ${applicationName} is the app's name. */
18+
"Write a new post in ${applicationName}" = "Write a new post in ${applicationName}";
19+
20+
/* Siri phrase to open the post editor. ${applicationName} is the app's name. */
21+
"New ${applicationName} post" = "New ${applicationName} post";
22+
23+
/* Siri phrase to open the Notifications tab. ${applicationName} is the app's name. */
24+
"Open my ${applicationName} notifications" = "Open my ${applicationName} notifications";
25+
26+
/* Siri phrase to open the Notifications tab. ${applicationName} is the app's name. */
27+
"Show my ${applicationName} notifications" = "Show my ${applicationName} notifications";
28+
29+
/* Siri phrase to open Stats. ${applicationName} is the app's name. */
30+
"Open my ${applicationName} stats" = "Open my ${applicationName} stats";
31+
32+
/* Siri phrase to open Stats. ${applicationName} is the app's name. */
33+
"Show my site stats in ${applicationName}" = "Show my site stats in ${applicationName}";
34+
35+
/* Siri phrase to open the Reader tab. ${applicationName} is the app's name. */
36+
"Open the ${applicationName} Reader" = "Open the ${applicationName} Reader";
37+
38+
/* Siri phrase to open the Reader tab. ${applicationName} is the app's name. */
39+
"Open Reader in ${applicationName}" = "Open Reader in ${applicationName}";
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import WordPress
5+
@testable import WordPressData
6+
7+
@MainActor
8+
@Suite("Blog app intent resolution")
9+
struct BlogAppIntentResolutionTests {
10+
@Test("a site identifier resolves the matching blog")
11+
func explicitIdentifierResolvesMatchingBlog() {
12+
let context = ContextManager.forTesting().mainContext
13+
BlogBuilder(context).with(dotComID: 111).build()
14+
let target = BlogBuilder(context).with(dotComID: 222).build()
15+
16+
#expect(Blog.forAppIntent(siteIdentifier: "222", in: context) == target)
17+
}
18+
19+
@Test("an identifier with no matching blog resolves nothing instead of falling back")
20+
func unknownIdentifierResolvesNil() {
21+
let context = ContextManager.forTesting().mainContext
22+
BlogBuilder(context).with(dotComID: 111).build()
23+
24+
#expect(Blog.forAppIntent(siteIdentifier: "999", in: context) == nil)
25+
}
26+
27+
@Test("a non-numeric identifier resolves nothing")
28+
func nonNumericIdentifierResolvesNil() {
29+
let context = ContextManager.forTesting().mainContext
30+
BlogBuilder(context).with(dotComID: 111).build()
31+
32+
#expect(Blog.forAppIntent(siteIdentifier: "not-a-number", in: context) == nil)
33+
}
34+
35+
@Test("no identifier falls back to the last used or first blog")
36+
func missingIdentifierFallsBack() {
37+
let context = ContextManager.forTesting().mainContext
38+
let blog = BlogBuilder(context).with(dotComID: 111).build()
39+
40+
#expect(Blog.forAppIntent(siteIdentifier: nil, in: context) == blog)
41+
}
42+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import Foundation
2+
import WordPressData
3+
4+
extension Blog {
5+
/// Resolves the blog an App Intent should act on.
6+
///
7+
/// An explicit site identifier must resolve to its own blog: falling back to
8+
/// another site would silently act on the wrong one, so unresolvable
9+
/// identifiers return `nil`. Only when no identifier is given does the
10+
/// resolution fall back to the last used or first blog.
11+
static func forAppIntent(siteIdentifier: String?, in context: NSManagedObjectContext) -> Blog? {
12+
guard let siteIdentifier else {
13+
return lastUsedOrFirst(in: context)
14+
}
15+
guard let siteID = Int(siteIdentifier) else {
16+
return nil
17+
}
18+
return try? lookup(withID: siteID, in: context)
19+
}
20+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/* Source strings for the Jetpack App Intents (Siri, Shortcuts, Spotlight), uploaded to GlotPress
2+
under the "ios-appintents." prefix (see MANUALLY_MAINTAINED_STRINGS_FILES in
3+
fastlane/lanes/localization.rb).
4+
5+
This file is not shipped in any bundle. At runtime the prefixed keys are resolved from the
6+
app's Localizable.strings, with the defaultValue in code as the fallback until the keys have
7+
gone through a code freeze. Keep the keys in sync with the LocalizedStringResource keys in
8+
Sources/Jetpack/AppIntents (minus the prefix). */
9+
10+
/* Title of the App Intent that opens the post editor. Shown in the Shortcuts app and Spotlight. */
11+
"newPost.title" = "New Post";
12+
13+
/* Description of the App Intent that opens the post editor. Shown in the Shortcuts app. */
14+
"newPost.description" = "Opens the editor to write a new post.";
15+
16+
/* Short title of the New Post shortcut tile in Spotlight and the Shortcuts app. */
17+
"newPost.shortTitle" = "New Post";
18+
19+
/* Title of the App Intent that opens the Notifications tab. Shown in the Shortcuts app and Spotlight. */
20+
"openNotifications.title" = "Open Notifications";
21+
22+
/* Description of the App Intent that opens the Notifications tab. Shown in the Shortcuts app. */
23+
"openNotifications.description" = "Opens your notifications.";
24+
25+
/* Short title of the Notifications shortcut tile in Spotlight and the Shortcuts app. */
26+
"openNotifications.shortTitle" = "Notifications";
27+
28+
/* Title of the App Intent that opens Stats for a site. Shown in the Shortcuts app and Spotlight. */
29+
"openStats.title" = "Open Stats";
30+
31+
/* Description of the App Intent that opens Stats for a site. Shown in the Shortcuts app. */
32+
"openStats.description" = "Opens the stats for one of your sites.";
33+
34+
/* Label of the site parameter of the Open Stats App Intent. Shown in the Shortcuts app. */
35+
"openStats.siteParameter" = "Site";
36+
37+
/* Short title of the Stats shortcut tile in Spotlight and the Shortcuts app. */
38+
"openStats.shortTitle" = "Stats";
39+
40+
/* Title of the App Intent that opens the Reader tab. Shown in the Shortcuts app and Spotlight. */
41+
"openReader.title" = "Open Reader";
42+
43+
/* Description of the App Intent that opens the Reader tab. Shown in the Shortcuts app. */
44+
"openReader.description" = "Opens the Reader to browse blogs you follow.";
45+
46+
/* Short title of the Reader shortcut tile in Spotlight and the Shortcuts app. */
47+
"openReader.shortTitle" = "Reader";
48+
49+
/* Error shown by Siri or the Shortcuts app when an App Intent runs while no account is signed in. */
50+
"openError.notLoggedIn" = "Sign in to the app first to use this action.";
51+
52+
/* Error shown by Siri or the Shortcuts app when the site an App Intent should act on no longer exists. */
53+
"openError.siteNotFound" = "The site could not be found.";

0 commit comments

Comments
 (0)