Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* [*] [internal] In-app updates: guard the flexible update notice against double-posting when two update checks race [#25666]
* [*] [build tooling] Add a String Catalog localization pipeline (plurals + build-free catalog generation) with a CI coverage gate [#25688]
* [*] [internal] Stats widgets: migrate the site picker from SiriKit to App Intents [#25753]
* [*] Add App Shortcuts for creating a post and opening Notifications, Stats, and the Reader from Siri, Spotlight, and the Shortcuts app [#25754]


27.0
Expand Down
23 changes: 23 additions & 0 deletions Sources/Jetpack/AppIntents/AppIntentOpenError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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."
)
case .siteNotFound:
return LocalizedStringResource(
"ios-appintents.openError.siteNotFound",
defaultValue: "The site could not be found."
)
}
}
}
47 changes: 47 additions & 0 deletions Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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("ios-appintents.newPost.shortTitle", defaultValue: "New Post"),
systemImageName: "square.and.pencil"
)
AppShortcut(
intent: OpenNotificationsIntent(),
phrases: [
"Open my \(.applicationName) notifications",
"Show my \(.applicationName) notifications"
],
shortTitle: LocalizedStringResource(
"ios-appintents.openNotifications.shortTitle",
defaultValue: "Notifications"
),
systemImageName: "bell"
)
AppShortcut(
intent: OpenStatsIntent(),
phrases: [
"Open my \(.applicationName) stats",
"Show my site stats in \(.applicationName)"
],
shortTitle: LocalizedStringResource("ios-appintents.openStats.shortTitle", defaultValue: "Stats"),
systemImageName: "chart.bar"
)
AppShortcut(
intent: OpenReaderIntent(),
phrases: [
"Open the \(.applicationName) Reader",
"Open Reader in \(.applicationName)"
],
shortTitle: LocalizedStringResource("ios-appintents.openReader.shortTitle", defaultValue: "Reader"),
systemImageName: "book"
)
}
}
22 changes: 22 additions & 0 deletions Sources/Jetpack/AppIntents/NewPostIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import AppIntents

/// Opens the post editor for the last used site.
struct NewPostIntent: AppIntent {
static let title = LocalizedStringResource("ios-appintents.newPost.title", defaultValue: "New Post")
static let description = IntentDescription(
LocalizedStringResource(
"ios-appintents.newPost.description",
defaultValue: "Opens the editor to write a new post."
)
)
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()
}
}
27 changes: 27 additions & 0 deletions Sources/Jetpack/AppIntents/OpenNotificationsIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import AppIntents

/// Opens the Notifications tab.
struct OpenNotificationsIntent: AppIntent {
static let title = LocalizedStringResource(
"ios-appintents.openNotifications.title",
defaultValue: "Open Notifications"
)
static let description = IntentDescription(
LocalizedStringResource(
"ios-appintents.openNotifications.description",
defaultValue: "Opens your notifications."
)
)
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()
}
}
24 changes: 24 additions & 0 deletions Sources/Jetpack/AppIntents/OpenReaderIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import AppIntents

/// Opens the Reader tab.
struct OpenReaderIntent: AppIntent {
static let title = LocalizedStringResource("ios-appintents.openReader.title", defaultValue: "Open Reader")
static let description = IntentDescription(
LocalizedStringResource(
"ios-appintents.openReader.description",
defaultValue: "Opens the Reader to browse blogs you follow."
)
)
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()
}
}
32 changes: 32 additions & 0 deletions Sources/Jetpack/AppIntents/OpenStatsIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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")
static let description = IntentDescription(
LocalizedStringResource(
"ios-appintents.openStats.description",
defaultValue: "Opens the stats for one of your sites."
)
)
static let openAppWhenRun = true

@Parameter(title: LocalizedStringResource("ios-appintents.openStats.siteParameter", defaultValue: "Site"))
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()
}
}
39 changes: 39 additions & 0 deletions Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* Siri invocation phrases for the App Shortcuts declared in
Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift.

Each key must match a phrase in code character for character, with ${applicationName} in
place of the \(.applicationName) interpolation; the system looks phrases up by the English
text, so a mismatched key silently disables that phrase's localization. Translations must
keep the ${applicationName} placeholder as-is.

This file ships in the Jetpack app bundle. It is uploaded to GlotPress under the
"ios-appintents-phrases." prefix (see MANUALLY_MAINTAINED_STRINGS_FILES in
fastlane/lanes/localization.rb), and the per-locale sibling files are generated by the
release tooling when translations are downloaded. */

/* Siri phrase to open the post editor. ${applicationName} is the app's name. */
"Create a new post in ${applicationName}" = "Create a new post in ${applicationName}";

/* Siri phrase to open the post editor. ${applicationName} is the app's name. */
"Write a new post in ${applicationName}" = "Write a new post in ${applicationName}";

/* Siri phrase to open the post editor. ${applicationName} is the app's name. */
"New ${applicationName} post" = "New ${applicationName} post";

/* Siri phrase to open the Notifications tab. ${applicationName} is the app's name. */
"Open my ${applicationName} notifications" = "Open my ${applicationName} notifications";

/* Siri phrase to open the Notifications tab. ${applicationName} is the app's name. */
"Show my ${applicationName} notifications" = "Show my ${applicationName} notifications";

/* Siri phrase to open Stats. ${applicationName} is the app's name. */
"Open my ${applicationName} stats" = "Open my ${applicationName} stats";

