diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 214c3c4eb..b52798890 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -7332,7 +7332,7 @@ repositoryURL = "https://github.com/matrix-org/matrix-rust-components-swift"; requirement = { kind = exactVersion; - version = 1.1.61; + version = 1.1.62; }; }; 821C67C9A7F8CC3FD41B28B4 /* XCRemoteSwiftPackageReference "emojibase-bindings" */ = { diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1f220e10a..088c6704b 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -139,8 +139,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-rust-components-swift", "state" : { - "revision" : "8def4c4c5cc5a95699431a509f1dcd55ec9b59d2", - "version" : "1.1.61" + "revision" : "c060a2a24f3cfa3cff3f6075bdc1e88abea72553", + "version" : "1.1.62" } }, { diff --git a/ElementX/Sources/Other/AccessibilityIdentifiers.swift b/ElementX/Sources/Other/AccessibilityIdentifiers.swift index a00920512..f767d6f93 100644 --- a/ElementX/Sources/Other/AccessibilityIdentifiers.swift +++ b/ElementX/Sources/Other/AccessibilityIdentifiers.swift @@ -144,6 +144,7 @@ enum A11yIdentifiers { let attachmentPickerTextFormatting = "room-attachment_picker_text_formatting" let timelineItemActionMenu = "room-timeline_item_action_menu" let joinCall = "room-join_call" + let scrollToBottom = "room-scroll_to_bottom" let composerToolbar = ComposerToolbar() diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift index c8e2ef8d0..ff71c6722 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift @@ -218,6 +218,16 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { return TextParsingMatch(type: .userID(identifier: identifier), range: match.range) } + matches.append(contentsOf: MatrixEntityRegex.roomAliasRegex.matches(in: string, options: []).compactMap { match in + guard let matchRange = Range(match.range, in: string) else { + return nil + } + + let alias = String(string[matchRange]) + + return TextParsingMatch(type: .roomAlias(alias: alias), range: match.range) + }) + matches.append(contentsOf: MatrixEntityRegex.linkRegex.matches(in: string, options: []).compactMap { match in guard let matchRange = Range(match.range, in: string) else { return nil @@ -257,6 +267,10 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { switch match.type { case .atRoom: attributedString.addAttribute(.MatrixAllUsersMention, value: true, range: match.range) + case .roomAlias(let alias): + if let url = try? matrixToRoomAliasPermalink(roomAlias: alias) { + attributedString.addAttribute(.link, value: url, range: match.range) + } case .userID, .link: if let url = match.link { attributedString.addAttribute(.link, value: url, range: match.range) @@ -363,6 +377,7 @@ protocol MentionBuilderProtocol { private struct TextParsingMatch { enum MatchType { case userID(identifier: String) + case roomAlias(alias: String) case link(urlString: String) case atRoom } diff --git a/ElementX/Sources/Other/MatrixEntityRegex.swift b/ElementX/Sources/Other/MatrixEntityRegex.swift index 3bb6cbe02..a5c503265 100644 --- a/ElementX/Sources/Other/MatrixEntityRegex.swift +++ b/ElementX/Sources/Other/MatrixEntityRegex.swift @@ -20,15 +20,18 @@ import MatrixRustSDK // https://spec.matrix.org/latest/appendices/#identifier-grammar enum MatrixEntityRegex: String { case homeserver - case userId + case userID + case roomAlias case allUsers var rawValue: String { switch self { case .homeserver: return "[A-Z0-9]+((\\.|\\-)[A-Z0-9]+){0,}(:[0-9]{2,5})?" - case .userId: + case .userID: return "@[\\x21-\\x39\\x3B-\\x7F]+:" + MatrixEntityRegex.homeserver.rawValue + case .roomAlias: + return "#[A-Z0-9._%#@=+-]+:" + MatrixEntityRegex.homeserver.rawValue case .allUsers: return PillConstants.atRoom } @@ -36,7 +39,8 @@ enum MatrixEntityRegex: String { // swiftlint:disable force_try static var homeserverRegex = try! NSRegularExpression(pattern: MatrixEntityRegex.homeserver.rawValue, options: .caseInsensitive) - static var userIdentifierRegex = try! NSRegularExpression(pattern: MatrixEntityRegex.userId.rawValue, options: .caseInsensitive) + static var userIdentifierRegex = try! NSRegularExpression(pattern: MatrixEntityRegex.userID.rawValue, options: .caseInsensitive) + static var roomAliasRegex = try! NSRegularExpression(pattern: MatrixEntityRegex.roomAlias.rawValue, options: .caseInsensitive) static var allUsersRegex = try! NSRegularExpression(pattern: MatrixEntityRegex.allUsers.rawValue) static var linkRegex = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) // swiftlint:enable force_try @@ -57,6 +61,14 @@ enum MatrixEntityRegex: String { return match.range.length == identifier.count } + static func isMatrixRoomAlias(_ alias: String) -> Bool { + guard let match = roomAliasRegex.firstMatch(in: alias) else { + return false + } + + return match.range.length == alias.count + } + static func containsMatrixAllUsers(_ string: String) -> Bool { guard allUsersRegex.firstMatch(in: string) != nil else { return false diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 11a8b42b4..32a5afb5d 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -85,6 +85,7 @@ enum RoomScreenViewAction { case sendReadReceiptIfNeeded(TimelineItemIdentifier) case paginateBackwards case paginateForwards + case scrollToBottom case timelineItemMenu(itemID: TimelineItemIdentifier) case timelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction) @@ -112,11 +113,8 @@ enum RoomScreenViewAction { case focusOnEventID(String) /// Switch back to a live timeline (from a detached one). case focusLive - /// Remove the highlighted event without switching timeline. - /// - /// This is useful when returning to the bottom of the live timeline - /// if `focusOnEventID` didn't use a detached timeline. - case clearFocussedEvent + /// The timeline scrolled to reveal the focussed item. + case scrolledToFocussedItem } enum RoomScreenComposerAction { @@ -229,7 +227,10 @@ struct TimelineViewState { var isLive = true var paginationState = PaginationState.default - var focussedEventID: String? + /// The ID of the focussed event navigated to via a permalink. + var focussedEventID: String? { didSet { focussedEventNeedsDisplay = focussedEventID != nil } } + /// Whether the timeline should scroll to `focussedEventID` once its item has been built and added to the timeline. + var focussedEventNeedsDisplay: Bool // These can be removed when we have full swiftUI and moved as @State values in the view var scrollToBottomPublisher = PassthroughSubject() diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index f6e4da813..d91594811 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -88,7 +88,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol roomAvatarURL: roomProxy.avatarURL, timelineStyle: appSettings.timelineStyle, isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom, - timelineViewState: TimelineViewState(focussedEventID: focussedEventID), + timelineViewState: TimelineViewState(focussedEventID: focussedEventID, + focussedEventNeedsDisplay: focussedEventID != nil), ownUserID: roomProxy.ownUserID, hasOngoingCall: roomProxy.hasOngoingCall, bindings: .init(reactionsCollapsed: [:])), @@ -175,6 +176,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol paginateBackwards() case .paginateForwards: paginateForwards() + case .scrollToBottom: + scrollToBottom() case .poll(let pollAction): processPollAction(pollAction) case .audio(let audioAction): @@ -187,8 +190,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol Task { await focusOnEvent(eventID: eventID) } case .focusLive: focusLive() - case .clearFocussedEvent: - state.timelineViewState.focussedEventID = nil + case .scrolledToFocussedItem: + // Use a Task to mutate view state after the current view update. + Task { state.timelineViewState.focussedEventNeedsDisplay = false } } } @@ -365,7 +369,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol .filter { $0 == .sentMessage } .receive(on: DispatchQueue.main) .sink { [weak self] _ in - self?.state.timelineViewState.scrollToBottomPublisher.send(()) + self?.scrollToBottom() } .store(in: &cancellables) @@ -488,6 +492,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } } + private func scrollToBottom() { + if state.timelineViewState.isLive { + state.timelineViewState.scrollToBottomPublisher.send(()) + } else { + focusLive() + } + } + private func sendReadReceiptIfNeeded(for lastVisibleItemID: TimelineItemIdentifier) async { guard appMediator.appState == .active else { return } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 886a9c187..f30fd516e 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -103,7 +103,7 @@ struct RoomScreen: View { } private var scrollToBottomButton: some View { - Button(action: scrollToBottom) { + Button { context.send(viewAction: .scrollToBottom) } label: { Image(systemName: "chevron.down") .font(.compound.bodyLG) .fontWeight(.semibold) @@ -121,20 +121,13 @@ struct RoomScreen: View { .opacity(isAtBottomAndLive ? 0.0 : 1.0) .accessibilityHidden(isAtBottomAndLive) .animation(.elementDefault, value: isAtBottomAndLive) + .accessibilityIdentifier(A11yIdentifiers.roomScreen.scrollToBottom) } private var isAtBottomAndLive: Bool { context.isScrolledToBottom && context.viewState.timelineViewState.isLive } - private func scrollToBottom() { - if context.viewState.timelineViewState.isLive { - context.viewState.timelineViewState.scrollToBottomPublisher.send(()) - } else { - context.send(viewAction: .focusLive) - } - } - @ViewBuilder private var loadingIndicator: some View { if context.viewState.showLoading { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineTableViewController.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineTableViewController.swift index 328b67953..c92aabdc1 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineTableViewController.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineTableViewController.swift @@ -76,10 +76,10 @@ class TimelineTableViewController: UIViewController { } /// There are pending items in `timelineItemsDictionary` that haven't been applied to the data source. - var hasPendingItems = false + private var hasPendingItems = false /// The user is dragging the scroll view (or it is still decelerating after a drag). - var isDraggingScrollView = false { + private var isDraggingScrollView = false { didSet { if !isDraggingScrollView, hasPendingItems { hasPendingItems = false @@ -104,19 +104,17 @@ class TimelineTableViewController: UIViewController { } } - /// The ID of the focussed event if opening the room to an event permalink. - var focussedEventID: String? { - didSet { - guard let focussedEventID else { return } - - focussedEventNeedsDisplay = true - scrollToItem(eventID: focussedEventID, animated: false) - } - } + /// The ID of the focussed event if navigating to an event permalink within the room. + var focussedEventID: String? /// Whether the timeline should scroll to `focussedEventID` when that item is added to the data source. /// This is necessary as the focussed event can be set before the timeline builder has built its item. - var focussedEventNeedsDisplay = false + var focussedEventNeedsDisplay = false { + didSet { + guard focussedEventNeedsDisplay, let focussedEventID else { return } + scrollToItem(eventID: focussedEventID, animated: false) + } + } /// Used to hold an observable object that the typing indicator can use let typingMembers = TypingMembersObservableObject(members: []) @@ -173,10 +171,7 @@ class TimelineTableViewController: UIViewController { scrollToBottomPublisher .sink { [weak self] _ in - guard let self else { return } - - scrollToNewestItem(animated: true) - coordinator.send(viewAction: .clearFocussedEvent) + self?.scrollToNewestItem(animated: true) } .store(in: &cancellables) @@ -343,7 +338,7 @@ class TimelineTableViewController: UIViewController { if let kvPair = timelineItemsDictionary.first(where: { $0.value.identifier.eventID == focussedEventID }), let indexPath = dataSource?.indexPath(for: kvPair.key) { tableView.scrollToRow(at: indexPath, at: .middle, animated: animated) - focussedEventNeedsDisplay = false + coordinator.send(viewAction: .scrolledToFocussedItem) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineView.swift index d00243bee..1ac01f864 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineView.swift @@ -65,6 +65,9 @@ struct TimelineView: UIViewControllerRepresentable { if tableViewController.focussedEventID != context.viewState.timelineViewState.focussedEventID { tableViewController.focussedEventID = context.viewState.timelineViewState.focussedEventID } + if tableViewController.focussedEventNeedsDisplay != context.viewState.timelineViewState.focussedEventNeedsDisplay { + tableViewController.focussedEventNeedsDisplay = context.viewState.timelineViewState.focussedEventNeedsDisplay + } if tableViewController.typingMembers.members != context.viewState.typingMembers { tableViewController.setTypingMembers(context.viewState.typingMembers) diff --git a/UITests/Sources/RoomScreenUITests.swift b/UITests/Sources/RoomScreenUITests.swift index c4d93488d..5bf0ad5d4 100644 --- a/UITests/Sources/RoomScreenUITests.swift +++ b/UITests/Sources/RoomScreenUITests.swift @@ -118,15 +118,21 @@ class RoomScreenUITests: XCTestCase { await client.waitForApp() defer { try? client.stop() } - // Some time for the timeline to settle. - try await Task.sleep(for: .seconds(1)) // When tapping a permalink to an item in the timeline. try await performOperation(.focusOnEvent("$5"), using: client) - // Some time for the timeline to settle. - try await Task.sleep(for: .seconds(1)) // Then the item should become highlighted. try await app.assertScreenshot(.roomLayoutHighlight, step: 0) + + guard UIDevice.current.userInterfaceIdiom == .phone else { return } + + // When scrolling to the bottom and tapping the same permalink again. + app.buttons[A11yIdentifiers.roomScreen.scrollToBottom].tap() + try await Task.sleep(for: .seconds(1)) // Some time for the timeline to settle + try await performOperation(.focusOnEvent("$5"), using: client) + + // Then the item should also be highlighted and scrolled to in the same state as before. + try await app.assertScreenshot(.roomLayoutHighlight, step: 0) } func testTimelineReadReceipts() async throws { diff --git a/UnitTests/Sources/AttributedStringBuilderTests.swift b/UnitTests/Sources/AttributedStringBuilderTests.swift index 553eb1674..74fdc123a 100644 --- a/UnitTests/Sources/AttributedStringBuilderTests.swift +++ b/UnitTests/Sources/AttributedStringBuilderTests.swift @@ -188,13 +188,24 @@ class AttributedStringBuilderTests: XCTestCase { checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: string, expectedRuns: 1) } - func testUserIdLink() { - let userId = "@user:matrix.org" - let string = "The user is \(userId)." - let expectedLink = "https://matrix.to/#/\(userId)" + func testUserIDLink() { + let userID = "@user:matrix.org" + let string = "The user is \(userID)." + let expectedLink = "https://matrix.to/#/\(userID)" checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: expectedLink, expectedRuns: 3) checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: expectedLink, expectedRuns: 3) } + + func testRoomAliasLink() { + let roomAlias = "#room:matrix.org" + let string = "The room is \(roomAlias)." + guard let expectedLink = URL(string: "https://matrix.to/#/\(roomAlias)") else { + XCTFail("The expected link should be valid.") + return + } + checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: expectedLink.absoluteString, expectedRuns: 3) + checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: expectedLink.absoluteString, expectedRuns: 3) + } func testDefaultFont() { let htmlString = "Test string." diff --git a/UnitTests/Sources/MatrixEntityRegexTests.swift b/UnitTests/Sources/MatrixEntityRegexTests.swift index 7435228b2..ed2bef9af 100644 --- a/UnitTests/Sources/MatrixEntityRegexTests.swift +++ b/UnitTests/Sources/MatrixEntityRegexTests.swift @@ -27,12 +27,18 @@ class MatrixEntityRegexTests: XCTestCase { XCTAssertFalse(MatrixEntityRegex.isMatrixHomeserver("matrix?.org")) } - func testUserId() { + func testUserID() { XCTAssertTrue(MatrixEntityRegex.isMatrixUserIdentifier("@username:example.com")) XCTAssertFalse(MatrixEntityRegex.isMatrixUserIdentifier("username:example.com")) XCTAssertFalse(MatrixEntityRegex.isMatrixUserIdentifier("@username.example.com")) } + func testRoomAlias() { + XCTAssertTrue(MatrixEntityRegex.isMatrixRoomAlias("#element-ios:matrix.org")) + XCTAssertFalse(MatrixEntityRegex.isMatrixRoomAlias("element-ios:matrix.org")) + XCTAssertFalse(MatrixEntityRegex.isMatrixRoomAlias("#element-ios.matrix.org")) + } + func testAllUsers() { XCTAssertTrue(MatrixEntityRegex.containsMatrixAllUsers("@room")) XCTAssertTrue(MatrixEntityRegex.containsMatrixAllUsers("a@rooma")) diff --git a/changelog.d/2758.bugfix b/changelog.d/2758.bugfix new file mode 100644 index 000000000..18bbad188 --- /dev/null +++ b/changelog.d/2758.bugfix @@ -0,0 +1 @@ +Fix a bug where tapping the same permalink a second time didn't do anything. \ No newline at end of file diff --git a/changelog.d/2760.bugfix b/changelog.d/2760.bugfix new file mode 100644 index 000000000..e7d892a20 --- /dev/null +++ b/changelog.d/2760.bugfix @@ -0,0 +1 @@ +Scrolling to the bottom after sending a message should also go live if necessary. \ No newline at end of file diff --git a/changelog.d/2762.bugfix b/changelog.d/2762.bugfix new file mode 100644 index 000000000..e127dbbfb --- /dev/null +++ b/changelog.d/2762.bugfix @@ -0,0 +1 @@ +Handle plain room aliases as permalinks. \ No newline at end of file diff --git a/changelog.d/pr-2748.feature b/changelog.d/pr-2748.feature new file mode 100644 index 000000000..127adf3f5 --- /dev/null +++ b/changelog.d/pr-2748.feature @@ -0,0 +1 @@ +Support navigating to permalinks and replies. \ No newline at end of file diff --git a/project.yml b/project.yml index 2a98b7807..c0b8a67b0 100644 --- a/project.yml +++ b/project.yml @@ -49,7 +49,7 @@ packages: # Element/Matrix dependencies MatrixRustSDK: url: https://github.com/matrix-org/matrix-rust-components-swift - exactVersion: 1.1.61 + exactVersion: 1.1.62 # path: ../matrix-rust-sdk Compound: url: https://github.com/element-hq/compound-ios