Files
Mauro a520d94760 Swift 6 Migration (#5733)
* Enable Swift 6.2 with default main actor isolation for the app target

Turn on SWIFT_VERSION 6.2, SWIFT_APPROACHABLE_CONCURRENCY and SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor for the ElementX target, matching the NSE and Share Extension (which already use 6.2 without the main actor default).

# Conflicts:
#	ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

* Update the code generation templates for Swift 6

Emit nonisolated/@unchecked Sendable mocks and nonisolated string enums, and mark async mock methods @concurrent so they keep suspending like the real implementations (otherwise intermediate states are unobservable in tests). Includes the regenerated mocks and strings.

# Conflicts:
#	ElementX/Sources/Mocks/Generated/GeneratedMocks.swift

* Make the data layer and services nonisolated and Sendable

Annotate the value-type models, Rust SDK proxy wrappers, timeline item graph, settings, logging and NSE-shared types as nonisolated, and conform the service protocols to Sendable so their existentials can cross isolation boundaries. These types run off the main actor and no longer inherit the target's default main actor isolation.

* Adopt the new isolation across call sites, mocks and signatures

Update call sites, closure signatures and mock configuration helpers to match the nonisolated/Sendable data layer, including the main actor isolated mock inits and the @Sendable filter on recentlyVisitedRooms.

^ Conflicts:
^	ElementX/Sources/Services/SecureBackup/SecureBackupController.swift

* Remove redundant @MainActor annotations

With the app target defaulting to main actor isolation, the explicit @MainActor on view models, coordinators, views and their protocols is no longer required.

* Fix concurrency hotspots surfaced by the migration

Resolve the room summary main-thread deadlock by moving diff processing onto AsyncStreams, add the SDKListener.onMainActor FIFO bridge for SDK callbacks, hop KVO/timer/UIKit callbacks to the main actor via assumeIsolated, add isolated deinits, and apply the few justified nonisolated(unsafe) escapes for queue-confined Apple APIs.

* Fix the unit tests for Swift 6 concurrency

Hop the waitForConfirmation body onto the caller's actor (Swift Testing runs confirmation closures off-actor), and adjust the affected suites and async signatures.

* offload diffs on concurrent threads instead of main actor

* make room info protocols sendable

* use a task group for fetching data so that everything runs concurrently in the TextRoomTimelineView when getting the preview.

* use the generic onMainActor implementation, which already makes a stream and send the continuation on the main actor.

* small pr comments issues being addressed

* update enterprise

* Update StaticCode to 1.1.0 and specify the required Swift isolation.

* fix ElementCallServiceTests

* update enterprise

* re-generate mocks

* update enterprise

* enterprise new code conformed to swift 6

---------

Co-authored-by: Doug <douglase@element.io>
2026-06-18 17:55:31 +02:00

335 lines
14 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2023-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import MatrixRustSDK
import SwiftUI
final class MessageTextView: UITextView, PillAttachmentViewProviderDelegate, UIGestureRecognizerDelegate {
var timelineContext: TimelineViewModel.Context?
var updateClosure: (() -> Void)?
private var pillViews = NSHashTable<UIView>.weakObjects()
override func addGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
// We don't need to change the behaviour on MacOS
if !ProcessInfo.processInfo.isiOSAppOnMac {
gestureRecognizer.delegate = self
}
super.addGestureRecognizer(gestureRecognizer)
}
/// This prevents the magnifying glass from showing up
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if otherGestureRecognizer is UILongPressGestureRecognizer {
return false
}
return true
}
func invalidateTextAttachmentsDisplay() {
attributedText.enumerateAttribute(.attachment,
in: NSRange(location: 0, length: attributedText.length),
options: []) { value, range, _ in
guard value != nil else {
return
}
self.layoutManager.invalidateDisplay(forCharacterRange: range)
updateClosure?()
}
}
func registerPillView(_ pillView: UIView) {
pillViews.add(pillView)
}
func flushPills() {
for view in pillViews.allObjects {
view.alpha = 0.0
view.removeFromSuperview()
}
pillViews.removeAllObjects()
}
}
/// 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 nonisolated 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
/// Cache key for `sizeThatFits`. Keyed on the reserved trailing size as well as the proposed
/// width to account for any changes on the send info label that happen after the first rendering.
private struct SizeCacheKey: Hashable {
let width: Double
let reservedSize: CGSize
}
@State private var computedSizes = [SizeCacheKey: CGSize]()
@State var attributedString: AttributedString {
didSet {
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)))
// Inherit the font from the preceding character so the appended runs
// (newline and attachment) carry the same line metrics as the surrounding
// text — otherwise a small default font on those characters can shrink the
// line height and clip e.g. a font-boosted lone emoji.
let trailingFont = baseText.length > 0
? baseText.attribute(.font, at: baseText.length - 1, effectiveRange: nil)
: nil
// 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) {
let newlineString = NSMutableAttributedString(string: "\n")
if let trailingFont {
newlineString.addAttribute(.font,
value: trailingFont,
range: NSRange(location: 0, length: newlineString.length))
}
combined.append(newlineString)
}
let attachmentString = NSMutableAttributedString(attachment: attachment)
if let trailingFont {
attachmentString.addAttribute(.font,
value: trailingFont,
range: NSRange(location: 0, length: attachmentString.length))
}
combined.append(attachmentString)
}
return combined
}
private func lastParagraphDirectionMatchesLayout(in attributedText: NSAttributedString) -> Bool {
let string = attributedText.string as NSString
guard string.length > 0 else { return true }
// Isolate the last paragraph because its direction is what decides
// which side the trailing attachment ends up on.
let lastParagraphRange = string.paragraphRange(for: NSRange(location: string.length - 1, length: 0))
let lastParagraph = string.substring(with: lastParagraphRange)
let textIsRTL = lastParagraph.firstStrongCharacterIsRTL
let layoutIsRTL = layoutDirection == .rightToLeft
return textIsRTL == layoutIsRTL
}
func makeUIView(context: Context) -> MessageTextView {
// Need to use TextKit 1 for mentions
let textView = MessageTextView(usingTextLayoutManager: false)
textView.timelineContext = viewModel
textView.updateClosure = { [weak textView] in
guard let textView else { return }
do {
attributedString = try AttributedString(textView.attributedText, including: \.elementX)
} catch {
MXLog.error("Failed to update attributedString: \(error)]")
return
}
}
textView.isEditable = false
textView.isScrollEnabled = false
textView.adjustsFontForContentSizeCategory = true
// Required to allow tapping links
// We disable selection at delegate level
textView.isSelectable = true
textView.isUserInteractionEnabled = true
// Otherwise links can be dragged and dropped when long pressed
textView.textDragInteraction?.isEnabled = false
// Otherwise items dropped onto a bubble land on the text view instead of
// bubbling up to the timeline's drop handler.
if let textDropInteraction = textView.textDropInteraction {
textView.removeInteraction(textDropInteraction)
}
textView.contentInset = .zero
textView.contentInsetAdjustmentBehavior = .never
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.layoutManager.usesFontLeading = false
textView.backgroundColor = .clear
if let attributedText = makeAttributedText() {
textView.attributedText = attributedText
}
textView.delegate = context.coordinator
return textView
}
func updateUIView(_ uiView: MessageTextView, context: Context) {
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
let key = SizeCacheKey(width: proposalWidth, reservedSize: trailingReservedSize)
if let size = computedSizes[key] {
return size
}
let size = uiView.sizeThatFits(CGSize(width: proposalWidth, height: UIView.layoutFittingCompressedSize.height))
DispatchQueue.main.async {
computedSizes[key] = size
}
return size
}
func makeCoordinator() -> Coordinator {
Coordinator(openURLAction: openURLAction)
}
final class Coordinator: NSObject, UITextViewDelegate {
var openURLAction: OpenURLAction
init(openURLAction: OpenURLAction) {
self.openURLAction = openURLAction
}
func textViewDidChangeSelection(_ textView: UITextView) {
guard !ProcessInfo.processInfo.isiOSAppOnMac else {
return
}
textView.selectedTextRange = nil
}
func textView(_ textView: UITextView, primaryActionFor textItem: UITextItem, defaultAction: UIAction) -> UIAction? {
if case .link(let url) = textItem.content {
return .init(title: defaultAction.title,
image: defaultAction.image,
discoverabilityTitle: defaultAction.discoverabilityTitle,
attributes: defaultAction.attributes,
state: defaultAction.state) { [weak self] _ in
self?.openURLAction.callAsFunction(url)
}
}
return defaultAction
}
func textView(_ textView: UITextView, menuConfigurationFor textItem: UITextItem, defaultMenu: UIMenu) -> UITextItem.MenuConfiguration? {
switch textItem.content {
case let .link(url):
guard !url.requiresConfirmation else {
return nil
}
// We don't want to show a URL preview for permalinks
let isPermalink = parseMatrixEntityFrom(uri: url.absoluteString) != nil
return .init(preview: isPermalink ? nil : .default, menu: defaultMenu)
default:
return nil
}
}
}
}
// MARK: - Previews
struct MessageText_Previews: PreviewProvider, TestablePreview {
private static let defaultFontContainer: AttributeContainer = {
var container = AttributeContainer()
container.font = UIFont.preferredFont(forTextStyle: .body)
return container
}()
private static let attributedString = AttributedString("Hello World! Hello world! Hello world! Hello world! Hello World! Hellooooooooooooooooooooooo Woooooooooooooooooooooorld", attributes: defaultFontContainer)
private static let attributedStringWithAttachment: AttributedString = {
let testData = PillTextAttachmentData(type: .user(userID: "@alice:example.com"), font: .preferredFont(forTextStyle: .body))
guard let attachment = PillTextAttachment(attachmentData: testData) else {
return AttributedString()
}
var attributedString = "Hello test test test " + AttributedString(NSAttributedString(attachment: attachment)) + " World!"
attributedString
.mergeAttributes(defaultFontContainer)
return attributedString
}()
private static let htmlStringWithQuote =
"""
<blockquote>A blockquote that is long and goes onto multiple lines as the first item in the message</blockquote><p>Then another line of text here to reply to the blockquote, which is also a multiline component.</p>
"""
private static let htmlStringWithList = "<p>This is a list</p>\n<ul><li>One</li>\n<li>Two</li>\n<li>And number 3</li>\n</ul>\n"
private static let attributedStringBuilder = AttributedStringBuilder(mentionBuilder: MentionBuilder())
static var attachmentPreview: some View {
MessageText(attributedString: attributedStringWithAttachment)
.border(Color.purple)
.environmentObject(TimelineViewModel.mock.context)
}
static var previews: some View {
MessageText(attributedString: attributedString)
.border(Color.purple)
.previewDisplayName("Custom Text")
// For comparison
Text(attributedString)
.border(Color.purple)
.previewDisplayName("SwiftUI Default Text")
attachmentPreview
.previewDisplayName("Custom Attachment")
if let attributedString = attributedStringBuilder.fromHTML(htmlStringWithQuote) {
MessageText(attributedString: attributedString)
.border(Color.purple)
.previewDisplayName("With block quote")
}
if let attributedString = attributedStringBuilder.fromHTML(htmlStringWithList) {
MessageText(attributedString: attributedString)
.border(Color.purple)
.previewDisplayName("With list")
}
}
}