/* Siri phrase to open Stats. ${applicationName} is the app's name. */
"Show my site stats in ${applicationName}" = "Show my site stats in ${applicationName}";

/* Siri phrase to open the Reader tab. ${applicationName} is the app's name. */
"Open the ${applicationName} Reader" = "Open the ${applicationName} Reader";

/* Siri phrase to open the Reader tab. ${applicationName} is the app's name. */
"Open Reader in ${applicationName}" = "Open Reader in ${applicationName}";
Original file line number Diff line number Diff line change
@@ -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)
}
}
20 changes: 20 additions & 0 deletions WordPress/Classes/Models/Blog/Blog+AppIntents.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
53 changes: 53 additions & 0 deletions WordPress/JetpackAppIntents/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/* Source strings for the Jetpack App Intents (Siri, Shortcuts, Spotlight), uploaded to GlotPress
under the "ios-appintents." prefix (see MANUALLY_MAINTAINED_STRINGS_FILES in
fastlane/lanes/localization.rb).

This file is not shipped in any bundle. At runtime the prefixed keys are resolved from the
app's Localizable.strings, with the defaultValue in code as the fallback until the keys have
gone through a code freeze. Keep the keys in sync with the LocalizedStringResource keys in
Sources/Jetpack/AppIntents (minus the prefix). */

/* Title of the App Intent that opens the post editor. Shown in the Shortcuts app and Spotlight. */
"newPost.title" = "New Post";

/* Description of the App Intent that opens the post editor. Shown in the Shortcuts app. */
"newPost.description" = "Opens the editor to write a new post.";

/* Short title of the New Post shortcut tile in Spotlight and the Shortcuts app. */
"newPost.shortTitle" = "New Post";

/* Title of the App Intent that opens the Notifications tab. Shown in the Shortcuts app and Spotlight. */
"openNotifications.title" = "Open Notifications";

/* Description of the App Intent that opens the Notifications tab. Shown in the Shortcuts app. */
"openNotifications.description" = "Opens your notifications.";

/* Short title of the Notifications shortcut tile in Spotlight and the Shortcuts app. */
"openNotifications.shortTitle" = "Notifications";

/* Title of the App Intent that opens Stats for a site. Shown in the Shortcuts app and Spotlight. */
"openStats.title" = "Open Stats";

/* Description of the App Intent that opens Stats for a site. Shown in the Shortcuts app. */
"openStats.description" = "Opens the stats for one of your sites.";

/* Label of the site parameter of the Open Stats App Intent. Shown in the Shortcuts app. */
"openStats.siteParameter" = "Site";

/* Short title of the Stats shortcut tile in Spotlight and the Shortcuts app. */
"openStats.shortTitle" = "Stats";

/* Title of the App Intent that opens the Reader tab. Shown in the Shortcuts app and Spotlight. */
"openReader.title" = "Open Reader";

/* Description of the App Intent that opens the Reader tab. Shown in the Shortcuts app. */
"openReader.description" = "Opens the Reader to browse blogs you follow.";

/* Short title of the Reader shortcut tile in Spotlight and the Shortcuts app. */
"openReader.shortTitle" = "Reader";

/* Error shown by Siri or the Shortcuts app when an App Intent runs while no account is signed in. */
"openError.notLoggedIn" = "Sign in to the app first to use this action.";

/* Error shown by Siri or the Shortcuts app when the site an App Intent should act on no longer exists. */
"openError.siteNotFound" = "The site could not be found.";
4 changes: 3 additions & 1 deletion fastlane/lanes/localization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@
File.join('WordPress', 'Resources', 'en.lproj', 'InfoPlist.strings') => 'infoplist.', # For now WordPress and Jetpack share the same InfoPlist.strings
File.join('WordPress', 'WordPressDraftActionExtension', 'en.lproj', 'InfoPlist.strings') => 'ios-sharesheet.', # CFBundleDisplayName for the "Save as Draft" share action
File.join('WordPress', 'JetpackDraftActionExtension', 'en.lproj', 'InfoPlist.strings') => 'ios-jetpack-sharesheet.', # CFBundleDisplayName for the "Save to Jetpack" share action
File.join('WordPress', 'JetpackStatsWidgets', 'en.lproj', 'Localizable.strings') => 'ios-widget.' # Strings for the App Intents UI used when configuring the iOS Widget; resolved at runtime via the prefixed keys in the app's Localizable.strings
File.join('WordPress', 'JetpackStatsWidgets', 'en.lproj', 'Localizable.strings') => 'ios-widget.', # Strings for the App Intents UI used when configuring the iOS Widget; resolved at runtime via the prefixed keys in the app's Localizable.strings
File.join('WordPress', 'JetpackAppIntents', 'en.lproj', 'Localizable.strings') => 'ios-appintents.', # Titles, descriptions, and dialogs of the Jetpack App Intents; resolved at runtime via the prefixed keys in the app's Localizable.strings
File.join('Sources', 'Jetpack', 'Resources', 'en.lproj', 'AppShortcuts.strings') => 'ios-appintents-phrases.' # Siri invocation phrases for the Jetpack App Shortcuts; the per-locale sibling files ship in the Jetpack app bundle
}.freeze

# Remote Swift Packages whose localizable strings we want to extract (they're checked out under Derived Data
Expand Down