From 7f8f08266e8dbb0660b1cd475cb11e63b152c4f1 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 22 Jul 2026 15:26:39 +1200 Subject: [PATCH 1/2] Add App Shortcuts with navigation intents to the Jetpack app Four App Intents expose new post, notifications, stats, and Reader navigation through Shortcuts, Spotlight, and Siri. Existing compatible app strings are reused for localization, while the remaining App Intent text and invocation phrases use their English source values until the shared String Catalog pipeline is ready. --- .../AppIntents/AppIntentOpenError.swift | 29 ++++++ .../AppIntents/JetpackAppShortcuts.swift | 64 +++++++++++++ .../Jetpack/AppIntents/NewPostIntent.swift | 29 ++++++ .../AppIntents/OpenNotificationsIntent.swift | 31 ++++++ .../Jetpack/AppIntents/OpenReaderIntent.swift | 31 ++++++ .../Jetpack/AppIntents/OpenStatsIntent.swift | 46 +++++++++ .../Jetpack/Resources/AppIntents.xcstrings | 94 ++++++++++++++++++ .../Jetpack/Resources/AppShortcuts.xcstrings | 96 +++++++++++++++++++ .../Models/BlogAppIntentResolutionTests.swift | 42 ++++++++ .../Classes/Models/Blog/Blog+AppIntents.swift | 20 ++++ 10 files changed, 482 insertions(+) create mode 100644 Sources/Jetpack/AppIntents/AppIntentOpenError.swift create mode 100644 Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift create mode 100644 Sources/Jetpack/AppIntents/NewPostIntent.swift create mode 100644 Sources/Jetpack/AppIntents/OpenNotificationsIntent.swift create mode 100644 Sources/Jetpack/AppIntents/OpenReaderIntent.swift create mode 100644 Sources/Jetpack/AppIntents/OpenStatsIntent.swift create mode 100644 Sources/Jetpack/Resources/AppIntents.xcstrings create mode 100644 Sources/Jetpack/Resources/AppShortcuts.xcstrings create mode 100644 Tests/KeystoneTests/Tests/Models/BlogAppIntentResolutionTests.swift create mode 100644 WordPress/Classes/Models/Blog/Blog+AppIntents.swift diff --git a/Sources/Jetpack/AppIntents/AppIntentOpenError.swift b/Sources/Jetpack/AppIntents/AppIntentOpenError.swift new file mode 100644 index 000000000000..f4ab68048c28 --- /dev/null +++ b/Sources/Jetpack/AppIntents/AppIntentOpenError.swift @@ -0,0 +1,29 @@ +import AppIntents +import Foundation + +/// Errors surfaced to the system when an open intent cannot proceed. +enum AppIntentOpenError: Error, CustomLocalizedStringResourceConvertible { + case notLoggedIn + case siteNotFound + + var localizedStringResource: LocalizedStringResource { + switch self { + case .notLoggedIn: + return LocalizedStringResource( + "ios-appintents.openError.notLoggedIn", + defaultValue: "Sign in to the app first to use this action.", + table: "AppIntents", + comment: + "Error shown by Siri or the Shortcuts app when an App Intent runs while no account is signed in." + ) + case .siteNotFound: + return LocalizedStringResource( + "ios-appintents.openError.siteNotFound", + defaultValue: "The site could not be found.", + table: "AppIntents", + comment: + "Error shown by Siri or the Shortcuts app when the site an App Intent should act on no longer exists." + ) + } + } +} diff --git a/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift b/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift new file mode 100644 index 000000000000..b48547e3c7de --- /dev/null +++ b/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift @@ -0,0 +1,64 @@ +import AppIntents + +/// Surfaces the navigation intents in the Shortcuts app and Siri without any user setup. +struct JetpackAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: NewPostIntent(), + phrases: [ + "Create a new post in \(.applicationName)", + "Write a new post in \(.applicationName)", + "New \(.applicationName) post" + ], + shortTitle: LocalizedStringResource( + "New Post", + defaultValue: "New Post", + table: "Localizable", + comment: "Short title of the New Post shortcut tile in Spotlight and the Shortcuts app." + ), + systemImageName: "square.and.pencil" + ) + AppShortcut( + intent: OpenNotificationsIntent(), + phrases: [ + "Open my \(.applicationName) notifications", + "Show my \(.applicationName) notifications" + ], + shortTitle: LocalizedStringResource( + "Notifications", + defaultValue: "Notifications", + table: "Localizable", + comment: "Short title of the Notifications shortcut tile in Spotlight and the Shortcuts app." + ), + systemImageName: "bell" + ) + AppShortcut( + intent: OpenStatsIntent(), + phrases: [ + "Open my \(.applicationName) stats", + "Show my site stats in \(.applicationName)" + ], + shortTitle: LocalizedStringResource( + "Stats", + defaultValue: "Stats", + table: "Localizable", + comment: "Short title of the Stats shortcut tile in Spotlight and the Shortcuts app." + ), + systemImageName: "chart.bar" + ) + AppShortcut( + intent: OpenReaderIntent(), + phrases: [ + "Open the \(.applicationName) Reader", + "Open Reader in \(.applicationName)" + ], + shortTitle: LocalizedStringResource( + "Reader", + defaultValue: "Reader", + table: "Localizable", + comment: "Short title of the Reader shortcut tile in Spotlight and the Shortcuts app." + ), + systemImageName: "book" + ) + } +} diff --git a/Sources/Jetpack/AppIntents/NewPostIntent.swift b/Sources/Jetpack/AppIntents/NewPostIntent.swift new file mode 100644 index 000000000000..5958c9e75846 --- /dev/null +++ b/Sources/Jetpack/AppIntents/NewPostIntent.swift @@ -0,0 +1,29 @@ +import AppIntents + +/// Opens the post editor for the last used site. +struct NewPostIntent: AppIntent { + static let title = LocalizedStringResource( + "New Post", + defaultValue: "New Post", + table: "Localizable", + comment: "Title of the App Intent that opens the post editor. Shown in the Shortcuts app and Spotlight." + ) + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.newPost.description", + defaultValue: "Opens the editor to write a new post.", + table: "AppIntents", + comment: "Description of the App Intent that opens the post editor. Shown in the Shortcuts app." + ) + ) + static let openAppWhenRun = true + + @MainActor + func perform() async throws -> some IntentResult { + guard AccountHelper.isLoggedIn else { + throw AppIntentOpenError.notLoggedIn + } + RootViewCoordinator.sharedPresenter.showPostEditor(animated: false) + return .result() + } +} diff --git a/Sources/Jetpack/AppIntents/OpenNotificationsIntent.swift b/Sources/Jetpack/AppIntents/OpenNotificationsIntent.swift new file mode 100644 index 000000000000..9b15808cbbfa --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenNotificationsIntent.swift @@ -0,0 +1,31 @@ +import AppIntents + +/// Opens the Notifications tab. +struct OpenNotificationsIntent: AppIntent { + static let title = LocalizedStringResource( + "ios-appintents.openNotifications.title", + defaultValue: "Open Notifications", + table: "AppIntents", + comment: "Title of the App Intent that opens the Notifications tab. Shown in the Shortcuts app and Spotlight." + ) + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openNotifications.description", + defaultValue: "Opens your notifications.", + table: "AppIntents", + comment: "Description of the App Intent that opens the Notifications tab. Shown in the Shortcuts app." + ) + ) + static let openAppWhenRun = true + + @MainActor + func perform() async throws -> some IntentResult { + guard AccountHelper.isLoggedIn else { + throw AppIntentOpenError.notLoggedIn + } + let presenter = RootViewCoordinator.sharedPresenter + presenter.rootViewController.dismiss(animated: false) + presenter.showNotificationsTab() + return .result() + } +} diff --git a/Sources/Jetpack/AppIntents/OpenReaderIntent.swift b/Sources/Jetpack/AppIntents/OpenReaderIntent.swift new file mode 100644 index 000000000000..e12d6e025ace --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenReaderIntent.swift @@ -0,0 +1,31 @@ +import AppIntents + +/// Opens the Reader tab. +struct OpenReaderIntent: AppIntent { + static let title = LocalizedStringResource( + "notifications.emptyState.buttonOpenReader", + defaultValue: "Open Reader", + table: "Localizable", + comment: "Title of the App Intent that opens the Reader tab. Shown in the Shortcuts app and Spotlight." + ) + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openReader.description", + defaultValue: "Opens the Reader to browse blogs you follow.", + table: "AppIntents", + comment: "Description of the App Intent that opens the Reader tab. Shown in the Shortcuts app." + ) + ) + static let openAppWhenRun = true + + @MainActor + func perform() async throws -> some IntentResult { + guard AccountHelper.isLoggedIn else { + throw AppIntentOpenError.notLoggedIn + } + let presenter = RootViewCoordinator.sharedPresenter + presenter.rootViewController.dismiss(animated: false) + presenter.showReader(path: nil) + return .result() + } +} diff --git a/Sources/Jetpack/AppIntents/OpenStatsIntent.swift b/Sources/Jetpack/AppIntents/OpenStatsIntent.swift new file mode 100644 index 000000000000..8ad634079dde --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenStatsIntent.swift @@ -0,0 +1,46 @@ +import AppIntents +import JetpackStatsWidgetsCore +import WordPressData + +/// Opens Stats for a chosen site, or the last used site when none is picked. +struct OpenStatsIntent: AppIntent { + static let title = LocalizedStringResource( + "ios-appintents.openStats.title", + defaultValue: "Open Stats", + table: "AppIntents", + comment: "Title of the App Intent that opens Stats for a site. Shown in the Shortcuts app and Spotlight." + ) + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openStats.description", + defaultValue: "Opens the stats for one of your sites.", + table: "AppIntents", + comment: "Description of the App Intent that opens Stats for a site. Shown in the Shortcuts app." + ) + ) + static let openAppWhenRun = true + + @Parameter( + title: LocalizedStringResource( + "ios-widget.ILcGmf", + defaultValue: "Site", + table: "Localizable", + comment: "Label of the site parameter of the Open Stats App Intent. Shown in the Shortcuts app." + ) + ) + var site: SiteEntity? + + @MainActor + func perform() async throws -> some IntentResult { + guard AccountHelper.isLoggedIn else { + throw AppIntentOpenError.notLoggedIn + } + guard let blog = Blog.forAppIntent(siteIdentifier: site?.id, in: ContextManager.shared.mainContext) else { + throw AppIntentOpenError.siteNotFound + } + let presenter = RootViewCoordinator.sharedPresenter + presenter.rootViewController.dismiss(animated: false) + presenter.showStats(for: blog, source: .shortcut) + return .result() + } +} diff --git a/Sources/Jetpack/Resources/AppIntents.xcstrings b/Sources/Jetpack/Resources/AppIntents.xcstrings new file mode 100644 index 000000000000..e000f6ca9581 --- /dev/null +++ b/Sources/Jetpack/Resources/AppIntents.xcstrings @@ -0,0 +1,94 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "ios-appintents.newPost.description" : { + "comment" : "Description of the App Intent that opens the post editor. Shown in the Shortcuts app.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opens the editor to write a new post." + } + } + } + }, + "ios-appintents.openError.notLoggedIn" : { + "comment" : "Error shown by Siri or the Shortcuts app when an App Intent runs while no account is signed in.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in to the app first to use this action." + } + } + } + }, + "ios-appintents.openError.siteNotFound" : { + "comment" : "Error shown by Siri or the Shortcuts app when the site an App Intent should act on no longer exists.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The site could not be found." + } + } + } + }, + "ios-appintents.openNotifications.description" : { + "comment" : "Description of the App Intent that opens the Notifications tab. Shown in the Shortcuts app.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opens your notifications." + } + } + } + }, + "ios-appintents.openNotifications.title" : { + "comment" : "Title of the App Intent that opens the Notifications tab. Shown in the Shortcuts app and Spotlight.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Notifications" + } + } + } + }, + "ios-appintents.openReader.description" : { + "comment" : "Description of the App Intent that opens the Reader tab. Shown in the Shortcuts app.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opens the Reader to browse blogs you follow." + } + } + } + }, + "ios-appintents.openStats.description" : { + "comment" : "Description of the App Intent that opens Stats for a site. Shown in the Shortcuts app.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opens the stats for one of your sites." + } + } + } + }, + "ios-appintents.openStats.title" : { + "comment" : "Title of the App Intent that opens Stats for a site. Shown in the Shortcuts app and Spotlight.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Stats" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/Sources/Jetpack/Resources/AppShortcuts.xcstrings b/Sources/Jetpack/Resources/AppShortcuts.xcstrings new file mode 100644 index 000000000000..8557a78efe7b --- /dev/null +++ b/Sources/Jetpack/Resources/AppShortcuts.xcstrings @@ -0,0 +1,96 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Create a new post in ${applicationName}" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create a new post in ${applicationName}" + } + } + } + }, + "New ${applicationName} post" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New ${applicationName} post" + } + } + } + }, + "Open my ${applicationName} notifications" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open my ${applicationName} notifications" + } + } + } + }, + "Open my ${applicationName} stats" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open my ${applicationName} stats" + } + } + } + }, + "Open Reader in ${applicationName}" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Reader in ${applicationName}" + } + } + } + }, + "Open the ${applicationName} Reader" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open the ${applicationName} Reader" + } + } + } + }, + "Show my ${applicationName} notifications" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show my ${applicationName} notifications" + } + } + } + }, + "Show my site stats in ${applicationName}" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show my site stats in ${applicationName}" + } + } + } + }, + "Write a new post in ${applicationName}" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Write a new post in ${applicationName}" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/Tests/KeystoneTests/Tests/Models/BlogAppIntentResolutionTests.swift b/Tests/KeystoneTests/Tests/Models/BlogAppIntentResolutionTests.swift new file mode 100644 index 000000000000..29499d2c9bb0 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Models/BlogAppIntentResolutionTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing + +@testable import WordPress +@testable import WordPressData + +@MainActor +@Suite("Blog app intent resolution") +struct BlogAppIntentResolutionTests { + @Test("a site identifier resolves the matching blog") + func explicitIdentifierResolvesMatchingBlog() { + let context = ContextManager.forTesting().mainContext + BlogBuilder(context).with(dotComID: 111).build() + let target = BlogBuilder(context).with(dotComID: 222).build() + + #expect(Blog.forAppIntent(siteIdentifier: "222", in: context) == target) + } + + @Test("an identifier with no matching blog resolves nothing instead of falling back") + func unknownIdentifierResolvesNil() { + let context = ContextManager.forTesting().mainContext + BlogBuilder(context).with(dotComID: 111).build() + + #expect(Blog.forAppIntent(siteIdentifier: "999", in: context) == nil) + } + + @Test("a non-numeric identifier resolves nothing") + func nonNumericIdentifierResolvesNil() { + let context = ContextManager.forTesting().mainContext + BlogBuilder(context).with(dotComID: 111).build() + + #expect(Blog.forAppIntent(siteIdentifier: "not-a-number", in: context) == nil) + } + + @Test("no identifier falls back to the last used or first blog") + func missingIdentifierFallsBack() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + + #expect(Blog.forAppIntent(siteIdentifier: nil, in: context) == blog) + } +} diff --git a/WordPress/Classes/Models/Blog/Blog+AppIntents.swift b/WordPress/Classes/Models/Blog/Blog+AppIntents.swift new file mode 100644 index 000000000000..b8afb1ebc5a9 --- /dev/null +++ b/WordPress/Classes/Models/Blog/Blog+AppIntents.swift @@ -0,0 +1,20 @@ +import Foundation +import WordPressData + +extension Blog { + /// Resolves the blog an App Intent should act on. + /// + /// An explicit site identifier must resolve to its own blog: falling back to + /// another site would silently act on the wrong one, so unresolvable + /// identifiers return `nil`. Only when no identifier is given does the + /// resolution fall back to the last used or first blog. + static func forAppIntent(siteIdentifier: String?, in context: NSManagedObjectContext) -> Blog? { + guard let siteIdentifier else { + return lastUsedOrFirst(in: context) + } + guard let siteID = Int(siteIdentifier) else { + return nil + } + return try? lookup(withID: siteID, in: context) + } +} From a161dd9e676464dd4ff5d46c4ed21dd2fcac2f2e Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 7 Jul 2026 15:59:27 +1200 Subject: [PATCH 2/2] Add a release note for the navigation App Shortcuts --- RELEASE-NOTES.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 718fbe84fd7c..574fc8053029 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -12,6 +12,8 @@ * [*] Fix Blogging Reminders text color in dark mode [#25780] * [*] Share extensions: Fix the site picker showing an error while sites are still loading [#25798] * [*] Share Extension: Fix a crash after uploading a post to WordPress.com [#25773] +* [*] Add App Shortcuts for creating a post and opening Notifications, Stats, and the Reader from Siri, Spotlight, and the Shortcuts app [#25754] + 27.0 -----