From 434018819dd1e0cd95660769283e7062afff3b3e Mon Sep 17 00:00:00 2001 From: IamNewHands Date: Fri, 2 Oct 2026 22:47:27 +0800 Subject: [PATCH 1/2] fix: guarantee first-line text headroom so post headings are not clipped CoreText can report no ascent overflow while iOS 17+ .oversize still draws the first line high enough to slip under the small fixed guard, so some post and reply headings lost their top pixels. Always measure the first line's glyph ink, floor the top inset at 3pt, and reserve the negative usedRect.minY extent in the measured height so a heading can never be clipped. Update the smoke test to the measured-height contract. --- .../Features/Thread/ContentBlockView.swift | 48 ++++++++----------- TiebaPureTests/TiebaPureSmokeTests.swift | 20 ++++++-- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/TiebaPure/Features/Thread/ContentBlockView.swift b/TiebaPure/Features/Thread/ContentBlockView.swift index 1ea8024..df8a5cb 100644 --- a/TiebaPure/Features/Thread/ContentBlockView.swift +++ b/TiebaPure/Features/Thread/ContentBlockView.swift @@ -1562,36 +1562,26 @@ enum InlineContentTextLayout { let fractionalBaselineGuard: CGFloat = 1 let physicalPixel = 1 / scale let rasterizationGuard = fractionalBaselineGuard + physicalPixel * 2 - let baselineInsets = UIEdgeInsets( - top: rasterizationGuard, - left: 0, - bottom: rasterizationGuard, - right: 0 - ) - guard requiresGlyphOutlineMeasurement(attributedText.string) else { - return baselineInsets - } + // Measure the real glyph ink on the first line so extreme ascenders + // and descenders are covered even without a non-base character. let line = CTLineCreateWithAttributedString(attributedText) var ascent: CGFloat = 0 var descent: CGFloat = 0 var leading: CGFloat = 0 CTLineGetTypographicBounds(line, &ascent, &descent, &leading) let inkBounds = CTLineGetBoundsWithOptions(line, [.useGlyphPathBounds]) - let topOverflow = max(inkBounds.maxY - ascent, 0) - let bottomOverflow = max(-inkBounds.minY - descent, 0) - // Glyph-path bounds are vector bounds. TextKit can shift a fallback - // font's live baseline by as much as the adjacent logical point. One - // physical pixel absorbs fractional raster rounding and a second keeps - // the final antialiased pixel inside (rather than on) the clip edge. - // Keeping those two quantities separate avoids a scale-dependent edge - // clip: on a 2x iPad, a plain 1pt inset leaves the last two pixels of a - // tall script on the clipping boundary even though it is sufficient on - // a 3x phone. + // A flat point floor for the heading's headroom. CoreText can report + // zero ink overflow above ascent while the live `.oversize` layout + // still draws the first line high enough to clip the guard above, so + // keep a meaningful constant on top regardless of the measurement. + let minimumTopPadding: CGFloat = 3 + let scaledTopOverflow = ceil(max(inkBounds.maxY - ascent, 0) * scale) / scale + let scaledBottomOverflow = ceil(max(-inkBounds.minY - descent, 0) * scale) / scale return UIEdgeInsets( - top: ceil(topOverflow * scale) / scale + rasterizationGuard, + top: max(scaledTopOverflow, minimumTopPadding) + rasterizationGuard, left: 0, - bottom: ceil(bottomOverflow * scale) / scale + rasterizationGuard, + bottom: scaledBottomOverflow + rasterizationGuard, right: 0 ) } @@ -1603,19 +1593,19 @@ enum InlineContentTextLayout { && abs(lhs.right - rhs.right) < 0.01 } - static func requiresGlyphOutlineMeasurement(_ text: String) -> Bool { - text.unicodeScalars.contains { - CharacterSet.nonBaseCharacters.contains($0) - } - } - static func measuredHeight( usedRect: CGRect, extraLineFragmentRect: CGRect, containerInset: UIEdgeInsets ) -> CGFloat { - ceil( - containerInset.top + // With the `.oversize` sizing rule UIKit can draw the first line's ink + // above the container's origin (negative minY). Reserve top space for + // that extent on top of containerInset.top so the heading is never + // clipped, instead of only summing positive usedRect.maxY. + let glyphTopOverflow = min(usedRect.minY, 0) + let topReservation = max(containerInset.top, -glyphTopOverflow) + return ceil( + topReservation + max(usedRect.maxY, extraLineFragmentRect.maxY, 0) + containerInset.bottom ) diff --git a/TiebaPureTests/TiebaPureSmokeTests.swift b/TiebaPureTests/TiebaPureSmokeTests.swift index da9b195..7647ede 100644 --- a/TiebaPureTests/TiebaPureSmokeTests.swift +++ b/TiebaPureTests/TiebaPureSmokeTests.swift @@ -1,6 +1,7 @@ import Security import SwiftUI import ImageIO +import UIKit import XCTest @testable import TiebaPure @@ -188,9 +189,22 @@ final class TiebaPureSmokeTests: XCTestCase { ) } - func testInlineContentTextOnlyMeasuresGlyphOutlinesForCombiningMarks() { - XCTAssertFalse(InlineContentTextLayout.requiresGlyphOutlineMeasurement("普通中文回复")) - XCTAssertTrue(InlineContentTextLayout.requiresGlyphOutlineMeasurement("a\u{0301}")) + func testTextContainerInsetsGuaranteeFirstNameHeadroom() { + // Top inset always keeps a flat point floor for the first line's ink, + // on top of the scale-aware raster guard, so the heading cannot be + // clipped even when CoreText reports no measurable ascent overflow. + let scale = CGFloat(3) + let insets = InlineContentTextLayout.textContainerInsets( + for: NSAttributedString(string: "普通中文回复", attributes: [ + .font: UIFont.preferredFont(forTextStyle: .callout) + ]), + displayScale: scale + ) + let bottomGuard = 1 + 2 / scale + XCTAssertEqual(insets.top, 3 + bottomGuard, accuracy: 0.02) + XCTAssertEqual(insets.bottom, bottomGuard, accuracy: 0.02) + XCTAssertEqual(insets.left, 0) + XCTAssertEqual(insets.right, 0) } func testThreadPaginationContinuesAfterServerLocatedPostPage() { From 44434af74b86bcdb70fd1ac9bc7c8ae9baf8af0f Mon Sep 17 00:00:00 2001 From: IamNewHands Date: Fri, 2 Oct 2026 21:38:10 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(thread):=20=E6=AD=A3=E6=96=87=E8=B4=B4?= =?UTF-8?q?=E5=90=A7=E9=93=BE=E6=8E=A5=E5=9C=A8=E5=BA=94=E7=94=A8=E5=86=85?= =?UTF-8?q?=E6=89=93=E5=BC=80=EF=BC=8C=E6=A5=BC=E4=B8=AD=E6=A5=BC=E5=86=85?= =?UTF-8?q?=E8=81=94=E9=A2=84=E8=A7=88=E8=A1=A5=E4=B8=8A=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个阅读器缺口: 1. 帖子正文里的 tieba.baidu.com 链接此前直接交给 UIApplication.shared.open,装了官方客户端就跳官方客户端,否则跳 Safari,应用内没有任何拦截点。现在 InlineContentText 的链接回调先过 ExternalRoute.parse:命中 /p/ 或 /f?kw= 就在阅读器内打开,未命中 才保留原来的外部打开。帖子详情与楼中楼弹窗各自持有一个 fullScreenCover——楼中楼弹窗打开时,它背后的阅读器已经在呈现 sheet, 无法再呈现第二个 cover——因此从链接打开的帖子还能继续在内部打开下一 层链接。 2. 楼中楼的内联预览走 InlineContentText,而它对 .image/.video 是直接 break,只有图片的楼中楼在楼层下只剩「用户名: 」一行空白。现在媒体块 折叠为 [图片]/[视频],与既有的 [语音] 一致;完整弹窗本来就经 ContentBlocksView 渲染真实图片,未改动。 测试:ContentMappingTests 覆盖楼中楼图片块映射;TiebaPureSmokeTests 覆盖 内联占位文本与朗读文本;TiebaURLTests 覆盖正文链接校验与路由判定一致。 本机无 Xcode,编译与用例由 CI 验证。构建号 68 → 69(project.yml 两处 + project.pbxproj 四处,与 project.yml 保持一致)。 --- TiebaPure/App/RootView.swift | 4 +- .../Features/Thread/ContentBlockView.swift | 54 ++++++++++++++++--- TiebaPure/Features/Thread/PostRowView.swift | 8 ++- .../Features/Thread/SubpostPreviewView.swift | 12 +++-- .../Features/Thread/ThreadDetailView.swift | 34 ++++++++++++ TiebaPureTests/ContentMappingTests.swift | 27 ++++++++++ TiebaPureTests/TiebaPureSmokeTests.swift | 38 +++++++++++++ TiebaPureTests/TiebaURLTests.swift | 23 ++++++++ 8 files changed, 187 insertions(+), 13 deletions(-) diff --git a/TiebaPure/App/RootView.swift b/TiebaPure/App/RootView.swift index 7a6b2da..393b21f 100644 --- a/TiebaPure/App/RootView.swift +++ b/TiebaPure/App/RootView.swift @@ -254,7 +254,9 @@ enum AccountTransitionPolicy { /// Container for externally opened destinations. A cover with its own stack /// keeps deep links independent of whichever tab and stack the user was in. -private struct ExternalRouteView: View { +/// The thread reader reuses it for tieba.baidu.com links tapped inside a post, +/// so it must stay visible to the rest of the module. +struct ExternalRouteView: View { let account: Account? let route: ExternalRoute let onClose: () -> Void diff --git a/TiebaPure/Features/Thread/ContentBlockView.swift b/TiebaPure/Features/Thread/ContentBlockView.swift index df8a5cb..a6c648f 100644 --- a/TiebaPure/Features/Thread/ContentBlockView.swift +++ b/TiebaPure/Features/Thread/ContentBlockView.swift @@ -35,6 +35,7 @@ struct ContentBlocksView: View { var readerLineSpacing: ReaderLineSpacing = .standard var inlineAccessibilityIdentifier: String? var onOpenUser: ((UserSummary) -> Void)? + var onOpenTiebaRoute: ((ExternalRoute) -> Void)? var onPlainTextTap: (() -> Void)? var body: some View { @@ -57,6 +58,7 @@ struct ContentBlocksView: View { allowsTextSelection: true, accessibilityIdentifier: inlineAccessibilityIdentifier, onOpenUser: onOpenUser, + onOpenTiebaRoute: onOpenTiebaRoute, onPlainTextTap: onPlainTextTap ) .fixedSize(horizontal: false, vertical: true) @@ -96,6 +98,7 @@ struct ContentBlocksView: View { ), accessibilityIdentifier: inlineAccessibilityIdentifier, onOpenUser: onOpenUser, + onOpenTiebaRoute: onOpenTiebaRoute, onPlainTextTap: onPlainTextTap ) .fixedSize(horizontal: false, vertical: true) @@ -967,6 +970,18 @@ enum InlineContentTextMeasurementCache { } } +/// Inline text runs have no room for a media view of their own. The 楼中楼 +/// preview renders its replies through `InlineContentText` (the full sheet uses +/// `ContentBlocksView` and draws the real artwork), so an image or video block +/// that is silently skipped leaves an image-only reply reading as a blank line +/// under the author prefix. Media therefore collapses to a bracketed +/// placeholder, matching the long-standing "[语音]" treatment. +enum InlineMediaPlaceholder { + static let image = "[图片]" + static let video = "[视频]" + static let voice = "[语音]" +} + struct InlineContentText: UIViewRepresentable { enum PrefixPart: Equatable { case text(String) @@ -1069,6 +1084,10 @@ struct InlineContentText: UIViewRepresentable { var allowsTextSelection = false var accessibilityIdentifier: String? var onOpenUser: ((UserSummary) -> Void)? + /// Routes a tapped tieba.baidu.com link into the reader instead of handing + /// it to Safari or the official client. Nil keeps the external hand-off for + /// surfaces that cannot present an in-app destination. + var onOpenTiebaRoute: ((ExternalRoute) -> Void)? var onPlainTextTap: (() -> Void)? var emoticonImageProvider: (String) -> UIImage? = { code in TiebaEmoticon.cachedImage(for: code) @@ -1097,7 +1116,11 @@ struct InlineContentText: UIViewRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(onOpenUser: onOpenUser, onPlainTextTap: onPlainTextTap) + Coordinator( + onOpenUser: onOpenUser, + onPlainTextTap: onPlainTextTap, + onOpenTiebaRoute: onOpenTiebaRoute + ) } func makeUIView(context: Context) -> InlineContentTextView { @@ -1162,6 +1185,7 @@ struct InlineContentText: UIViewRepresentable { textView.panGestureRecognizer.isEnabled = false textView.normalizeContentOffsetIfTextSelectionIsInactive() context.coordinator.onOpenUser = onOpenUser + context.coordinator.onOpenTiebaRoute = onOpenTiebaRoute context.coordinator.onPlainTextTap = onPlainTextTap context.coordinator.observeArtwork( imageNames: rendered.emoticonImageNames, @@ -1200,6 +1224,7 @@ struct InlineContentText: UIViewRepresentable { final class Coordinator: NSObject, UITextViewDelegate, UIGestureRecognizerDelegate { weak var textView: InlineContentTextView? var onOpenUser: ((UserSummary) -> Void)? + var onOpenTiebaRoute: ((ExternalRoute) -> Void)? var onPlainTextTap: (() -> Void)? private var observedArtworkImageNames: Set = [] private var artworkNotificationToken: NSObjectProtocol? @@ -1210,10 +1235,12 @@ struct InlineContentText: UIViewRepresentable { init( onOpenUser: ((UserSummary) -> Void)?, - onPlainTextTap: (() -> Void)? + onPlainTextTap: (() -> Void)?, + onOpenTiebaRoute: ((ExternalRoute) -> Void)? ) { self.onOpenUser = onOpenUser self.onPlainTextTap = onPlainTextTap + self.onOpenTiebaRoute = onOpenTiebaRoute } deinit { @@ -1321,6 +1348,13 @@ struct InlineContentText: UIViewRepresentable { } return false } + // A supported Tieba page stays inside the reader; anything else keeps + // the previous hand-off to Safari or the official client. + if let route = ExternalRoute.parse(URL), + let onOpenTiebaRoute = self.onOpenTiebaRoute { + DispatchQueue.main.async { onOpenTiebaRoute(route) } + return false + } guard let safeURL = TiebaURL.webpage(URL.absoluteString) else { return false } UIApplication.shared.open(safeURL) return false @@ -1437,9 +1471,11 @@ struct InlineContentText: UIViewRepresentable { case let .emoticon(code): result.append(emoticonAttachment(for: code, font: font, attributes: baseAttributes)) case .voice: - result.append(NSAttributedString(string: "[语音]", attributes: baseAttributes)) - case .image, .video: - break + result.append(NSAttributedString(string: InlineMediaPlaceholder.voice, attributes: baseAttributes)) + case .image: + result.append(NSAttributedString(string: InlineMediaPlaceholder.image, attributes: baseAttributes)) + case .video: + result.append(NSAttributedString(string: InlineMediaPlaceholder.video, attributes: baseAttributes)) } } @@ -1473,9 +1509,11 @@ struct InlineContentText: UIViewRepresentable { case let .emoticon(code): result.append(TiebaEmoticon.displayText(for: code)) case .voice: - result.append("[语音]") - case .image, .video: - break + result.append(InlineMediaPlaceholder.voice) + case .image: + result.append(InlineMediaPlaceholder.image) + case .video: + result.append(InlineMediaPlaceholder.video) } } return result diff --git a/TiebaPure/Features/Thread/PostRowView.swift b/TiebaPure/Features/Thread/PostRowView.swift index 955b3f4..487a3e3 100644 --- a/TiebaPure/Features/Thread/PostRowView.swift +++ b/TiebaPure/Features/Thread/PostRowView.swift @@ -9,6 +9,7 @@ struct PostRowView: View { let isMainPost: Bool let onOpenSubposts: ((Post) -> Void)? let onOpenUser: ((UserSummary) -> Void)? + let onOpenTiebaRoute: ((ExternalRoute) -> Void)? let isLikeUpdating: Bool let onToggleLike: (() -> Void)? let onReply: (() -> Void)? @@ -20,6 +21,7 @@ struct PostRowView: View { isMainPost: Bool = false, onOpenSubposts: ((Post) -> Void)? = nil, onOpenUser: ((UserSummary) -> Void)? = nil, + onOpenTiebaRoute: ((ExternalRoute) -> Void)? = nil, isLikeUpdating: Bool = false, onToggleLike: (() -> Void)? = nil, onReply: (() -> Void)? = nil @@ -30,6 +32,7 @@ struct PostRowView: View { self.isMainPost = isMainPost self.onOpenSubposts = onOpenSubposts self.onOpenUser = onOpenUser + self.onOpenTiebaRoute = onOpenTiebaRoute self.isLikeUpdating = isLikeUpdating self.onToggleLike = onToggleLike self.onReply = onReply @@ -91,6 +94,7 @@ struct PostRowView: View { inlineAccessibilityIdentifier: isMainPost ? "thread-main-text" : "thread-reply-text", + onOpenTiebaRoute: onOpenTiebaRoute, onPlainTextTap: onReply ) @@ -112,7 +116,8 @@ struct PostRowView: View { totalCount: post.subpostCount, threadAuthorID: threadAuthorID, onOpenAll: onOpenSubposts.map { open in { open(post) } }, - onOpenUser: onOpenUser + onOpenUser: onOpenUser, + onOpenTiebaRoute: onOpenTiebaRoute ) } } @@ -136,6 +141,7 @@ extension PostRowView: Equatable { && lhs.isLikeUpdating == rhs.isLikeUpdating && (lhs.onOpenSubposts != nil) == (rhs.onOpenSubposts != nil) && (lhs.onOpenUser != nil) == (rhs.onOpenUser != nil) + && (lhs.onOpenTiebaRoute != nil) == (rhs.onOpenTiebaRoute != nil) && (lhs.onToggleLike != nil) == (rhs.onToggleLike != nil) && (lhs.onReply != nil) == (rhs.onReply != nil) } diff --git a/TiebaPure/Features/Thread/SubpostPreviewView.swift b/TiebaPure/Features/Thread/SubpostPreviewView.swift index 4a5f282..158d39f 100644 --- a/TiebaPure/Features/Thread/SubpostPreviewView.swift +++ b/TiebaPure/Features/Thread/SubpostPreviewView.swift @@ -6,19 +6,22 @@ struct SubpostPreviewView: View { let threadAuthorID: Int64? let onOpenAll: (() -> Void)? let onOpenUser: ((UserSummary) -> Void)? + let onOpenTiebaRoute: ((ExternalRoute) -> Void)? init( subposts: [Subpost], totalCount: Int, threadAuthorID: Int64?, onOpenAll: (() -> Void)? = nil, - onOpenUser: ((UserSummary) -> Void)? = nil + onOpenUser: ((UserSummary) -> Void)? = nil, + onOpenTiebaRoute: ((ExternalRoute) -> Void)? = nil ) { self.subposts = Array(subposts.prefix(3)) self.totalCount = totalCount self.threadAuthorID = threadAuthorID self.onOpenAll = onOpenAll self.onOpenUser = onOpenUser + self.onOpenTiebaRoute = onOpenTiebaRoute } var body: some View { @@ -30,7 +33,8 @@ struct SubpostPreviewView: View { subpost: subpost, threadAuthorID: threadAuthorID, lineLimit: ThreadContentDisplayPolicy.detailLineLimit, - onOpenUser: onOpenUser + onOpenUser: onOpenUser, + onOpenTiebaRoute: onOpenTiebaRoute ) } } @@ -89,6 +93,7 @@ struct SubpostInlineRow: View { let threadAuthorID: Int64? var lineLimit: Int = ThreadContentDisplayPolicy.detailLineLimit var onOpenUser: ((UserSummary) -> Void)? + var onOpenTiebaRoute: ((ExternalRoute) -> Void)? var body: some View { InlineContentText( @@ -106,7 +111,8 @@ struct SubpostInlineRow: View { for: lineLimit ), accessibilityIdentifier: "thread-subpost-preview-text", - onOpenUser: onOpenUser + onOpenUser: onOpenUser, + onOpenTiebaRoute: onOpenTiebaRoute ) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/TiebaPure/Features/Thread/ThreadDetailView.swift b/TiebaPure/Features/Thread/ThreadDetailView.swift index 8b04818..35bfbb1 100644 --- a/TiebaPure/Features/Thread/ThreadDetailView.swift +++ b/TiebaPure/Features/Thread/ThreadDetailView.swift @@ -83,6 +83,7 @@ struct ThreadDetailView: View { @State private var contentNavigationGeneration = 0 @State private var contentNavigationTask: Task? @State private var pendingSubpostInitialID: UInt64? + @State private var linkedRoute: ExternalRoute? @State private var contentActionError: String? @State private var showsOwnThreadDeleteConfirmation = false @State private var isDeletingOwnThread = false @@ -130,6 +131,15 @@ struct ThreadDetailView: View { .fullScreenInteractiveNavigationPop( isEnabled: selectedSubpostPost == nil && isDeletingOwnThread == false ) + // A tieba.baidu.com link tapped inside a post opens here instead of + // leaving for Safari or the official client. The cover carries its + // own stack, so a thread opened from a link can itself open the + // next link without unwinding the reader underneath. + .fullScreenCover(item: $linkedRoute) { route in + ExternalRouteView(account: account, route: route) { + linkedRoute = nil + } + } } private var decoratedContent: some View { @@ -405,6 +415,7 @@ struct ThreadDetailView: View { pendingSubmissionAccount = nil pendingSubmissionRouteID = nil pendingSubpostInitialID = nil + linkedRoute = nil isSearchActive = false isStandaloneSearchPresented = false selectedUser = nil @@ -569,6 +580,7 @@ struct ThreadDetailView: View { isMainPost: true, onOpenSubposts: openSubpostsIfPossible, onOpenUser: openUser, + onOpenTiebaRoute: openTiebaRoute, isLikeUpdating: updatingPostLikeIDs.contains(mainPost.id), onToggleLike: contentSubmissionSettingsStore.likesEnabled && mainPost.id > 0 ? { toggleLike(for: mainPost, objectType: .thread) } @@ -642,6 +654,7 @@ struct ThreadDetailView: View { threadAuthorID: threadAuthorID, onOpenSubposts: openSubpostsIfPossible, onOpenUser: openUser, + onOpenTiebaRoute: openTiebaRoute, isLikeUpdating: updatingPostLikeIDs.contains(post.id), onToggleLike: contentSubmissionSettingsStore.likesEnabled ? { toggleLike(for: post, objectType: .post) } @@ -2097,6 +2110,10 @@ struct ThreadDetailView: View { selectedSubpostPost = post } + private func openTiebaRoute(_ route: ExternalRoute) { + linkedRoute = route + } + private func toggleLike(for post: Post, objectType: TiebaLikeObjectType) { guard contentSubmissionSettingsStore.likesEnabled else { return } guard updatingPostLikeIDs.contains(post.id) == false else { return } @@ -2796,6 +2813,7 @@ private struct SubpostListSheet: View { @State private var pendingSubmissionRouteID: UUID? @State private var submissionReloadGeneration = 0 @State private var submissionReloadTask: Task? + @State private var linkedRoute: ExternalRoute? init( account: Account?, @@ -2874,6 +2892,7 @@ private struct SubpostListSheet: View { readerFontFamily: readingPreferences.fontFamily, readerLineSpacing: readingPreferences.lineSpacing, inlineAccessibilityIdentifier: "thread-subpost-parent-text", + onOpenTiebaRoute: openTiebaRoute, onPlainTextTap: contentSubmissionSettingsStore.repliesEnabled ? openParentReplyComposer : nil @@ -2903,6 +2922,7 @@ private struct SubpostListSheet: View { subpost: subpost, threadAuthorID: threadAuthorID, onOpenUser: openUser, + onOpenTiebaRoute: openTiebaRoute, isLikeUpdating: updatingLikeIDs.contains(subpost.id), onToggleLike: contentSubmissionSettingsStore.likesEnabled ? { toggleSubpostLike(subpost) } @@ -3029,6 +3049,14 @@ private struct SubpostListSheet: View { .environmentObject(environment) } } + // A tieba.baidu.com link tapped in the parent floor or in a + // 楼中楼 reply opens from here: the reader behind this sheet is + // already presenting it and cannot present a second cover. + .fullScreenCover(item: $linkedRoute) { route in + ExternalRouteView(account: account, route: route) { + linkedRoute = nil + } + } } .task { guard didLoad == false else { return } @@ -3053,6 +3081,10 @@ private struct SubpostListSheet: View { } } + private func openTiebaRoute(_ route: ExternalRoute) { + linkedRoute = route + } + private var selectedUserIsActive: Binding { Binding( get: { selectedUser != nil }, @@ -3374,6 +3406,7 @@ private struct SubpostRowView: View { let subpost: Subpost let threadAuthorID: Int64? let onOpenUser: ((UserSummary) -> Void)? + let onOpenTiebaRoute: ((ExternalRoute) -> Void)? let isLikeUpdating: Bool let onToggleLike: (() -> Void)? let onReply: (() -> Void)? @@ -3407,6 +3440,7 @@ private struct SubpostRowView: View { readerLineSpacing: readingPreferences.lineSpacing, inlineAccessibilityIdentifier: "thread-subpost-text", onOpenUser: onOpenUser, + onOpenTiebaRoute: onOpenTiebaRoute, onPlainTextTap: onReply ) ThreadPostMetadataView( diff --git a/TiebaPureTests/ContentMappingTests.swift b/TiebaPureTests/ContentMappingTests.swift index 5dd7c30..8b82cda 100644 --- a/TiebaPureTests/ContentMappingTests.swift +++ b/TiebaPureTests/ContentMappingTests.swift @@ -611,6 +611,33 @@ final class ContentMappingTests: XCTestCase { XCTAssertEqual(mapped.previewSubposts.first?.blocks.compactMap(\.plainText).joined(), "回复内容") } + func testSubpostMappingKeepsImageContentForTheDetailSheet() { + var imageContent = Tieba_PbContent() + imageContent.type = 3 + imageContent.src = "https://tiebac.baidu.com/forum/w%3D580/sign=abc.jpg" + imageContent.cdnSrc = "https://tiebac.baidu.com/forum/w%3D580/sign=abc.jpg" + imageContent.bsize = "800,600" + imageContent.width = 800 + imageContent.height = 600 + + var subpost = Tieba_SubPostList() + subpost.id = 99 + subpost.content = [imageContent] + + let mapped = PostMapper.subpost(subpost) + + XCTAssertEqual(mapped.blocks.count, 1) + guard case let .image(image)? = mapped.blocks.first else { + return XCTFail("Expected the subpost image block to survive mapping") + } + XCTAssertEqual( + image.thumbnailURL?.absoluteString, + "https://tiebac.baidu.com/forum/w%3D580/sign=abc.jpg" + ) + XCTAssertEqual(image.width, 800) + XCTAssertEqual(image.height, 600) + } + func testPreviewSubpostResolvesUIDLessStructuredReplyTargetFromUniqueUser() { var target = Tieba_User() target.id = 42 diff --git a/TiebaPureTests/TiebaPureSmokeTests.swift b/TiebaPureTests/TiebaPureSmokeTests.swift index 7647ede..3a19a0d 100644 --- a/TiebaPureTests/TiebaPureSmokeTests.swift +++ b/TiebaPureTests/TiebaPureSmokeTests.swift @@ -3939,6 +3939,44 @@ final class TiebaPureSmokeTests: XCTestCase { XCTAssertEqual(linkedUser.displayNameResolved, "被回复用户") } + func testInlineSubpostMediaCollapsesToBracketedPlaceholder() { + let image = ImageContent( + thumbnailURL: URL(string: "https://tiebac.baidu.com/forum/w%3D580/sign=abc.jpg"), + originalURL: nil, + width: 800, + height: 600, + showOriginalButton: false + ) + let video = VideoContent( + videoURL: nil, + coverURL: nil, + webURL: nil, + width: 0, + height: 0, + duration: 0 + ) + let prefix = [InlineContentText.PrefixPart.text("作者: ")] + + // The 楼中楼 preview renders through the inline text path, which has no + // media view of its own: an image-only reply used to read as a blank + // line under the author prefix. + let imageOnly = InlineContentText( + blocks: [.image(image)], + style: .subpost, + prefixParts: prefix + ) + XCTAssertEqual(imageOnly.attributedString().string, "作者: [图片]") + XCTAssertEqual(imageOnly.accessibilityText(), "作者: [图片]") + + let mixed = InlineContentText( + blocks: [.image(image), .text("配图说明"), .video(video)], + style: .subpost, + prefixParts: prefix + ) + XCTAssertEqual(mixed.attributedString().string, "作者: [图片]配图说明[视频]") + XCTAssertEqual(mixed.accessibilityText(), "作者: [图片]配图说明[视频]") + } + func testReferenceSubpostFixtureExercisesMissingUserTypeZeroPayload() throws { let first = try XCTUnwrap(FixtureTiebaAPI.referenceSubpostFixtures.first) diff --git a/TiebaPureTests/TiebaURLTests.swift b/TiebaPureTests/TiebaURLTests.swift index b56f27c..e8ae6cb 100644 --- a/TiebaPureTests/TiebaURLTests.swift +++ b/TiebaPureTests/TiebaURLTests.swift @@ -221,6 +221,29 @@ final class TiebaURLTests: XCTestCase { XCTAssertNil(ExternalRoute.parse(try XCTUnwrap(URL(string: "https://faketieba.baidu.com.evil.example/p/1")))) } + func testTiebaContentLinksAgreeWithTheInAppReaderRoute() throws { + // The reader validates a post's .link block with TiebaURL.webpage and + // then hands that same URL to ExternalRoute.parse. Both policies have to + // agree, otherwise a supported page silently leaves for Safari. + for supported in [ + "https://tieba.baidu.com/p/424242?pid=99&see_lz=1#floor", + "https://c.tieba.baidu.com/p/31337", + "https://tieba.baidu.com/f?kw=%E6%B5%8B%E8%AF%95&ie=utf-8" + ] { + let url = try XCTUnwrap(TiebaURL.webpage(supported)) + XCTAssertNotNil(ExternalRoute.parse(url), supported) + } + + for unsupported in [ + "https://tieba.baidu.com/home/main", + "https://tieba.baidu.com/p/notanumber", + "https://example.com/p/1" + ] { + let url = try XCTUnwrap(TiebaURL.webpage(unsupported)) + XCTAssertNil(ExternalRoute.parse(url), unsupported) + } + } + func testExternalRouteBuildsImportURLOnlyForSupportedTiebaPages() throws { let threadURL = try XCTUnwrap(URL(string: "https://tieba.baidu.com/p/424242?pid=99")) let importedThreadURL = try XCTUnwrap(ExternalRoute.importURL(forWebURL: threadURL))