From 686753721ef63e96daae9a718c5bcacc266ddea4 Mon Sep 17 00:00:00 2001 From: Mauro Romito Date: Mon, 11 May 2026 16:59:40 +0200 Subject: [PATCH] new implementation based on the usage of NSTextAttachment to allocate space for the timestamp --- .../Sources/Other/Extensions/String.swift | 19 ++++ .../Sources/Other/Pills/MessageText.swift | 93 +++++++++++++++---- .../EventBasedTimelineItemProtocol.swift | 29 ++++-- 3 files changed, 115 insertions(+), 26 deletions(-) diff --git a/ElementX/Sources/Other/Extensions/String.swift b/ElementX/Sources/Other/Extensions/String.swift index 7b4a4dec2..0f1badb03 100644 --- a/ElementX/Sources/Other/Extensions/String.swift +++ b/ElementX/Sources/Other/Extensions/String.swift @@ -101,6 +101,25 @@ extension String { } } +extension String { + /// Whether the first character with a strong BiDi direction is right-to-left. + /// Mirrors the Unicode BiDi "first strong" rule used by TextKit to resolve + /// paragraph direction when `baseWritingDirection` is `.natural`. + var firstStrongCharacterIsRTL: Bool { + for scalar in unicodeScalars { + let value = scalar.value + // Strong RTL: Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic, + // Arabic Extended, and their presentation forms. + let isStrongRTL = (0x0590...0x08FF).contains(value) || + (0xFB1D...0xFDFF).contains(value) || + (0xFE70...0xFEFF).contains(value) + if isStrongRTL { return true } + if scalar.properties.isAlphabetic { return false } + } + return false + } +} + extension String { /// To be used if the string is actually a URL var asSanitizedLink: String { diff --git a/ElementX/Sources/Other/Pills/MessageText.swift b/ElementX/Sources/Other/Pills/MessageText.swift index 478b06743..3d2318d15 100644 --- a/ElementX/Sources/Other/Pills/MessageText.swift +++ b/ElementX/Sources/Other/Pills/MessageText.swift @@ -13,7 +13,7 @@ final class MessageTextView: UITextView, PillAttachmentViewProviderDelegate, UIG var timelineContext: TimelineViewModel.Context? var updateClosure: (() -> Void)? private var pillViews = NSHashTable.weakObjects() - + override func addGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) { // We don't need to change the behaviour on MacOS if !ProcessInfo.processInfo.isiOSAppOnMac { @@ -41,11 +41,11 @@ final class MessageTextView: UITextView, PillAttachmentViewProviderDelegate, UIG updateClosure?() } } - + func registerPillView(_ pillView: UIView) { pillViews.add(pillView) } - + func flushPills() { for view in pillViews.allObjects { view.alpha = 0.0 @@ -55,9 +55,25 @@ final class MessageTextView: UITextView, PillAttachmentViewProviderDelegate, UIG } } +/// An `NSTextAttachment` that takes up its declared `bounds` but draws nothing — used +/// as an invisible spacer so a message bubble's text reserves room for the overlaid +/// timestamp. Without this subclass, an attachment with no image would render TextKit's +/// default "missing image" glyph. +private final class TransparentTextAttachment: NSTextAttachment { + override func image(forBounds imageBounds: CGRect, + textContainer: NSTextContainer?, + characterIndex charIndex: Int) -> UIImage? { + let format = UIGraphicsImageRendererFormat() + format.opaque = false + let size = CGSize(width: max(imageBounds.width, 1), height: max(imageBounds.height, 1)) + return UIGraphicsImageRenderer(size: size, format: format).image { _ in } + } +} + struct MessageText: UIViewRepresentable { @Environment(\.openURL) private var openURLAction @Environment(\.timelineContext) private var viewModel + @Environment(\.layoutDirection) private var layoutDirection @State private var computedSizes = [Double: CGSize]() @State var attributedString: AttributedString { @@ -65,7 +81,51 @@ struct MessageText: UIViewRepresentable { computedSizes.removeAll() } } + + /// Reserves an invisible inline area of this size at the very end of the rendered + /// text. Used so that the message bubble's natural size accommodates the timestamp + /// overlaid on top of the bubble's bottom-trailing corner. TextKit decides whether + /// the reserved region fits on the last line (timestamp tucks) or wraps to a new + /// line (timestamp drops below the text). + var trailingReservedSize: CGSize = .zero + + private func makeAttributedText() -> NSAttributedString? { + guard let baseText = try? NSAttributedString(attributedString, including: \.elementX) else { + return nil + } + let combined = NSMutableAttributedString(attributedString: baseText) + if trailingReservedSize.width > 0 { + let attachment = TransparentTextAttachment() + attachment.isAccessibilityElement = false + attachment.bounds = CGRect(origin: .zero, + size: CGSize(width: trailingReservedSize.width, + height: max(trailingReservedSize.height, 1))) + + // When the last paragraph's natural text direction doesn't match the layout + // direction, the inline attachment would land on the wrong side and overlap the + // overlaid timestamp. Force it onto its own line so the bubble just grows taller. + if !lastParagraphDirectionMatchesLayout(in: combined) { + combined.append(NSAttributedString(string: "\n")) + } + + combined.append(NSAttributedString(attachment: attachment)) + } + + return combined + } + + private func lastParagraphDirectionMatchesLayout(in attributedText: NSAttributedString) -> Bool { + let string = attributedText.string as NSString + guard string.length > 0 else { return true } + + let lastParagraphRange = string.paragraphRange(for: NSRange(location: string.length - 1, length: 0)) + let lastParagraph = string.substring(with: lastParagraphRange) + + let textIsRTL = lastParagraph.firstStrongCharacterIsRTL + return textIsRTL == (layoutDirection == .rightToLeft) + } + func makeUIView(context: Context) -> MessageTextView { // Need to use TextKit 1 for mentions let textView = MessageTextView(usingTextLayoutManager: false) @@ -82,7 +142,7 @@ struct MessageText: UIViewRepresentable { textView.isEditable = false textView.isScrollEnabled = false textView.adjustsFontForContentSizeCategory = true - + // Required to allow tapping links // We disable selection at delegate level textView.isSelectable = true @@ -90,29 +150,30 @@ struct MessageText: UIViewRepresentable { // Otherwise links can be dragged and dropped when long pressed textView.textDragInteraction?.isEnabled = false - + textView.contentInset = .zero textView.contentInsetAdjustmentBehavior = .never textView.textContainerInset = .zero textView.textContainer.lineFragmentPadding = 0 textView.layoutManager.usesFontLeading = false textView.backgroundColor = .clear - if let attributedText = try? NSAttributedString(attributedString, including: \.elementX) { + if let attributedText = makeAttributedText() { textView.attributedText = attributedText } textView.delegate = context.coordinator return textView } - + func updateUIView(_ uiView: MessageTextView, context: Context) { - if let newAttributedText = try? NSAttributedString(attributedString, including: \.elementX), + if let newAttributedText = makeAttributedText(), uiView.attributedText != newAttributedText { uiView.flushPills() uiView.attributedText = newAttributedText + computedSizes.removeAll() } context.coordinator.openURLAction = openURLAction } - + func sizeThatFits(_ proposal: ProposedViewSize, uiView: MessageTextView, context: Context) -> CGSize? { let proposalWidth = proposal.width ?? UIView.layoutFittingExpandedSize.width @@ -126,14 +187,14 @@ struct MessageText: UIViewRepresentable { } return size } - + func makeCoordinator() -> Coordinator { Coordinator(openURLAction: openURLAction) } - + final class Coordinator: NSObject, UITextViewDelegate { var openURLAction: OpenURLAction - + init(openURLAction: OpenURLAction) { self.openURLAction = openURLAction } @@ -157,7 +218,7 @@ struct MessageText: UIViewRepresentable { } return defaultAction } - + func textView(_ textView: UITextView, menuConfigurationFor textItem: UITextItem, defaultMenu: UIMenu) -> UITextItem.MenuConfiguration? { switch textItem.content { case let .link(url): @@ -196,14 +257,14 @@ struct MessageText_Previews: PreviewProvider, TestablePreview { .mergeAttributes(defaultFontContainer) return attributedString }() - + private static let htmlStringWithQuote = """
A blockquote that is long and goes onto multiple lines as the first item in the message

Then another line of text here to reply to the blockquote, which is also a multiline component.

""" private static let htmlStringWithList = "

This is a list

\n\n" - + private static let attributedStringBuilder = AttributedStringBuilder(mentionBuilder: MentionBuilder()) static var attachmentPreview: some View { @@ -211,7 +272,7 @@ struct MessageText_Previews: PreviewProvider, TestablePreview { .border(Color.purple) .environmentObject(TimelineViewModel.mock.context) } - + static var previews: some View { MessageText(attributedString: attributedString) .border(Color.purple) diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift index 7c099b350..960eafbde 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift @@ -64,18 +64,27 @@ extension EventBasedTimelineItemProtocol { } } - func additionalWhitespaces() -> Int { - var whiteSpaces = 1 - localizedSendInfo.forEach { _ in - whiteSpaces += 1 - } - - // To account for the extra spacing created by the status icon + /// The inline area to reserve at the trailing edge of the message text so the + /// bubble's natural size accommodates the timestamp (which is overlaid on the + /// bubble's bottom-trailing corner). Text wraps around this area: if it fits on + /// the last line, the timestamp tucks inline; otherwise it sits on a new line. + /// Sized using the same dynamic-type-aware font as the rendered timestamp. + var trailingReservedSize: CGSize { + let textStyle: UIFont.TextStyle = .caption1 // matches compound.bodyXS + let font = UIFont.preferredFont(forTextStyle: textStyle) + let textSize = (localizedSendInfo as NSString).size(withAttributes: [.font: font]) + var width = textSize.width + var height = textSize.height if hasStatusIcon { - whiteSpaces += 3 + // CompoundIcon at .xSmall is 16pt at the base size; scale with the same UIFontMetrics + // as the icon's `relativeTo: .compound.bodyXS` modifier, plus the 4pt HStack spacing. + let iconSize = UIFontMetrics(forTextStyle: textStyle).scaledValue(for: 16) + width += iconSize + 4 + height = max(height, iconSize) } - - return whiteSpaces + // Small visual gap between the text content and the overlaid timestamp. + width += 4 + return CGSize(width: ceil(width), height: ceil(height)) } /// contains the timestamp and an optional edited localised prefix