Permalink Tweaks 2 (#2766)

* Missing changelog.

* Parse bare room aliases as permalinks.

Update the SDK.

* Fix tapping the same permalink twice.

Add a test.

Don't clear the focussed item when reaching the bottom of the timeline.

* Make sure sending a message returns to live.
This commit is contained in:
Doug
2024-04-29 17:32:16 +01:00
committed by GitHub
parent aaa8af2996
commit 7c94ed98a5
18 changed files with 111 additions and 52 deletions
+1 -1
View File
@@ -7332,7 +7332,7 @@
repositoryURL = "https://github.com/matrix-org/matrix-rust-components-swift"; repositoryURL = "https://github.com/matrix-org/matrix-rust-components-swift";
requirement = { requirement = {
kind = exactVersion; kind = exactVersion;
version = 1.1.61; version = 1.1.62;
}; };
}; };
821C67C9A7F8CC3FD41B28B4 /* XCRemoteSwiftPackageReference "emojibase-bindings" */ = { 821C67C9A7F8CC3FD41B28B4 /* XCRemoteSwiftPackageReference "emojibase-bindings" */ = {
@@ -139,8 +139,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/matrix-org/matrix-rust-components-swift", "location" : "https://github.com/matrix-org/matrix-rust-components-swift",
"state" : { "state" : {
"revision" : "8def4c4c5cc5a95699431a509f1dcd55ec9b59d2", "revision" : "c060a2a24f3cfa3cff3f6075bdc1e88abea72553",
"version" : "1.1.61" "version" : "1.1.62"
} }
}, },
{ {
@@ -144,6 +144,7 @@ enum A11yIdentifiers {
let attachmentPickerTextFormatting = "room-attachment_picker_text_formatting" let attachmentPickerTextFormatting = "room-attachment_picker_text_formatting"
let timelineItemActionMenu = "room-timeline_item_action_menu" let timelineItemActionMenu = "room-timeline_item_action_menu"
let joinCall = "room-join_call" let joinCall = "room-join_call"
let scrollToBottom = "room-scroll_to_bottom"
let composerToolbar = ComposerToolbar() let composerToolbar = ComposerToolbar()
@@ -218,6 +218,16 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
return TextParsingMatch(type: .userID(identifier: identifier), range: match.range) 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 matches.append(contentsOf: MatrixEntityRegex.linkRegex.matches(in: string, options: []).compactMap { match in
guard let matchRange = Range(match.range, in: string) else { guard let matchRange = Range(match.range, in: string) else {
return nil return nil
@@ -257,6 +267,10 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
switch match.type { switch match.type {
case .atRoom: case .atRoom:
attributedString.addAttribute(.MatrixAllUsersMention, value: true, range: match.range) 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: case .userID, .link:
if let url = match.link { if let url = match.link {
attributedString.addAttribute(.link, value: url, range: match.range) attributedString.addAttribute(.link, value: url, range: match.range)
@@ -363,6 +377,7 @@ protocol MentionBuilderProtocol {
private struct TextParsingMatch { private struct TextParsingMatch {
enum MatchType { enum MatchType {
case userID(identifier: String) case userID(identifier: String)
case roomAlias(alias: String)
case link(urlString: String) case link(urlString: String)
case atRoom case atRoom
} }
+15 -3
View File
@@ -20,15 +20,18 @@ import MatrixRustSDK
// https://spec.matrix.org/latest/appendices/#identifier-grammar // https://spec.matrix.org/latest/appendices/#identifier-grammar
enum MatrixEntityRegex: String { enum MatrixEntityRegex: String {
case homeserver case homeserver
case userId case userID
case roomAlias
case allUsers case allUsers
var rawValue: String { var rawValue: String {
switch self { switch self {
case .homeserver: case .homeserver:
return "[A-Z0-9]+((\\.|\\-)[A-Z0-9]+){0,}(:[0-9]{2,5})?" return "[A-Z0-9]+((\\.|\\-)[A-Z0-9]+){0,}(:[0-9]{2,5})?"
case .userId: case .userID:
return "@[\\x21-\\x39\\x3B-\\x7F]+:" + MatrixEntityRegex.homeserver.rawValue return "@[\\x21-\\x39\\x3B-\\x7F]+:" + MatrixEntityRegex.homeserver.rawValue
case .roomAlias:
return "#[A-Z0-9._%#@=+-]+:" + MatrixEntityRegex.homeserver.rawValue
case .allUsers: case .allUsers:
return PillConstants.atRoom return PillConstants.atRoom
} }
@@ -36,7 +39,8 @@ enum MatrixEntityRegex: String {
// swiftlint:disable force_try // swiftlint:disable force_try
static var homeserverRegex = try! NSRegularExpression(pattern: MatrixEntityRegex.homeserver.rawValue, options: .caseInsensitive) 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 allUsersRegex = try! NSRegularExpression(pattern: MatrixEntityRegex.allUsers.rawValue)
static var linkRegex = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) static var linkRegex = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
// swiftlint:enable force_try // swiftlint:enable force_try
@@ -57,6 +61,14 @@ enum MatrixEntityRegex: String {
return match.range.length == identifier.count 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 { static func containsMatrixAllUsers(_ string: String) -> Bool {
guard allUsersRegex.firstMatch(in: string) != nil else { guard allUsersRegex.firstMatch(in: string) != nil else {
return false return false
@@ -85,6 +85,7 @@ enum RoomScreenViewAction {
case sendReadReceiptIfNeeded(TimelineItemIdentifier) case sendReadReceiptIfNeeded(TimelineItemIdentifier)
case paginateBackwards case paginateBackwards
case paginateForwards case paginateForwards
case scrollToBottom
case timelineItemMenu(itemID: TimelineItemIdentifier) case timelineItemMenu(itemID: TimelineItemIdentifier)
case timelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction) case timelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction)
@@ -112,11 +113,8 @@ enum RoomScreenViewAction {
case focusOnEventID(String) case focusOnEventID(String)
/// Switch back to a live timeline (from a detached one). /// Switch back to a live timeline (from a detached one).
case focusLive case focusLive
/// Remove the highlighted event without switching timeline. /// The timeline scrolled to reveal the focussed item.
/// case scrolledToFocussedItem
/// This is useful when returning to the bottom of the live timeline
/// if `focusOnEventID` didn't use a detached timeline.
case clearFocussedEvent
} }
enum RoomScreenComposerAction { enum RoomScreenComposerAction {
@@ -229,7 +227,10 @@ struct TimelineViewState {
var isLive = true var isLive = true
var paginationState = PaginationState.default 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 // These can be removed when we have full swiftUI and moved as @State values in the view
var scrollToBottomPublisher = PassthroughSubject<Void, Never>() var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
@@ -88,7 +88,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
roomAvatarURL: roomProxy.avatarURL, roomAvatarURL: roomProxy.avatarURL,
timelineStyle: appSettings.timelineStyle, timelineStyle: appSettings.timelineStyle,
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom, isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
timelineViewState: TimelineViewState(focussedEventID: focussedEventID), timelineViewState: TimelineViewState(focussedEventID: focussedEventID,
focussedEventNeedsDisplay: focussedEventID != nil),
ownUserID: roomProxy.ownUserID, ownUserID: roomProxy.ownUserID,
hasOngoingCall: roomProxy.hasOngoingCall, hasOngoingCall: roomProxy.hasOngoingCall,
bindings: .init(reactionsCollapsed: [:])), bindings: .init(reactionsCollapsed: [:])),
@@ -175,6 +176,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
paginateBackwards() paginateBackwards()
case .paginateForwards: case .paginateForwards:
paginateForwards() paginateForwards()
case .scrollToBottom:
scrollToBottom()
case .poll(let pollAction): case .poll(let pollAction):
processPollAction(pollAction) processPollAction(pollAction)
case .audio(let audioAction): case .audio(let audioAction):
@@ -187,8 +190,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
Task { await focusOnEvent(eventID: eventID) } Task { await focusOnEvent(eventID: eventID) }
case .focusLive: case .focusLive:
focusLive() focusLive()
case .clearFocussedEvent: case .scrolledToFocussedItem:
state.timelineViewState.focussedEventID = nil // 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 } .filter { $0 == .sentMessage }
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
self?.state.timelineViewState.scrollToBottomPublisher.send(()) self?.scrollToBottom()
} }
.store(in: &cancellables) .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 { private func sendReadReceiptIfNeeded(for lastVisibleItemID: TimelineItemIdentifier) async {
guard appMediator.appState == .active else { return } guard appMediator.appState == .active else { return }
@@ -103,7 +103,7 @@ struct RoomScreen: View {
} }
private var scrollToBottomButton: some View { private var scrollToBottomButton: some View {
Button(action: scrollToBottom) { Button { context.send(viewAction: .scrollToBottom) } label: {
Image(systemName: "chevron.down") Image(systemName: "chevron.down")
.font(.compound.bodyLG) .font(.compound.bodyLG)
.fontWeight(.semibold) .fontWeight(.semibold)
@@ -121,20 +121,13 @@ struct RoomScreen: View {
.opacity(isAtBottomAndLive ? 0.0 : 1.0) .opacity(isAtBottomAndLive ? 0.0 : 1.0)
.accessibilityHidden(isAtBottomAndLive) .accessibilityHidden(isAtBottomAndLive)
.animation(.elementDefault, value: isAtBottomAndLive) .animation(.elementDefault, value: isAtBottomAndLive)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.scrollToBottom)
} }
private var isAtBottomAndLive: Bool { private var isAtBottomAndLive: Bool {
context.isScrolledToBottom && context.viewState.timelineViewState.isLive 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 @ViewBuilder
private var loadingIndicator: some View { private var loadingIndicator: some View {
if context.viewState.showLoading { if context.viewState.showLoading {
@@ -76,10 +76,10 @@ class TimelineTableViewController: UIViewController {
} }
/// There are pending items in `timelineItemsDictionary` that haven't been applied to the data source. /// 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). /// The user is dragging the scroll view (or it is still decelerating after a drag).
var isDraggingScrollView = false { private var isDraggingScrollView = false {
didSet { didSet {
if !isDraggingScrollView, hasPendingItems { if !isDraggingScrollView, hasPendingItems {
hasPendingItems = false hasPendingItems = false
@@ -104,19 +104,17 @@ class TimelineTableViewController: UIViewController {
} }
} }
/// The ID of the focussed event if opening the room to an event permalink. /// The ID of the focussed event if navigating to an event permalink within the room.
var focussedEventID: String? { var focussedEventID: String?
didSet {
guard let focussedEventID else { return }
focussedEventNeedsDisplay = true
scrollToItem(eventID: focussedEventID, animated: false)
}
}
/// Whether the timeline should scroll to `focussedEventID` when that item is added to the data source. /// 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. /// 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 /// Used to hold an observable object that the typing indicator can use
let typingMembers = TypingMembersObservableObject(members: []) let typingMembers = TypingMembersObservableObject(members: [])
@@ -173,10 +171,7 @@ class TimelineTableViewController: UIViewController {
scrollToBottomPublisher scrollToBottomPublisher
.sink { [weak self] _ in .sink { [weak self] _ in
guard let self else { return } self?.scrollToNewestItem(animated: true)
scrollToNewestItem(animated: true)
coordinator.send(viewAction: .clearFocussedEvent)
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -343,7 +338,7 @@ class TimelineTableViewController: UIViewController {
if let kvPair = timelineItemsDictionary.first(where: { $0.value.identifier.eventID == focussedEventID }), if let kvPair = timelineItemsDictionary.first(where: { $0.value.identifier.eventID == focussedEventID }),
let indexPath = dataSource?.indexPath(for: kvPair.key) { let indexPath = dataSource?.indexPath(for: kvPair.key) {
tableView.scrollToRow(at: indexPath, at: .middle, animated: animated) tableView.scrollToRow(at: indexPath, at: .middle, animated: animated)
focussedEventNeedsDisplay = false coordinator.send(viewAction: .scrolledToFocussedItem)
} }
} }
@@ -65,6 +65,9 @@ struct TimelineView: UIViewControllerRepresentable {
if tableViewController.focussedEventID != context.viewState.timelineViewState.focussedEventID { if tableViewController.focussedEventID != context.viewState.timelineViewState.focussedEventID {
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 { if tableViewController.typingMembers.members != context.viewState.typingMembers {
tableViewController.setTypingMembers(context.viewState.typingMembers) tableViewController.setTypingMembers(context.viewState.typingMembers)
+10 -4
View File
@@ -118,15 +118,21 @@ class RoomScreenUITests: XCTestCase {
await client.waitForApp() await client.waitForApp()
defer { try? client.stop() } 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. // When tapping a permalink to an item in the timeline.
try await performOperation(.focusOnEvent("$5"), using: client) 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. // Then the item should become highlighted.
try await app.assertScreenshot(.roomLayoutHighlight, step: 0) 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 { func testTimelineReadReceipts() async throws {
@@ -188,13 +188,24 @@ class AttributedStringBuilderTests: XCTestCase {
checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: string, expectedRuns: 1) checkLinkIn(attributedString: attributedStringBuilder.fromPlain(string), expectedLink: string, expectedRuns: 1)
} }
func testUserIdLink() { func testUserIDLink() {
let userId = "@user:matrix.org" let userID = "@user:matrix.org"
let string = "The user is \(userId)." let string = "The user is \(userID)."
let expectedLink = "https://matrix.to/#/\(userId)" let expectedLink = "https://matrix.to/#/\(userID)"
checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: expectedLink, expectedRuns: 3) checkLinkIn(attributedString: attributedStringBuilder.fromHTML(string), expectedLink: expectedLink, expectedRuns: 3)
checkLinkIn(attributedString: attributedStringBuilder.fromPlain(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() { func testDefaultFont() {
let htmlString = "<b>Test</b> <i>string</i>." let htmlString = "<b>Test</b> <i>string</i>."
@@ -27,12 +27,18 @@ class MatrixEntityRegexTests: XCTestCase {
XCTAssertFalse(MatrixEntityRegex.isMatrixHomeserver("matrix?.org")) XCTAssertFalse(MatrixEntityRegex.isMatrixHomeserver("matrix?.org"))
} }
func testUserId() { func testUserID() {
XCTAssertTrue(MatrixEntityRegex.isMatrixUserIdentifier("@username:example.com")) XCTAssertTrue(MatrixEntityRegex.isMatrixUserIdentifier("@username:example.com"))
XCTAssertFalse(MatrixEntityRegex.isMatrixUserIdentifier("username:example.com")) XCTAssertFalse(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() { func testAllUsers() {
XCTAssertTrue(MatrixEntityRegex.containsMatrixAllUsers("@room")) XCTAssertTrue(MatrixEntityRegex.containsMatrixAllUsers("@room"))
XCTAssertTrue(MatrixEntityRegex.containsMatrixAllUsers("a@rooma")) XCTAssertTrue(MatrixEntityRegex.containsMatrixAllUsers("a@rooma"))
+1
View File
@@ -0,0 +1 @@
Fix a bug where tapping the same permalink a second time didn't do anything.
+1
View File
@@ -0,0 +1 @@
Scrolling to the bottom after sending a message should also go live if necessary.
+1
View File
@@ -0,0 +1 @@
Handle plain room aliases as permalinks.
+1
View File
@@ -0,0 +1 @@
Support navigating to permalinks and replies.
+1 -1
View File
@@ -49,7 +49,7 @@ packages:
# Element/Matrix dependencies # Element/Matrix dependencies
MatrixRustSDK: MatrixRustSDK:
url: https://github.com/matrix-org/matrix-rust-components-swift url: https://github.com/matrix-org/matrix-rust-components-swift
exactVersion: 1.1.61 exactVersion: 1.1.62
# path: ../matrix-rust-sdk # path: ../matrix-rust-sdk
Compound: Compound:
url: https://github.com/element-hq/compound-ios url: https://github.com/element-hq/compound-ios