Skip to content
Merged
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
110 changes: 110 additions & 0 deletions app/Shared/Models/TopicPostLocator.swift
Original file line number Diff line number Diff line change
@@ -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<Result<Location, LogicError>, Never>]()

init(topic: Topic) {
topicID = topic.id
fav = topic.hasFav ? topic.fav : nil
}

func seed(posts: some Sequence<Post>) {
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<Location, LogicError> {
if let location = cachedLocation(for: postId) {
return .success(location)
}
if let task = inFlight[postId] {
return await task.value
}

let task = Task { [weak self] () -> Result<Location, LogicError> 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<Location, LogicError> {
var page = 1
var totalPages: Int?

while totalPages == nil || page <= totalPages! {
let response: Result<TopicDetailsResponse, LogicError> = 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)
})
}
}
2 changes: 1 addition & 1 deletion app/Shared/Views/PostReplyChainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
90 changes: 83 additions & 7 deletions app/Shared/Views/TopicDetailsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?
Expand Down Expand Up @@ -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 })
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
)
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading