diff --git a/app/Shared/Models/TopicPostLocator.swift b/app/Shared/Models/TopicPostLocator.swift new file mode 100644 index 00000000..088bcfa0 --- /dev/null +++ b/app/Shared/Models/TopicPostLocator.swift @@ -0,0 +1,110 @@ +// +// TopicPostLocator.swift +// MNGA +// +// Created by Codex on 2026/3/14. +// + +import Foundation +import SwiftUI + +@MainActor +final class TopicPostLocator: ObservableObject { + struct Location: Equatable { + let floor: Int + let page: Int + } + + private let topicID: String + private let fav: String? + private var locations = [PostId: Location]() + private var inFlight = [PostId: Task, Never>]() + + init(topic: Topic) { + topicID = topic.id + fav = topic.hasFav ? topic.fav : nil + } + + func seed(posts: some Sequence) { + for post in posts { + guard post.id.tid == topicID else { continue } + let page = max(Int(post.atPage), 1) + locations[post.id] = .init(floor: Int(post.floor), page: page) + } + } + + func cachedLocation(for postId: PostId) -> Location? { + if postId.pid == "0" { + return .init(floor: 0, page: 1) + } + return locations[postId] + } + + func locate(_ postId: PostId) async -> Result { + if let location = cachedLocation(for: postId) { + return .success(location) + } + if let task = inFlight[postId] { + return await task.value + } + + let task = Task { [weak self] () -> Result in + guard let self else { + return .failure(LogicError(error: "Unable to locate this post in the full topic.")) + } + return await scanLocation(for: postId) + } + inFlight[postId] = task + + let result = await task.value + inFlight.removeValue(forKey: postId) + + if case let .success(location) = result { + locations[postId] = location + } + + return result + } + + private func scanLocation(for postId: PostId) async -> Result { + var page = 1 + var totalPages: Int? + + while totalPages == nil || page <= totalPages! { + let response: Result = await logicCallAsync( + buildRequest(page: page), + errorToastModel: nil, + ) + + switch response { + case let .success(response): + seed(posts: response.replies) + + if let location = locations[postId] { + return .success(location) + } + + totalPages = max(Int(response.pages), 1) + if response.replies.isEmpty { + break + } + page += 1 + case let .failure(error): + return .failure(error) + } + } + + return .failure(LogicError(error: "Unable to locate this post in the full topic.")) + } + + private func buildRequest(page: Int) -> AsyncRequest.OneOf_Value { + .topicDetails(TopicDetailsRequest.with { + $0.webApiStrategy = PreferencesStorage.shared.topicDetailsWebApiStrategy + $0.topicID = topicID + if let fav { + $0.fav = fav + } + $0.page = UInt32(page) + }) + } +} diff --git a/app/Shared/Views/PostReplyChainView.swift b/app/Shared/Views/PostReplyChainView.swift index d6e346f7..52244872 100644 --- a/app/Shared/Views/PostReplyChainView.swift +++ b/app/Shared/Views/PostReplyChainView.swift @@ -55,7 +55,7 @@ struct PostReplyChainView: View { .mayGroupedListStyle() .refreshable { resolver.resetFailures() } .navigationDestination(item: $action.navigateToAuthorOnly) { author in - TopicDetailsView.build(topic: topic, only: author) + TopicDetailsView.build(topic: topic, only: author, locateFloorInTopic: locateFloorInTopic) } } } diff --git a/app/Shared/Views/TopicDetailsView.swift b/app/Shared/Views/TopicDetailsView.swift index 476e2626..4e70a521 100644 --- a/app/Shared/Views/TopicDetailsView.swift +++ b/app/Shared/Views/TopicDetailsView.swift @@ -128,11 +128,13 @@ struct TopicDetailsView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.enableAuthorOnly) var enableAuthorOnly + @Environment(\.dismiss) private var dismiss @EnvironmentObject var viewingImage: ViewingImageModel @EnvironmentObject.Optional var postReply: PostReplyModel? @StateObject var dataSource: DataSource @StateObject var action = TopicDetailsActionModel() + @StateObject var postLocator: TopicPostLocator @StateObject var votes = VotesModel() @StateObject var quotedPosts: QuotedPostResolver @StateObject var prefs = PreferencesStorage.shared @@ -142,6 +144,7 @@ struct TopicDetailsView: View { let onlyPost: (id: PostId?, atPage: Int?) let forceLocalMode: Bool let previewMode: Bool + let locateFloorInTopic: ((Post) -> Void)? @State var showJumpSelector = false @State var floorToJump: Int? @@ -169,11 +172,13 @@ struct TopicDetailsView: View { onlyPost: (id: PostId?, atPage: Int?), forceLocalMode: Bool, previewMode: Bool, + locateFloorInTopic: ((Post) -> Void)? = nil, floorToJump: Int? = nil, postIdToJump: PostId? = nil, ) { _topic = topic _dataSource = StateObject(wrappedValue: dataSource) + _postLocator = StateObject(wrappedValue: TopicPostLocator(topic: topic.wrappedValue)) let resolver = QuotedPostResolver { [weak dataSource] id in dataSource?.items.first(where: { $0.id == id }) @@ -183,6 +188,7 @@ struct TopicDetailsView: View { self.onlyPost = onlyPost self.forceLocalMode = forceLocalMode self.previewMode = previewMode + self.locateFloorInTopic = locateFloorInTopic _floorToJump = State(initialValue: floorToJump) _postIdToJump = State(initialValue: postIdToJump) } @@ -280,7 +286,11 @@ struct TopicDetailsView: View { } } - static func build(topic: Topic, only author: AuthorOnly) -> some View { + static func build( + topic: Topic, + only author: AuthorOnly, + locateFloorInTopic: ((Post) -> Void)? = nil, + ) -> some View { let dataSource = DataSource( buildRequest: { page in .topicDetails(TopicDetailsRequest.with { @@ -306,8 +316,39 @@ struct TopicDetailsView: View { ) return StaticTopicDetailsView(topic: topic) { binding in - Self(topic: binding, dataSource: dataSource, onlyPost: (nil, nil), forceLocalMode: false, previewMode: false) - .environment(\.enableAuthorOnly, false) + Self( + topic: binding, + dataSource: dataSource, + onlyPost: (nil, nil), + forceLocalMode: false, + previewMode: false, + locateFloorInTopic: locateFloorInTopic, + ) + .environment(\.enableAuthorOnly, false) + } + } + + private var canLocateBackInTopic: Bool { + locateFloorInTopic != nil + } + + private var rowLocateFloorCallback: ((Post) -> Void)? { + guard let locateFloorInTopic else { return nil } + return { post in + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + locateFloorInTopic(post) + } + } + } + + private var childLocateFloorCallback: ((Post) -> Void)? { + if let rowLocateFloorCallback { + return rowLocateFloorCallback + } + guard !localMode, onlyPost.id == nil else { return nil } + return { post in + locatePostInCurrentTopic(post) } } @@ -458,6 +499,7 @@ struct TopicDetailsView: View { isAuthor: post.authorID == topic.authorID, screenshotTopic: topic, vote: votes.binding(for: post), + locateFloor: canLocateBackInTopic ? { _ in rowLocateFloorCallback?(post) } : nil, ) if withId { @@ -715,9 +757,7 @@ struct TopicDetailsView: View { resolver: quotedPosts, chain: chain, topic: topic, - locateFloorInTopic: onlyPost.id == nil ? { post in - action.scrollToPid = post.id.pid - } : nil, + locateFloorInTopic: childLocateFloorCallback, ) } @@ -752,7 +792,11 @@ struct TopicDetailsView: View { postReplyChainDestination(chain: $0) } .navigationDestination(item: $action.navigateToAuthorOnly) { - TopicDetailsView.build(topic: topic, only: $0) + TopicDetailsView.build( + topic: topic, + only: $0, + locateFloorInTopic: childLocateFloorCallback, + ) } .navigationDestination(isPresented: $action.navigateToLocalMode) { TopicDetailsView.build(topic: topic, localMode: true) @@ -860,6 +904,9 @@ struct TopicDetailsView: View { func updateTopicOnNewResponse(response: TopicDetailsResponse?) { guard let response else { return } let newTopic = response.topic + if onlyPost.id == nil, enableAuthorOnly { + postLocator.seed(posts: response.replies) + } quotedPosts.seed(posts: response.replies) action.indexReplyRelations(in: response.replies) if let first = response.replies.first(where: { $0.id.pid == "0" }), !first.hotReplies.isEmpty { @@ -945,6 +992,35 @@ struct TopicDetailsView: View { } } } + + @MainActor + private func locatePostInCurrentTopic(_ post: Post) { + Task { + let result = await postLocator.locate(post.id) + + switch result { + case let .success(location): + jumpToLocatedPost(post, location: location) + case let .failure(error): + ToastModel.showAuto(.error(error.error)) + } + } + } + + @MainActor + private func jumpToLocatedPost(_ post: Post, location: TopicPostLocator.Location) { + action.scrollToFloor = nil + action.scrollToPid = nil + floorToJump = nil + + if dataSource.items.contains(where: { $0.id == post.id }) { + action.scrollToPid = post.id.pid + return + } + + postIdToJump = post.id + dataSource.loadFromPage = location.page + } } struct TopicDetailsView_Preview: PreviewProvider {