Skip to content
27 changes: 27 additions & 0 deletions Modules/Sources/WordPressKit/ReaderPostServiceRemote+V2.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import Foundation

public struct ResolvedReaderPost: Decodable {
public let postId: UInt64
public let siteId: UInt64

enum CodingKeys: String, CodingKey {
case postId = "post_id"
case siteId = "site_id"
}
}

extension ReaderPostServiceRemote {
/// Returns a collection of RemoteReaderPost
/// This method returns the best available content for the given topics.
Expand Down Expand Up @@ -31,6 +41,23 @@
})
}

public func resolveUrl(
_ url: URL,
success: @escaping (ResolvedReaderPost) -> Void,
failure: @escaping (Error) -> Void
) {
let path = "/wpcom/v2/mobile/resolve-reader-url"

Check warning on line 49 in Modules/Sources/WordPressKit/ReaderPostServiceRemote+V2.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=wordpress-mobile_WordPress-iOS&issues=AZrCjlasIp2uJjfQV2p2&open=AZrCjlasIp2uJjfQV2p2&pullRequest=25016

Task { @MainActor [wordPressComRestApi] in
await wordPressComRestApi.perform(.get, URLString: path, parameters: [
"url": url.absoluteString,
], type: ResolvedReaderPost.self)
.map { $0.body }
.eraseToError()
.execute(onSuccess: success, onFailure: failure)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: you can use the async API, which supports Decodable, in wordPressComRestApi.perform(...) (which is different from wordPressComRESTAPI)

}

private func postsEndpoint(for topics: [String], page: String? = nil) -> String? {
var path = URLComponents(string: "read/tags/posts")

Expand Down
6 changes: 6 additions & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
* [*] Fix overly long related post titles in Reader [#25011]
* [*] Increase number of lines for post tiles in Reader to three [#25019]
* [*] Fix horizontal insets in Reader article view [#25010]
* [*] Fix a bug where the app can't access some Jetpack connected sites [#24976]
* [*] Add support for editing custom taxonomy terms from "Post Settings" [#24964]
* [*] Fix overly long related post titles in Reader [#25011]
* [*] Increase number of lines for post tiles in Reader to three [#25019]
* [*] Fix horizontal insets in Reader article view [#25010]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These entries are now duplicated.

* [*] Fixed several reader bugs causing posts to load strangely, or not at all [#25016]

26.4
-----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,6 @@ class ReaderDetailCoordinatorTests: CoreDataTestCase {
expect(serviceMock.didCallFetchPostWithIsFeed).to(beTrue())
}

/// Given a URL, retrieves the post
///
func testRetrieveAReaderPostWhenURLIsGiven() {
let serviceMock = ReaderPostServiceMock()
let viewMock = ReaderDetailViewMock()
let coordinator = ReaderDetailCoordinator(readerPostService: serviceMock, view: viewMock)
coordinator.postURL = URL(string: "https://wpmobilep2.wordpress.com/post/")

coordinator.start()

expect(serviceMock.didCallFetchWithURL).to(equal(URL(string: "https://wpmobilep2.wordpress.com/post/")))
}

/// Inform the view to render a post after it is fetched
///
func testUpdateViewWithRetrievedPost() {
Expand Down Expand Up @@ -63,39 +50,6 @@ class ReaderDetailCoordinatorTests: CoreDataTestCase {
expect(viewMock.didCallShowError).to(beTrue())
}

/// When an error happens, tell the view to show an error
///
func testShowErrorWithWebActionInView() {
let serviceMock = ReaderPostServiceMock()
serviceMock.forceError = true
let viewMock = ReaderDetailViewMock()
let coordinator = ReaderDetailCoordinator(readerPostService: serviceMock, view: viewMock)
coordinator.postURL = URL(string: "https://wordpress.com/")

coordinator.start()

expect(viewMock.didCallShowErrorWithWebAction).to(beTrue())
}

/// When an error happens, call the callback
///
func testCallCallbackWhenAnErrorHappens() {
var didCallPostLoadFailureBlock = false
let serviceMock = ReaderPostServiceMock()
serviceMock.forceError = true
let viewMock = ReaderDetailViewMock()
let coordinator = ReaderDetailCoordinator(readerPostService: serviceMock, view: viewMock)
coordinator.postURL = URL(string: "https://wordpress.com/")
coordinator.postLoadFailureBlock = {
didCallPostLoadFailureBlock = true
}

coordinator.start()

expect(didCallPostLoadFailureBlock).to(beTrue())
expect(coordinator.postLoadFailureBlock).to(beNil())
}

/// If a post is given, do not call the servce and render the content right away
///
func testGivenAPostRenderItRightAway() {
Expand Down Expand Up @@ -198,22 +152,6 @@ class ReaderDetailCoordinatorTests: CoreDataTestCase {
expect(viewMock.didCallPresentWith).to(beAKindOf(LightboxViewController.self))
}

/// Present an URL in a new Reader Detail screen
///
func testShowPresentURL() {
let post = makeReaderPost()
let serviceMock = ReaderPostServiceMock()
let viewMock = ReaderDetailViewMock()
let coordinator = ReaderDetailCoordinator(readerPostService: serviceMock, view: viewMock)
coordinator.post = post
let navigationControllerMock = UINavigationControllerMock()
viewMock.navigationController = navigationControllerMock

coordinator.handle(URL(string: "https://wpmobilep2.wordpress.com/2020/06/01/hello-test/")!)

expect(navigationControllerMock.didCallPushViewControllerWith).to(beAKindOf(ReaderDetailViewController.self))
}

/// Present an URL in a webview controller
///
func testShowPresentURLInWebViewController() {
Expand Down Expand Up @@ -330,7 +268,7 @@ private class ReaderDetailViewMock: UIViewController, ReaderDetailView {
didCallShowError = true
}

func showErrorWithWebAction(error: String?) {
func showErrorWithWebAction(error: (any Error)?) {
didCallShowErrorWithWebAction = true
}

Expand Down
18 changes: 18 additions & 0 deletions Tests/KeystoneTests/Tests/Utility/UniversalLinkRouterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation
import Testing

@testable import WordPress

struct UniversalLinkRouterTests {

@Test(
arguments: [
"http://en.blog.wordpress.com/2025/11/26/wordpress-migration-checklist/",
]
)
func supportPostLinks(url: String) {
let router = UniversalLinkRouter.shared
#expect(router.canHandle(url: URL(string: url)!))
}

}
14 changes: 14 additions & 0 deletions WordPress/Classes/Services/Reader Post/ReaderPostService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ import WordPressKit

extension ReaderPostService {

// MARK: – WP.com URL resolution

/// Ask the server for details about a Reader Post.
///
/// It's impossible to resolve a post URL into anything that the API can understand client-side. So we can ask the server to do it for us.
func resolvePostUrl(
_ url: URL,
success: @escaping (ResolvedReaderPost) -> Void,
failure: @escaping(Error) -> Void
) {
let remoteService = ReaderPostServiceRemote(wordPressComRestApi: apiForRequest())
remoteService.resolveUrl(url, success: success, failure: failure)
}

// MARK: - Fetch Unblocked Posts

/// Fetches a list of posts from the API and filters out the posts that belong to a blocked author.
Expand Down
60 changes: 60 additions & 0 deletions WordPress/Classes/System/Root View/ReaderPresenter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ public final class ReaderPresenter: NSObject, SplitViewDisplayable {
/// column (split view) or pushing to the navigation stack.
private func show(_ viewController: UIViewController, isLargeTitle: Bool = false) {
if let splitViewController {
guard !self.contentIsAlreadyDisplayed(viewController, in: splitViewController, for: .secondary) else {
DDLogInfo("View controller for \(viewController.contentIdentifier) already presented – skipping show")
return
}
(viewController as? ReaderStreamViewController)?.isNotificationsBarButtonEnabled = true

let navigationVC = UINavigationController(rootViewController: viewController)
Expand All @@ -207,6 +211,12 @@ public final class ReaderPresenter: NSObject, SplitViewDisplayable {
}
splitViewController.setViewController(navigationVC, for: .secondary)
} else {
// Don't push a view controller on top of another with the same content
guard !self.contentIsAlreadyDisplayed(viewController, in: mainNavigationController) else {
DDLogInfo("View controller for \(viewController.contentIdentifier) already presented – skipping show")
return
}

mainNavigationController.safePushViewController(viewController, animated: true)
}
}
Expand All @@ -215,14 +225,56 @@ public final class ReaderPresenter: NSObject, SplitViewDisplayable {
/// the `.secondary` column (split view) or to the main navigation stack.
private func push(_ viewController: UIViewController) {
if let splitViewController {
// Don't push a view controller on top of another with the same content
guard !contentIsAlreadyDisplayed(viewController, in: splitViewController, for: .secondary) else {
DDLogInfo("View controller for \(viewController.contentIdentifier) already presented – skipping push")
return
}
let navigationVC = splitViewController.viewController(for: .secondary) as? UINavigationController
wpAssert(navigationVC != nil)
navigationVC?.safePushViewController(viewController, animated: true)
} else {
// Don't push a view controller on top of another with the same content
guard !self.contentIsAlreadyDisplayed(viewController, in: mainNavigationController) else {
DDLogInfo("View controller for \(viewController.contentIdentifier) already presented – skipping push")
return
}

mainNavigationController.safePushViewController(viewController, animated: true)
}
}

private func contentIsAlreadyDisplayed(_ viewController: UIViewController, in nav: UINavigationController) -> Bool {
guard
let current = nav.topViewController as? ContentIdentifiable,
let new = viewController as? ContentIdentifiable
else {
return false
}

return current.contentIdentifier == new.contentIdentifier
}

private func contentIsAlreadyDisplayed(
_ viewController: UIViewController,
in split: UISplitViewController,
for column: UISplitViewController.Column
) -> Bool {
guard let top = split.viewController(for: column) else {
return false
}

if let nav = top as? UINavigationController {
return self.contentIsAlreadyDisplayed(viewController, in: nav)
}

guard let current = top as? ContentIdentifiable, let new = viewController as? ContentIdentifiable else {
return false
}

return current.contentIdentifier == new.contentIdentifier
}

// MARK: - Deep Links (ReaderNavigationPath)

func navigate(to path: ReaderNavigationPath) {
Expand Down Expand Up @@ -271,3 +323,11 @@ private extension UINavigationController {
pushViewController(viewController, animated: animated)
}
}

fileprivate extension UIViewController {
/// Helper for logging – if a user hits a bug where we're not showing the content they expect, this should help debug it.
/// Using a non-nil value makes the log lines easier to write.
var contentIdentifier: String {
(self as? ContentIdentifiable)?.contentIdentifier ?? "(unknown)"
}
}
15 changes: 15 additions & 0 deletions WordPress/Classes/System/WordPressAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,21 @@ extension WordPressAppDelegate {
return
}

// Don't try to resolve `apps.wordpress.com` URLs
if url.host == "apps.wordpress.com" {
UniversalLinkRouter.shared.handle(url: url)
return
}

// WordPress.com News links (i.e. http://en.blog.wordpress.com/2025/11/24/managed-vs-shared-wordpress-hosting/),
// which can be parsed by the app, redirect to links (i.e. https://wordpress.com/blog/2025/11/24/managed-vs-shared-wordpress-hosting/)
// that are not parsable by the app.
// Since we can handle post links in blog.wordpress.com, we don't need to resolve them.
if url.host?.hasSuffix("blog.wordpress.com") == true, UniversalLinkRouter.shared.canHandle(url: url) {
UniversalLinkRouter.shared.handle(url: url)
return
}

trackDeepLink(for: url) { url in
DispatchQueue.main.async {
UniversalLinkRouter.shared.handle(url: url)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

/// A protocol representing a content identifier – it could be a URL, ISBN, etc
///
/// More than one object might share a content identifier – two objects with the same identifier represent the same content.
///
public protocol ContentIdentifiable {
var contentIdentifier: String? { get }
}
26 changes: 20 additions & 6 deletions WordPress/Classes/Utility/Universal Links/Route.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,33 @@ import UIKit
/// Path: /me/account/:username
///
protocol Route {
var path: String { get }
var path: RoutePath { get }
var alternatePaths: [RoutePath] { get }
var section: DeepLinkSection? { get }
var source: DeepLinkSource { get }
var action: NavigationAction { get }
var shouldTrack: Bool { get }
var jetpackPowered: Bool { get }
}

extension Route {
var alternatePaths: [RoutePath] {
[]
}

var allPaths: [RoutePath] {
[path] + alternatePaths
}
}

typealias RoutePath = String

extension RoutePath {
var components: [String] {
return (self as NSString).pathComponents
}
}

extension Route {
// Default routes to handling links rather than other source types
var source: DeepLinkSource {
Expand Down Expand Up @@ -75,11 +94,6 @@ struct FailureNavigationAction: NavigationAction {
// MARK: - Route helper methods

extension Route {
/// Returns the path components of a route's path.
var components: [String] {
return (path as NSString).pathComponents
}

func isEqual(to route: Route) -> Bool {
return path == route.path
}
Expand Down
Loading