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 ----- 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) + } +}