mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+12
-17
@@ -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)
|
||||||
|
|||||||
@@ -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,14 +188,25 @@ 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"))
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Fix a bug where tapping the same permalink a second time didn't do anything.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Scrolling to the bottom after sending a message should also go live if necessary.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Handle plain room aliases as permalinks.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Support navigating to permalinks and replies.
|
||||||
+1
-1
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user