Skip to content
Open
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
4 changes: 3 additions & 1 deletion TiebaPure/App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 65 additions & 37 deletions TiebaPure/Features/Thread/ContentBlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -57,6 +58,7 @@ struct ContentBlocksView: View {
allowsTextSelection: true,
accessibilityIdentifier: inlineAccessibilityIdentifier,
onOpenUser: onOpenUser,
onOpenTiebaRoute: onOpenTiebaRoute,
onPlainTextTap: onPlainTextTap
)
.fixedSize(horizontal: false, vertical: true)
Expand Down Expand Up @@ -96,6 +98,7 @@ struct ContentBlocksView: View {
),
accessibilityIdentifier: inlineAccessibilityIdentifier,
onOpenUser: onOpenUser,
onOpenTiebaRoute: onOpenTiebaRoute,
onPlainTextTap: onPlainTextTap
)
.fixedSize(horizontal: false, vertical: true)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<String> = []
private var artworkNotificationToken: NSObjectProtocol?
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1562,36 +1600,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
)
}
Expand All @@ -1603,19 +1631,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
)
Expand Down
8 changes: 7 additions & 1 deletion TiebaPure/Features/Thread/PostRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -91,6 +94,7 @@ struct PostRowView: View {
inlineAccessibilityIdentifier: isMainPost
? "thread-main-text"
: "thread-reply-text",
onOpenTiebaRoute: onOpenTiebaRoute,
onPlainTextTap: onReply
)

Expand All @@ -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
)
}
}
Expand All @@ -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)
}
Expand Down
12 changes: 9 additions & 3 deletions TiebaPure/Features/Thread/SubpostPreviewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -30,7 +33,8 @@ struct SubpostPreviewView: View {
subpost: subpost,
threadAuthorID: threadAuthorID,
lineLimit: ThreadContentDisplayPolicy.detailLineLimit,
onOpenUser: onOpenUser
onOpenUser: onOpenUser,
onOpenTiebaRoute: onOpenTiebaRoute
)
}
}
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down
Loading