From 38e824d74dd7b19aa03cac13bfc376f3982ceb9e Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Fri, 29 Sep 2023 15:40:31 +0200 Subject: [PATCH] @room pill (#1834) * all users mention pill + red higlight for own mentions * more tests * changelog * removed useless if let --- ElementX.xcodeproj/project.pbxproj | 12 ++- .../HTMLParsing/AttributedStringBuilder.swift | 72 +++++++++------ .../HTMLParsing/ElementXAttributeScope.swift | 7 ++ .../Sources/Other/MatrixEntityRegex.swift | 12 +++ .../Sources/Other/Pills/MentionBuilder.swift | 24 +++++ .../Pills/PillAttachmentViewProvider.swift | 8 +- .../Sources/Other/Pills/PillContext.swift | 76 ++++++--------- .../Other/Pills/PillTextAttachmentData.swift | 2 + ElementX/Sources/Other/Pills/PillView.swift | 29 +++--- .../Screens/RoomScreen/RoomScreenModels.swift | 1 + .../RoomScreen/RoomScreenViewModel.swift | 1 + NSE/Sources/Other/NSEMentionBuilder.swift | 2 + .../AttributedStringBuilderTests.swift | 67 +++++++++++++- .../Sources/MatrixEntityRegexTests.swift | 9 ++ UnitTests/Sources/PillContextTests.swift | 92 +++++++++++++++++++ .../PreviewTests/test_pillView.All-Users.png | 3 + .../test_pillView.Loaded-Long-Own.png | 3 + .../test_pillView.Loading-Own.png | 3 + changelog.d/1829.feature | 1 + 19 files changed, 326 insertions(+), 98 deletions(-) create mode 100644 UnitTests/Sources/PillContextTests.swift create mode 100644 UnitTests/__Snapshots__/PreviewTests/test_pillView.All-Users.png create mode 100644 UnitTests/__Snapshots__/PreviewTests/test_pillView.Loaded-Long-Own.png create mode 100644 UnitTests/__Snapshots__/PreviewTests/test_pillView.Loading-Own.png create mode 100644 changelog.d/1829.feature diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 40e998187..b63941aa1 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -366,6 +366,7 @@ 754602A7B2AAD443C4228ED4 /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 997C7385E1A07E061D7E2100 /* GZIP */; }; 755727E0B756430DFFEC4732 /* SessionVerificationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF05DA24F71B455E8EFEBC3B /* SessionVerificationViewModelTests.swift */; }; 764AFCC225B044CF5F9B41E5 /* PaginationIndicatorRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42EEA67A6796BDC2761619C5 /* PaginationIndicatorRoomTimelineView.swift */; }; + 767D366C40F1311CFA333763 /* PillContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86376BEE425704AEE197CA54 /* PillContext.swift */; }; 76BA28216FBAF83B2D86A027 /* InvitesScreenCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2A71915C1F075E403F559C /* InvitesScreenCell.swift */; }; 7708976CEE6AFB5CFAEFBA68 /* PillTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CF1EE0AA78470C674554262 /* PillTextAttachment.swift */; }; 7719778A682FDAC21445E9C8 /* OnboardingLogo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B0D7955FFB19B584594844B /* OnboardingLogo.swift */; }; @@ -542,6 +543,7 @@ A6DEC1ADEC8FEEC206A0FA37 /* AttributedStringBuilderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */; }; A722F426FD81FC67706BB1E0 /* CustomLayoutLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42236480CF0431535EBE8387 /* CustomLayoutLabelStyle.swift */; }; A74438ED16F8683A4B793E6A /* AnalyticsSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BCE3FAF40932AC7C7639AC4 /* AnalyticsSettingsScreenViewModel.swift */; }; + A7BC01132AC6E83A009C9784 /* PillContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7BC01122AC6E83A009C9784 /* PillContextTests.swift */; }; A7D48E44D485B143AADDB77D /* Strings+Untranslated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18F6CE4D694D21E4EA9B25 /* Strings+Untranslated.swift */; }; A7FD7B992E6EE6E5A8429197 /* RoomSummaryDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */; }; A816F7087C495D85048AC50E /* RoomMemberDetailsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B6E30BB748F3F480F077969 /* RoomMemberDetailsScreenModels.swift */; }; @@ -716,7 +718,6 @@ D871C8CF46950F959C9A62C3 /* WelcomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54464351F170D570110AFCA /* WelcomeScreen.swift */; }; D876EC0FED3B6D46C806912A /* AvatarSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24B88AD3D1599E8CB1376E0 /* AvatarSize.swift */; }; D8CFF02C2730EE5BC4F17ABF /* ElementToggleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0960A7F5C1B0B6679BDF26F9 /* ElementToggleStyle.swift */; }; - D9092786ACCFF72565AD7389 /* PillContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B203689DFD1431181A795F4 /* PillContext.swift */; }; D9473FC9B077A6EDB7A12001 /* LocationRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 772334731A8BF8E6D90B194D /* LocationRoomTimelineView.swift */; }; D98B5EE8C4F5A2CE84687AE8 /* UTType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897DF5E9A70CE05A632FC8AF /* UTType.swift */; }; D9F80CE61BF8FF627FDB0543 /* LoadableImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352359663A0E52BA20761EE /* LoadableImage.swift */; }; @@ -1114,7 +1115,6 @@ 4A4AD793D50748F8997E5B15 /* TimelineItemMacContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMacContextMenu.swift; sourceTree = ""; }; 4AB7D7DAAAF662DED9D02379 /* MockMediaLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaLoader.swift; sourceTree = ""; }; 4ADC55DFF46083BC957E0019 /* CreatePollScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePollScreenModels.swift; sourceTree = ""; }; - 4B203689DFD1431181A795F4 /* PillContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillContext.swift; sourceTree = ""; }; 4B41FABA2B0AEF4389986495 /* LoginMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginMode.swift; sourceTree = ""; }; 4B5046BB295AEAFA6FB81655 /* SessionVerificationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenModels.swift; sourceTree = ""; }; 4BD371B60E07A5324B9507EF /* AnalyticsSettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenCoordinator.swift; sourceTree = ""; }; @@ -1281,6 +1281,7 @@ 8544F7058D31DBEB8DBFF0F5 /* NotificationSettingsEditScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenViewModelTests.swift; sourceTree = ""; }; 854BCEAF2A832176FAACD2CB /* SplashScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenCoordinator.swift; sourceTree = ""; }; 85EB16E7FE59A947CA441531 /* MediaProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProviderProtocol.swift; sourceTree = ""; }; + 86376BEE425704AEE197CA54 /* PillContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillContext.swift; sourceTree = ""; }; 86873A768B13069BB5CAECF6 /* InvitesScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenViewModelProtocol.swift; sourceTree = ""; }; 86A6F283BC574FDB96ABBB07 /* DeveloperOptionsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenViewModel.swift; sourceTree = ""; }; 874A1842477895F199567BD7 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = ""; }; @@ -1370,6 +1371,7 @@ A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxy.swift; sourceTree = ""; }; A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = ""; }; A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = ""; }; + A7BC01122AC6E83A009C9784 /* PillContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillContextTests.swift; sourceTree = ""; }; A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = ""; }; A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = ""; }; A8903A9F615BBD0E6D7CD133 /* ApplicationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationProtocol.swift; sourceTree = ""; }; @@ -2755,6 +2757,7 @@ 7583EAC171059A86B767209F /* MediaProvider */, 7DBC911559934065993A5FF4 /* NotificationManager */, 1C62F5382CC9D9F7DCEC344A /* UserDiscoveryService */, + A7BC01122AC6E83A009C9784 /* PillContextTests.swift */, ); path = Sources; sourceTree = ""; @@ -3212,10 +3215,10 @@ 15748C254911E3654C93B0ED /* MentionBuilder.swift */, E1E0B4A34E69BD2132BEC521 /* MessageText.swift */, 1B53D6C5C0D14B04D3AB3F6E /* PillAttachmentViewProvider.swift */, + 86376BEE425704AEE197CA54 /* PillContext.swift */, 9CF1EE0AA78470C674554262 /* PillTextAttachment.swift */, 913C8E13B8B602C7B6C0C4AE /* PillTextAttachmentData.swift */, 7773CBFDBD458E0B7E270507 /* PillView.swift */, - 4B203689DFD1431181A795F4 /* PillContext.swift */, ); path = Pills; sourceTree = ""; @@ -4537,6 +4540,7 @@ 81A7C020CB5F6232242A8414 /* UserSessionTests.swift in Sources */, FB9A1DD83EF641A75ABBCE69 /* WaitlistScreenViewModelTests.swift in Sources */, 7F02063FB3D1C3E5601471A1 /* WelcomeScreenScreenViewModelTests.swift in Sources */, + A7BC01132AC6E83A009C9784 /* PillContextTests.swift in Sources */, 3116693C5EB476E028990416 /* XCTestCase.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4859,10 +4863,10 @@ 80D00A7C62AAB44F54725C43 /* PermalinkBuilder.swift in Sources */, 962A4F8AD6312804E2C6BB6E /* PhotoLibraryPicker.swift in Sources */, EE4E2C1922BBF5169E213555 /* PillAttachmentViewProvider.swift in Sources */, + 767D366C40F1311CFA333763 /* PillContext.swift in Sources */, 7708976CEE6AFB5CFAEFBA68 /* PillTextAttachment.swift in Sources */, 8C050A8012E6078BEAEF5BC8 /* PillTextAttachmentData.swift in Sources */, 7E2BB42805C59DB57E95610F /* PillView.swift in Sources */, - D9092786ACCFF72565AD7389 /* PillContext.swift in Sources */, 9D79B94493FB32249F7E472F /* PlaceholderAvatarImage.swift in Sources */, 1BA04D05EBC6646958B1BE60 /* PlaceholderScreenCoordinator.swift in Sources */, 16CBD087038DE3815CDA512C /* PollMock.swift in Sources */, diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift index 7f100bfe6..79ac4e2ca 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift @@ -143,8 +143,9 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { if let value = value as? UIColor, value == temporaryCodeBlockMarkingColor { attributedString.addAttribute(.backgroundColor, value: UIColor(.compound._bgCodeBlock) as Any, range: range) - // Codeblocks should not have links + // Codeblocks should not have links and all users mentions attributedString.removeAttribute(.link, range: range) + attributedString.removeAttribute(.MatrixAllUsersMention, range: range) } } } @@ -173,36 +174,40 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { let linkMatches = MatrixEntityRegex.linkRegex.matches(in: string, options: []) matches.append(contentsOf: linkMatches) - guard matches.count > 0 else { - return - } - - // Sort the links by length so the longest one always takes priority - matches.sorted { $0.range.length > $1.range.length }.forEach { match in - guard let matchRange = Range(match.range, in: string) else { - return - } - - var hasLink = false - attributedString.enumerateAttribute(.link, in: match.range, options: []) { value, _, stop in - if value != nil { - hasLink = true - stop.pointee = true + if matches.count > 0 { + // Sort the links by length so the longest one always takes priority + matches.sorted { $0.range.length > $1.range.length }.forEach { match in + guard let matchRange = Range(match.range, in: string) else { + return + } + + var hasLink = false + attributedString.enumerateAttribute(.link, in: match.range, options: []) { value, _, stop in + if value != nil { + hasLink = true + stop.pointee = true + } + } + + if hasLink { + return + } + + var link = String(string[matchRange]) + + if linkMatches.contains(match), !link.contains("://") { + link.insert(contentsOf: "https://", at: link.startIndex) + } + + if let url = URL(string: link) { + attributedString.addAttribute(.link, value: url, range: match.range) } } - - if hasLink { - return - } - - var link = String(string[matchRange]) - - if linkMatches.contains(match), !link.contains("://") { - link.insert(contentsOf: "https://", at: link.startIndex) - } - - if let url = URL(string: link) { - attributedString.addAttribute(.link, value: url, range: match.range) + } + + MatrixEntityRegex.allUsersRegex.matches(in: string, options: []).forEach { match in + if attributedString.attribute(.link, at: 0, longestEffectiveRange: nil, in: match.range) == nil { + attributedString.addAttribute(.MatrixAllUsersMention, value: true, range: match.range) } } } @@ -226,6 +231,13 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { } } } + + attributedString.enumerateAttribute(.MatrixAllUsersMention, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in + if let value = value as? Bool, + value { + mentionBuilder.handleAllUsersMention(for: attributedString, in: range) + } + } } private func removeDefaultForegroundColor(_ attributedString: NSMutableAttributedString) { @@ -286,8 +298,10 @@ extension NSAttributedString.Key { static let MatrixRoomID: NSAttributedString.Key = .init(rawValue: RoomIDAttribute.name) static let MatrixRoomAlias: NSAttributedString.Key = .init(rawValue: RoomAliasAttribute.name) static let MatrixEventID: NSAttributedString.Key = .init(rawValue: EventIDAttribute.name) + static let MatrixAllUsersMention: NSAttributedString.Key = .init(rawValue: AllUsersMentionAttribute.name) } protocol MentionBuilderProtocol { func handleUserMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, userID: String) + func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange) } diff --git a/ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift b/ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift index a14a92701..a7596d717 100644 --- a/ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift +++ b/ElementX/Sources/Other/HTMLParsing/ElementXAttributeScope.swift @@ -36,6 +36,11 @@ enum RoomAliasAttribute: AttributedStringKey { public static var name = "MXRoomAliasAttribute" } +enum AllUsersMentionAttribute: AttributedStringKey { + typealias Value = Bool + public static var name = "MXAllUsersMentionAttribute" +} + struct EventIDAttributeValue: Hashable { let roomID: String let eventID: String @@ -55,6 +60,8 @@ extension AttributeScopes { let roomAlias: RoomAliasAttribute let eventID: EventIDAttribute + let allUsersMention: AllUsersMentionAttribute + let swiftUI: SwiftUIAttributes let uiKit: UIKitAttributes } diff --git a/ElementX/Sources/Other/MatrixEntityRegex.swift b/ElementX/Sources/Other/MatrixEntityRegex.swift index 2f9e28f90..090316c75 100644 --- a/ElementX/Sources/Other/MatrixEntityRegex.swift +++ b/ElementX/Sources/Other/MatrixEntityRegex.swift @@ -23,6 +23,7 @@ enum MatrixEntityRegex: String { case roomAlias case roomId case eventId + case allUsers var rawValue: String { switch self { @@ -36,6 +37,8 @@ enum MatrixEntityRegex: String { return "![A-Z0-9]+:" + MatrixEntityRegex.homeserver.rawValue case .eventId: return "\\$[a-z0-9_\\-\\/]+(:[a-z0-9]+\\.[a-z0-9]+)?" + case .allUsers: + return "@room" } } @@ -45,6 +48,7 @@ enum MatrixEntityRegex: String { static var roomAliasRegex = try! NSRegularExpression(pattern: MatrixEntityRegex.roomAlias.rawValue, options: .caseInsensitive) static var roomIdentifierRegex = try! NSRegularExpression(pattern: MatrixEntityRegex.roomId.rawValue, options: .caseInsensitive) static var eventIdentifierRegex = try! NSRegularExpression(pattern: MatrixEntityRegex.eventId.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 @@ -87,4 +91,12 @@ enum MatrixEntityRegex: String { return match.range.length == identifier.count } + + static func containsMatrixAllUsers(_ string: String) -> Bool { + guard allUsersRegex.firstMatch(in: string) != nil else { + return false + } + + return true + } } diff --git a/ElementX/Sources/Other/Pills/MentionBuilder.swift b/ElementX/Sources/Other/Pills/MentionBuilder.swift index e62d4d9cc..22674abec 100644 --- a/ElementX/Sources/Other/Pills/MentionBuilder.swift +++ b/ElementX/Sources/Other/Pills/MentionBuilder.swift @@ -46,4 +46,28 @@ struct MentionBuilder: MentionBuilderProtocol { attachmentString.addAttributes(attributes, range: NSRange(location: 0, length: attachmentString.length)) attributedString.replaceCharacters(in: range, with: attachmentString) } + + func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange) { + guard mentionsEnabled else { + return + } + + let attributes = attributedString.attributes(at: 0, longestEffectiveRange: nil, in: range) + let font = attributes[.font] as? UIFont ?? .preferredFont(forTextStyle: .body) + let blockquote = attributes[.MatrixBlockquote] + + let attachmentData = PillTextAttachmentData(type: .allUsers, font: font) + guard let attachment = PillTextAttachment(attachmentData: attachmentData) else { + return + } + + var attributesToAdd: [NSAttributedString.Key: Any] = [:] + if let blockquote { + // mentions can be in blockquotes, so if the replaced string was in one, we keep the attribute + attributesToAdd[.MatrixBlockquote] = blockquote + } + let attachmentString = NSMutableAttributedString(attachment: attachment) + attachmentString.addAttributes(attributes, range: NSRange(location: 0, length: attachmentString.length)) + attributedString.replaceCharacters(in: range, with: attachmentString) + } } diff --git a/ElementX/Sources/Other/Pills/PillAttachmentViewProvider.swift b/ElementX/Sources/Other/Pills/PillAttachmentViewProvider.swift index bd4ee2212..e6c05fe32 100644 --- a/ElementX/Sources/Other/Pills/PillAttachmentViewProvider.swift +++ b/ElementX/Sources/Other/Pills/PillAttachmentViewProvider.swift @@ -40,21 +40,21 @@ final class PillAttachmentViewProvider: NSTextAttachmentViewProvider { return } - let viewModel: PillContext + let context: PillContext let imageProvider: ImageProviderProtocol? if ProcessInfo.isXcodePreview || ProcessInfo.isRunningTests { // The mock viewModel simulates the loading logic for testing purposes - viewModel = PillContext.mock(type: .loadUser) + context = PillContext.mock(type: .loadUser(isOwn: false)) imageProvider = MockMediaProvider() } else if let roomContext = messageTextView?.roomContext { - viewModel = PillContext(roomContext: roomContext, data: textAttachmentData) + context = PillContext(roomContext: roomContext, data: textAttachmentData) imageProvider = roomContext.imageProvider } else { MXLog.failure("[PillAttachmentViewProvider]: missing room context") return } - let view = PillView(imageProvider: imageProvider, viewModel: viewModel) { [weak self] in + let view = PillView(imageProvider: imageProvider, context: context) { [weak self] in self?.messageTextView?.invalidateTextAttachmentsDisplay(update: true) } let controller = UIHostingController(rootView: view) diff --git a/ElementX/Sources/Other/Pills/PillContext.swift b/ElementX/Sources/Other/Pills/PillContext.swift index e6144298d..fa8394f17 100644 --- a/ElementX/Sources/Other/Pills/PillContext.swift +++ b/ElementX/Sources/Other/Pills/PillContext.swift @@ -19,92 +19,72 @@ import Foundation @MainActor final class PillContext: ObservableObject { - enum PillViewState { - case loading(contentID: String) - case loaded(contentID: String, name: String, avatarURL: URL?) + struct PillViewState: Equatable { + let contentID: String + let isOwnMention: Bool + let name: String? + let displayText: String + let avatarURL: URL? } - @Published private var state: PillViewState - - var url: URL? { - switch state { - case .loading: - return nil - case .loaded(_, _, let url): - return url - } - } - - var name: String? { - switch state { - case .loading: - return nil - case .loaded(_, let name, _): - return name - } - } - - var displayText: String { - switch state { - case .loaded(_, let name, _): - return name - case .loading(let contentID): - return contentID - } - } - - var contentID: String { - switch state { - case .loaded(let contentID, _, _), .loading(let contentID): - return contentID - } - } + @Published private(set) var viewState: PillViewState private var cancellable: AnyCancellable? init(roomContext: RoomScreenViewModel.Context, data: PillTextAttachmentData) { switch data.type { case let .user(id): + let isOwnMention = id == roomContext.viewState.ownUserID if let profile = roomContext.viewState.members[id] { - state = .loaded(contentID: id, name: profile.displayName ?? id, avatarURL: profile.avatarURL) + let name = profile.displayName ?? id + viewState = PillViewState(contentID: id, isOwnMention: isOwnMention, name: name, displayText: name, avatarURL: profile.avatarURL) } else { - state = .loading(contentID: id) + viewState = PillViewState(contentID: id, isOwnMention: isOwnMention, name: nil, displayText: id, avatarURL: nil) cancellable = roomContext.$viewState.sink { [weak self] viewState in guard let self else { return } if let profile = viewState.members[id] { - state = .loaded(contentID: id, name: profile.displayName ?? id, avatarURL: profile.avatarURL) + let name = profile.displayName ?? id + self.viewState = PillViewState(contentID: id, isOwnMention: isOwnMention, name: name, displayText: name, avatarURL: profile.avatarURL) cancellable = nil } } } + case .allUsers: + viewState = PillViewState(contentID: roomContext.viewState.roomID, isOwnMention: true, name: roomContext.viewState.roomTitle, displayText: "@room", avatarURL: roomContext.viewState.roomAvatarURL) } } } extension PillContext { enum MockType { - case loadUser - case loadedUser + case loadUser(isOwn: Bool) + case loadedUser(isOwn: Bool) + case allUsers } static func mock(type: MockType) -> PillContext { + let testID = "@test:test.com" let pillType: PillType switch type { - case .loadUser: - pillType = .user(userID: "@test:test.com") + case .loadUser(let isOwn): + pillType = .user(userID: testID) let viewModel = PillContext(roomContext: RoomScreenViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body))) + viewModel.viewState = PillViewState(contentID: testID, isOwnMention: isOwn, name: nil, displayText: testID, avatarURL: nil) Task { try? await Task.sleep(for: .seconds(2)) - viewModel.state = .loaded(contentID: "@test:test.com", name: "Test Longer Display Text", avatarURL: URL.documentsDirectory) + viewModel.viewState = PillViewState(contentID: "@test:test.com", isOwnMention: isOwn, name: nil, displayText: "Test Long Display Text", avatarURL: URL.documentsDirectory) } return viewModel - case .loadedUser: + case .loadedUser(let isOwn): pillType = .user(userID: "@test:test.com") let viewModel = PillContext(roomContext: RoomScreenViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body))) - viewModel.state = .loaded(contentID: "@test:test.com", name: "Very Very Long Test Display Text", avatarURL: URL.documentsDirectory) + viewModel.viewState = PillViewState(contentID: "@test:test.com", isOwnMention: isOwn, name: nil, displayText: "Very Very Long Test Display Text", avatarURL: URL.documentsDirectory) return viewModel + case .allUsers: + pillType = .allUsers + return PillContext(roomContext: RoomScreenViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body))) } } } diff --git a/ElementX/Sources/Other/Pills/PillTextAttachmentData.swift b/ElementX/Sources/Other/Pills/PillTextAttachmentData.swift index 416468d9f..0ed10150e 100644 --- a/ElementX/Sources/Other/Pills/PillTextAttachmentData.swift +++ b/ElementX/Sources/Other/Pills/PillTextAttachmentData.swift @@ -20,6 +20,8 @@ import UIKit enum PillType: Codable { /// A pill that mentions a user case user(userID: String) + /// A pill that mentions all users in a room + case allUsers } struct PillTextAttachmentData { diff --git a/ElementX/Sources/Other/Pills/PillView.swift b/ElementX/Sources/Other/Pills/PillView.swift index f514eaaa5..aa597d183 100644 --- a/ElementX/Sources/Other/Pills/PillView.swift +++ b/ElementX/Sources/Other/Pills/PillView.swift @@ -18,14 +18,14 @@ import SwiftUI struct PillView: View { let imageProvider: ImageProviderProtocol? - @ObservedObject var viewModel: PillContext + @ObservedObject var context: PillContext /// callback triggerd by changes in the display text let didChangeText: () -> Void var body: some View { HStack(spacing: 4) { - LoadableAvatarImage(url: viewModel.url, name: viewModel.name, contentID: viewModel.contentID, avatarSize: .custom(24), imageProvider: imageProvider) - Text(viewModel.displayText) + LoadableAvatarImage(url: context.viewState.avatarURL, name: context.viewState.name, contentID: context.viewState.contentID, avatarSize: .custom(24), imageProvider: imageProvider) + Text(context.viewState.displayText) .font(.compound.bodyLGSemibold) .foregroundColor(.compound.textOnSolidPrimary) .lineLimit(1) @@ -33,9 +33,9 @@ struct PillView: View { .padding(.horizontal, 8) .padding(.vertical, 4) // for now design has defined no color so we will just use gray - .background(Capsule().foregroundColor(.gray)) + .background(Capsule().foregroundColor(context.viewState.isOwnMention ? .compound.bgCriticalPrimary : .gray)) .frame(maxWidth: 235) - .onChange(of: viewModel.displayText) { _ in + .onChange(of: context.viewState.displayText) { _ in didChangeText() } } @@ -44,16 +44,21 @@ struct PillView: View { struct PillView_Previews: PreviewProvider, TestablePreview { static let mockMediaProvider = MockMediaProvider() - static var loading: some View { - PillView(imageProvider: mockMediaProvider, - viewModel: PillContext.mock(type: .loadUser)) { } - } - static var previews: some View { - loading + PillView(imageProvider: mockMediaProvider, + context: PillContext.mock(type: .loadUser(isOwn: false))) { } .previewDisplayName("Loading") PillView(imageProvider: mockMediaProvider, - viewModel: PillContext.mock(type: .loadedUser)) { } + context: PillContext.mock(type: .loadUser(isOwn: true))) { } + .previewDisplayName("Loading Own") + PillView(imageProvider: mockMediaProvider, + context: PillContext.mock(type: .loadedUser(isOwn: false))) { } .previewDisplayName("Loaded Long") + PillView(imageProvider: mockMediaProvider, + context: PillContext.mock(type: .loadedUser(isOwn: true))) { } + .previewDisplayName("Loaded Long Own") + PillView(imageProvider: mockMediaProvider, + context: PillContext.mock(type: .allUsers)) { } + .previewDisplayName("All Users") } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 6cb38319e..3a8caf89e 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -102,6 +102,7 @@ struct RoomScreenViewState: BindableState { var timelineViewState = TimelineViewState() // check the doc before changing this var swiftUITimelineEnabled = false var longPressDisabledItemID: TimelineItemIdentifier? + var ownUserID: String var bindings: RoomScreenViewStateBindings diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 137e883df..88535075e 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -62,6 +62,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol timelineStyle: appSettings.timelineStyle, readReceiptsEnabled: appSettings.readReceiptsEnabled, isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom, + ownUserID: roomProxy.ownUserID, bindings: .init(reactionsCollapsed: [:])), imageProvider: mediaProvider) diff --git a/NSE/Sources/Other/NSEMentionBuilder.swift b/NSE/Sources/Other/NSEMentionBuilder.swift index 543aa9c9c..83b7c7262 100644 --- a/NSE/Sources/Other/NSEMentionBuilder.swift +++ b/NSE/Sources/Other/NSEMentionBuilder.swift @@ -17,6 +17,8 @@ import Foundation struct NSEMentionBuilder: MentionBuilderProtocol { + func handleAllUsersMention(for attributedString: NSMutableAttributedString, in range: NSRange) { } + func handleUserMention(for attributedString: NSMutableAttributedString, in range: NSRange, url: URL, userID: String) { attributedString.addAttributes([.MatrixUserID: userID], range: range) } diff --git a/UnitTests/Sources/AttributedStringBuilderTests.swift b/UnitTests/Sources/AttributedStringBuilderTests.swift index 1b7dd850d..5e172f448 100644 --- a/UnitTests/Sources/AttributedStringBuilderTests.swift +++ b/UnitTests/Sources/AttributedStringBuilderTests.swift @@ -418,9 +418,59 @@ class AttributedStringBuilderTests: XCTestCase { let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) XCTAssertNotNil(attributedStringFromHTML?.attachment) let attributedStringFromPlain = attributedStringBuilder.fromPlain(string) - XCTAssert(attributedStringFromPlain?.attachment.isNil == false) + XCTAssertNotNil(attributedStringFromPlain?.attachment) } + func testAllUsersMentionAttachment() { + let string = "@room" + let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) + checkAttachment(attributedString: attributedStringFromHTML, expectedRuns: 1) + let attributedStringFromPlain = attributedStringBuilder.fromPlain(string) + checkAttachment(attributedString: attributedStringFromPlain, expectedRuns: 1) + + let string2 = "Hello @room" + let attributedStringFromHTML2 = attributedStringBuilder.fromHTML(string2) + checkAttachment(attributedString: attributedStringFromHTML2, expectedRuns: 2) + let attributedStringFromPlain2 = attributedStringBuilder.fromPlain(string2) + checkAttachment(attributedString: attributedStringFromPlain2, expectedRuns: 2) + + let string3 = "Hello @room how are you doing?" + let attributedStringFromHTML3 = attributedStringBuilder.fromHTML(string3) + checkAttachment(attributedString: attributedStringFromHTML3, expectedRuns: 3) + let attributedStringFromPlain3 = attributedStringBuilder.fromPlain(string3) + checkAttachment(attributedString: attributedStringFromPlain3, expectedRuns: 3) + } + + func testLinksHavePriorityOverAllUserMention() { + let string = "https://test@room.org" + let attributedStringFromHTML = attributedStringBuilder.fromHTML(string) + checkLinkIn(attributedString: attributedStringFromHTML, expectedLink: string, expectedRuns: 1) + let attributedStringFromPlain = attributedStringBuilder.fromPlain(string) + checkLinkIn(attributedString: attributedStringFromPlain, expectedLink: string, expectedRuns: 1) + + let string2 = "https://matrix.to/#/@roomusername:matrix.org" + let attributedStringFromHTML2 = attributedStringBuilder.fromHTML(string2) + checkLinkIn(attributedString: attributedStringFromHTML2, expectedLink: string2, expectedRuns: 1) + checkAttachment(attributedString: attributedStringFromHTML2, expectedRuns: 1) + let attributedStringFromPlain2 = attributedStringBuilder.fromPlain(string2) + checkLinkIn(attributedString: attributedStringFromPlain2, expectedLink: string2, expectedRuns: 1) + checkAttachment(attributedString: attributedStringFromPlain2, expectedRuns: 1) + } + + func testLinksAreIgnoredInCode() { + let htmlString = "
test https://matrix.org test
" + let attributedStringFromHTML = attributedStringBuilder.fromHTML(htmlString) + XCTAssert(attributedStringFromHTML?.runs.count == 1) + XCTAssertNil(attributedStringFromHTML?.link) + } + + func testAllUsersIsIgnoredInCode() { + let htmlString = "
test @room test
" + let attributedStringFromHTML = attributedStringBuilder.fromHTML(htmlString) + XCTAssert(attributedStringFromHTML?.runs.count == 1) + XCTAssertNil(attributedStringFromHTML?.attachment) + } + // MARK: - Private private func checkLinkIn(attributedString: AttributedString?, expectedLink: String, expectedRuns: Int) { @@ -438,4 +488,19 @@ class AttributedStringBuilderTests: XCTestCase { XCTFail("Couldn't find expected value.") } + + private func checkAttachment(attributedString: AttributedString?, expectedRuns: Int) { + guard let attributedString else { + XCTFail("Could not build the attributed string") + return + } + + XCTAssertEqual(attributedString.runs.count, expectedRuns) + + for run in attributedString.runs where run.attachment != nil { + return + } + + XCTFail("Couldn't find expected value.") + } } diff --git a/UnitTests/Sources/MatrixEntityRegexTests.swift b/UnitTests/Sources/MatrixEntityRegexTests.swift index 0f1b52101..29aea24b7 100644 --- a/UnitTests/Sources/MatrixEntityRegexTests.swift +++ b/UnitTests/Sources/MatrixEntityRegexTests.swift @@ -64,4 +64,13 @@ class MatrixEntityRegexTests: XCTestCase { XCTAssertFalse(MatrixEntityRegex.isMatrixEventIdentifier("$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg:")) XCTAssertFalse(MatrixEntityRegex.isMatrixEventIdentifier("$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg?")) } + + func testAllUsers() { + XCTAssertTrue(MatrixEntityRegex.containsMatrixAllUsers("@room")) + XCTAssertTrue(MatrixEntityRegex.containsMatrixAllUsers("a@rooma")) + XCTAssertTrue(MatrixEntityRegex.containsMatrixAllUsers("a @room a")) + XCTAssertFalse(MatrixEntityRegex.containsMatrixAllUsers("a @roaom a")) + XCTAssertFalse(MatrixEntityRegex.containsMatrixAllUsers("@roaom")) + XCTAssertTrue(MatrixEntityRegex.containsMatrixAllUsers("@room\n")) + } } diff --git a/UnitTests/Sources/PillContextTests.swift b/UnitTests/Sources/PillContextTests.swift new file mode 100644 index 000000000..53b34da5c --- /dev/null +++ b/UnitTests/Sources/PillContextTests.swift @@ -0,0 +1,92 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import XCTest + +@testable import ElementX + +@MainActor +class PillContextTests: XCTestCase { + func testUser() async throws { + let id = "@test:matrix.org" + let proxyMock = RoomProxyMock(with: .init(displayName: "Test")) + let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([]) + proxyMock.members = subject.asCurrentValuePublisher() + let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + mediaProvider: MockMediaProvider(), + roomProxy: proxyMock, + appSettings: ServiceLocator.shared.settings, + analytics: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) + let context = PillContext(roomContext: mock.context, data: PillTextAttachmentData(type: .user(userID: id), font: .preferredFont(forTextStyle: .body))) + + XCTAssertFalse(context.viewState.isOwnMention) + XCTAssertNil(context.viewState.avatarURL) + XCTAssertNil(context.viewState.name) + XCTAssertEqual(context.viewState.contentID, id) + XCTAssertEqual(context.viewState.displayText, id) + + let name = "Mr. Test" + let avatarURL = URL(string: "https://test.jpg") + subject.send([RoomMemberProxyMock(with: .init(userID: id, displayName: name, avatarURL: avatarURL, membership: .join))]) + await Task.yield() + + XCTAssertFalse(context.viewState.isOwnMention) + XCTAssertEqual(context.viewState.avatarURL, avatarURL) + XCTAssertEqual(context.viewState.name, name) + XCTAssertEqual(context.viewState.contentID, id) + XCTAssertEqual(context.viewState.displayText, name) + } + + func testOwnUser() async throws { + let id = "@test:matrix.org" + let proxyMock = RoomProxyMock(with: .init(displayName: "Test", ownUserID: id)) + let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([]) + proxyMock.members = subject.asCurrentValuePublisher() + let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + mediaProvider: MockMediaProvider(), + roomProxy: proxyMock, + appSettings: ServiceLocator.shared.settings, + analytics: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) + let context = PillContext(roomContext: mock.context, data: PillTextAttachmentData(type: .user(userID: id), font: .preferredFont(forTextStyle: .body))) + + XCTAssertTrue(context.viewState.isOwnMention) + } + + func testAllUsers() async throws { + let avatarURL = URL(string: "https://matrix.jpg") + let id = "test_room" + let displayName = "Test" + let proxyMock = RoomProxyMock(with: .init(id: id, displayName: displayName, avatarURL: avatarURL)) + let mockController = MockRoomTimelineController() + mockController.roomProxy = proxyMock + let mock = RoomScreenViewModel(timelineController: mockController, + mediaProvider: MockMediaProvider(), + roomProxy: proxyMock, + appSettings: ServiceLocator.shared.settings, + analytics: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) + let context = PillContext(roomContext: mock.context, data: PillTextAttachmentData(type: .allUsers, font: .preferredFont(forTextStyle: .body))) + + XCTAssertTrue(context.viewState.isOwnMention) + XCTAssertEqual(context.viewState.avatarURL, avatarURL) + XCTAssertEqual(context.viewState.name, displayName) + XCTAssertEqual(context.viewState.contentID, id) + XCTAssertEqual(context.viewState.displayText, "@room") + } +} diff --git a/UnitTests/__Snapshots__/PreviewTests/test_pillView.All-Users.png b/UnitTests/__Snapshots__/PreviewTests/test_pillView.All-Users.png new file mode 100644 index 000000000..87c8579b4 --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_pillView.All-Users.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:424b4e9eb2c8a83401fec9d8c8c737c825a622e025c443563c4a23a17976ab1d +size 62173 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_pillView.Loaded-Long-Own.png b/UnitTests/__Snapshots__/PreviewTests/test_pillView.Loaded-Long-Own.png new file mode 100644 index 000000000..b144f79ff --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_pillView.Loaded-Long-Own.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3cc84247a769c93950919a515079d0ff4a20293d9874df0905bcdf4264b78eb +size 66498 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_pillView.Loading-Own.png b/UnitTests/__Snapshots__/PreviewTests/test_pillView.Loading-Own.png new file mode 100644 index 000000000..134e88148 --- /dev/null +++ b/UnitTests/__Snapshots__/PreviewTests/test_pillView.Loading-Own.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:91680c015ee5ecbadadb799ff90f99a0112f00b7a95460d53bcd2d8f18177a1c +size 64987 diff --git a/changelog.d/1829.feature b/changelog.d/1829.feature new file mode 100644 index 000000000..0d2d2878b --- /dev/null +++ b/changelog.d/1829.feature @@ -0,0 +1 @@ +@room mention pill, and own mentions are red. \ No newline at end of file