diff --git a/Riot/Assets/Images.xcassets/Room/Pill/Contents.json b/Riot/Assets/Images.xcassets/Room/Pill/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Pill/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Pill/pill_user.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Pill/pill_user.imageset/Contents.json new file mode 100644 index 0000000000..3a4181e5d1 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Pill/pill_user.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "pill_user.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "pill_user@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "pill_user@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Pill/pill_user.imageset/pill_user.png b/Riot/Assets/Images.xcassets/Room/Pill/pill_user.imageset/pill_user.png new file mode 100644 index 0000000000..abe8be2888 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Pill/pill_user.imageset/pill_user.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Pill/pill_user.imageset/pill_user@2x.png b/Riot/Assets/Images.xcassets/Room/Pill/pill_user.imageset/pill_user@2x.png new file mode 100644 index 0000000000..b047760db0 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Pill/pill_user.imageset/pill_user@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Pill/pill_user.imageset/pill_user@3x.png b/Riot/Assets/Images.xcassets/Room/Pill/pill_user.imageset/pill_user@3x.png new file mode 100644 index 0000000000..62a8fcfe13 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Pill/pill_user.imageset/pill_user@3x.png differ diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index f6d1a0d652..8a9f712c3c 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -3156,3 +3156,9 @@ To enable access, tap Settings> Location and select Always"; "ssl_unexpected_existing_expl" = "The certificate has changed from one that was trusted by your phone. This is HIGHLY UNUSUAL. It is recommended that you DO NOT ACCEPT this new certificate."; "ssl_expected_existing_expl" = "The certificate has changed from a previously trusted one to one that is not trusted. The server may have renewed its certificate. Contact the server administrator for the expected fingerprint."; "ssl_only_accept" = "ONLY accept the certificate if the server administrator has published a fingerprint that matches the one above."; + +// Pills +"pill_room_fallback_display_name" = "Space/Room"; +"pill_message" = "Message"; +"pill_message_from" = "Message from %@"; +"pill_message_in" = "Message in %@"; diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index ccad26c356..ce49740834 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -244,6 +244,7 @@ internal class Asset: NSObject { internal static let locationPinIcon = ImageAsset(name: "location_pin_icon") internal static let locationShareIcon = ImageAsset(name: "location_share_icon") internal static let locationUserMarker = ImageAsset(name: "location_user_marker") + internal static let pillUser = ImageAsset(name: "pill_user") internal static let pollCheckboxDefault = ImageAsset(name: "poll_checkbox_default") internal static let pollCheckboxSelected = ImageAsset(name: "poll_checkbox_selected") internal static let pollDeleteIcon = ImageAsset(name: "poll_delete_icon") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index bd7d356337..b31ee8596a 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -4691,6 +4691,22 @@ public class VectorL10n: NSObject { public static func photoLibraryAccessNotGranted(_ p1: String) -> String { return VectorL10n.tr("Vector", "photo_library_access_not_granted", p1) } + /// Message + public static var pillMessage: String { + return VectorL10n.tr("Vector", "pill_message") + } + /// Message from %@ + public static func pillMessageFrom(_ p1: String) -> String { + return VectorL10n.tr("Vector", "pill_message_from", p1) + } + /// Message in %@ + public static func pillMessageIn(_ p1: String) -> String { + return VectorL10n.tr("Vector", "pill_message_in", p1) + } + /// Space/Room + public static var pillRoomFallbackDisplayName: String { + return VectorL10n.tr("Vector", "pill_room_fallback_display_name") + } /// Create a PIN for security public static var pinProtectionChoosePin: String { return VectorL10n.tr("Vector", "pin_protection_choose_pin") diff --git a/Riot/Modules/MatrixKit/Utils/MXKTools.m b/Riot/Modules/MatrixKit/Utils/MXKTools.m index 9ba006a891..3af8ef1fdd 100644 --- a/Riot/Modules/MatrixKit/Utils/MXKTools.m +++ b/Riot/Modules/MatrixKit/Utils/MXKTools.m @@ -36,6 +36,10 @@ // Attribute in an NSAttributeString that marks a blockquote block that was in the original HTML string. NSString *const kMXKToolsBlockquoteMarkAttribute = @"kMXKToolsBlockquoteMarkAttribute"; +// Regex expression for permalink detection +NSString *const kMXKToolsRegexStringForPermalink = @"\\/#\\/(?:(?:room|user)\\/)?([^\\s]*)"; + + #pragma mark - MXKTools static private members // The regex used to find matrix ids. static NSRegularExpression *userIdRegex; @@ -47,6 +51,8 @@ // A regex to find all HTML tags static NSRegularExpression *htmlTagsRegex; static NSDataDetector *linkDetector; +// A regex to detect permalinks +static NSRegularExpression* permalinkRegex; @implementation MXKTools @@ -63,6 +69,9 @@ + (void)initialize httpLinksRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)\\b(https?://\\S*)\\b" options:NSRegularExpressionCaseInsensitive error:nil]; htmlTagsRegex = [NSRegularExpression regularExpressionWithPattern:@"<(\\w+)[^>]*>" options:NSRegularExpressionCaseInsensitive error:nil]; linkDetector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeLink error:nil]; + + NSString *permalinkPattern = [NSString stringWithFormat:@"%@%@", BuildSettings.clientPermalinkBaseUrl ?: kMXMatrixDotToUrl, kMXKToolsRegexStringForPermalink]; + permalinkRegex = [NSRegularExpression regularExpressionWithPattern:permalinkPattern options:NSRegularExpressionCaseInsensitive error:nil]; }); } @@ -1039,10 +1048,29 @@ + (void)createLinksInMutableAttributedString:(NSMutableAttributedString*)mutable { [MXKTools createLinksInMutableAttributedString:mutableAttributedString matchingRegex:eventIdRegex]; } + + // Permalinks + NSArray* matches = [httpLinksRegex matchesInString: [mutableAttributedString string] options:0 range: NSMakeRange(0,mutableAttributedString.length)]; + if (matches) { + for (NSTextCheckingResult *match in matches) + { + NSRange matchRange = [match range]; + + NSString *link = [mutableAttributedString.string substringWithRange:matchRange]; + // Handle potential permalinks + if ([permalinkRegex numberOfMatchesInString:link options:0 range:NSMakeRange(0, link.length)]) { + NSURLComponents *url = [[NSURLComponents new] initWithString:link]; + if (url.URL) + { + [mutableAttributedString addAttribute:NSLinkAttributeName value:url.URL range:matchRange]; + } + } + } + } // This allows to check for normal url based links (like https://element.io) // And set back the default link color - NSArray *matches = [linkDetector matchesInString: [mutableAttributedString string] options:0 range: NSMakeRange(0,mutableAttributedString.length)]; + matches = [linkDetector matchesInString: [mutableAttributedString string] options:0 range: NSMakeRange(0,mutableAttributedString.length)]; if (matches) { for (NSTextCheckingResult *match in matches) diff --git a/Riot/Modules/Pills/PillAttachmentView.swift b/Riot/Modules/Pills/PillAttachmentView.swift index 36dc3eb5eb..538b88a48b 100644 --- a/Riot/Modules/Pills/PillAttachmentView.swift +++ b/Riot/Modules/Pills/PillAttachmentView.swift @@ -25,7 +25,9 @@ class PillAttachmentView: UIView { struct Sizes { var verticalMargin: CGFloat var horizontalMargin: CGFloat + var avatarLeading: CGFloat var avatarSideLength: CGFloat + var itemSpacing: CGFloat var pillBackgroundHeight: CGFloat { return avatarSideLength + 2 * verticalMargin @@ -33,11 +35,8 @@ class PillAttachmentView: UIView { var pillHeight: CGFloat { return pillBackgroundHeight + 2 * verticalMargin } - var displaynameLabelLeading: CGFloat { - return avatarSideLength + 2 * horizontalMargin - } var totalWidthWithoutLabel: CGFloat { - return displaynameLabelLeading + 2 * horizontalMargin + return avatarSideLength + 2 * horizontalMargin } } @@ -56,44 +55,111 @@ class PillAttachmentView: UIView { mediaManager: MXMediaManager?, andPillData pillData: PillTextAttachmentData) { self.init(frame: frame) - let label = UILabel(frame: .zero) - label.text = pillData.displayText - label.font = pillData.font - label.textColor = pillData.isHighlighted ? theme.baseTextPrimaryColor : theme.textPrimaryColor - let labelSize = label.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, - height: sizes.pillBackgroundHeight)) - label.frame = CGRect(x: sizes.displaynameLabelLeading, - y: 0, - width: labelSize.width, - height: sizes.pillBackgroundHeight) + + let stack = UIStackView(frame: frame) + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = sizes.itemSpacing - let pillBackgroundView = UIView(frame: CGRect(x: 0, - y: sizes.verticalMargin, - width: labelSize.width + sizes.totalWidthWithoutLabel, - height: sizes.pillBackgroundHeight)) + var computedWidth: CGFloat = 0 + for item in pillData.items { + switch item { + case .text(let string): + let label = UILabel(frame: .zero) + label.text = string + label.font = pillData.font + label.textColor = pillData.isHighlighted ? theme.baseTextPrimaryColor : theme.textPrimaryColor + label.translatesAutoresizingMaskIntoConstraints = false + label.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + stack.addArrangedSubview(label) + + computedWidth += label.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, height: sizes.pillBackgroundHeight)).width - let avatarView = UserAvatarView(frame: CGRect(x: sizes.horizontalMargin, - y: sizes.verticalMargin, - width: sizes.avatarSideLength, - height: sizes.avatarSideLength)) + case .avatar(let url, let alt, let matrixId): + let avatarView = UserAvatarView(frame: CGRect(origin: .zero, size: CGSize(width: sizes.avatarSideLength, height: sizes.avatarSideLength))) - avatarView.fill(with: AvatarViewData(matrixItemId: pillData.matrixItemId, - displayName: pillData.displayName, - avatarUrl: pillData.avatarUrl, - mediaManager: mediaManager, - fallbackImage: .matrixItem(pillData.matrixItemId, pillData.displayName))) - avatarView.isUserInteractionEnabled = false + avatarView.fill(with: AvatarViewData(matrixItemId: matrixId, + displayName: alt, + avatarUrl: url, + mediaManager: mediaManager, + fallbackImage: .matrixItem(matrixId, alt))) + avatarView.isUserInteractionEnabled = false + avatarView.translatesAutoresizingMaskIntoConstraints = false + stack.addArrangedSubview(avatarView) + NSLayoutConstraint.activate([ + avatarView.widthAnchor.constraint(equalToConstant: sizes.avatarSideLength), + avatarView.heightAnchor.constraint(equalToConstant: sizes.avatarSideLength) + ]) + + computedWidth += sizes.avatarSideLength + + case .spaceAvatar(let url, let alt, let matrixId): + let avatarView = SpaceAvatarView(frame: CGRect(origin: .zero, size: CGSize(width: sizes.avatarSideLength, height: sizes.avatarSideLength))) - pillBackgroundView.addSubview(avatarView) - pillBackgroundView.addSubview(label) + avatarView.fill(with: AvatarViewData(matrixItemId: matrixId, + displayName: alt, + avatarUrl: url, + mediaManager: mediaManager, + fallbackImage: .matrixItem(matrixId, alt))) + avatarView.isUserInteractionEnabled = false + avatarView.translatesAutoresizingMaskIntoConstraints = false + stack.addArrangedSubview(avatarView) + NSLayoutConstraint.activate([ + avatarView.widthAnchor.constraint(equalToConstant: sizes.avatarSideLength), + avatarView.heightAnchor.constraint(equalToConstant: sizes.avatarSideLength) + ]) + + computedWidth += sizes.avatarSideLength + + case .asset(let name, let parameters): + let assetView = UIView(frame: CGRect(x: 0, y: 0, width: sizes.avatarSideLength, height: sizes.avatarSideLength)) + assetView.backgroundColor = parameters.backgroundColor?.uiColor + assetView.layer.cornerRadius = sizes.avatarSideLength / 2 + assetView.isUserInteractionEnabled = false + assetView.translatesAutoresizingMaskIntoConstraints = false + let imageView = UIImageView(frame: .zero) + imageView.image = ImageAsset(name: name).image.withRenderingMode(UIImage.RenderingMode(rawValue: parameters.rawRenderingMode) ?? .automatic) + imageView.tintColor = parameters.tintColor?.uiColor ?? theme.baseIconPrimaryColor + imageView.contentMode = .scaleAspectFit + + assetView.vc_addSubViewMatchingParent(imageView, withInsets: UIEdgeInsets(top: parameters.padding, left: parameters.padding, bottom: -parameters.padding, right: -parameters.padding)) + + stack.addArrangedSubview(assetView) + NSLayoutConstraint.activate([ + assetView.widthAnchor.constraint(equalToConstant: sizes.avatarSideLength), + assetView.heightAnchor.constraint(equalToConstant: sizes.avatarSideLength) + ]) + + computedWidth += sizes.avatarSideLength + } + } + computedWidth += max(0, CGFloat(stack.arrangedSubviews.count - 1) * stack.spacing) + + let leadingStackMargin: CGFloat + switch pillData.items.first { + case .asset, .avatar: + leadingStackMargin = sizes.avatarLeading + computedWidth += sizes.avatarLeading + sizes.horizontalMargin + default: + leadingStackMargin = sizes.horizontalMargin + computedWidth += 2 * sizes.horizontalMargin + } + + let pillBackgroundView = UIView(frame: CGRect(x: 0, + y: sizes.verticalMargin, + width: computedWidth, + height: sizes.pillBackgroundHeight)) + + pillBackgroundView.vc_addSubViewMatchingParent(stack, withInsets: UIEdgeInsets(top: sizes.verticalMargin, left: leadingStackMargin, bottom: -sizes.verticalMargin, right: -sizes.horizontalMargin)) + pillBackgroundView.backgroundColor = pillData.isHighlighted ? theme.colors.alert : theme.colors.quinaryContent pillBackgroundView.layer.cornerRadius = sizes.pillBackgroundHeight / 2.0 self.addSubview(pillBackgroundView) self.alpha = pillData.alpha } - + // MARK: - Override override var isHidden: Bool { get { diff --git a/Riot/Modules/Pills/PillAttachmentViewProvider.swift b/Riot/Modules/Pills/PillAttachmentViewProvider.swift index 1a14b182a7..ba03ef61af 100644 --- a/Riot/Modules/Pills/PillAttachmentViewProvider.swift +++ b/Riot/Modules/Pills/PillAttachmentViewProvider.swift @@ -20,9 +20,11 @@ import UIKit @available(iOS 15.0, *) @objc class PillAttachmentViewProvider: NSTextAttachmentViewProvider { // MARK: - Properties - private static let pillAttachmentViewSizes = PillAttachmentView.Sizes(verticalMargin: 2.0, - horizontalMargin: 4.0, - avatarSideLength: 16.0) + static let pillAttachmentViewSizes = PillAttachmentView.Sizes(verticalMargin: 2.0, + horizontalMargin: 6.0, + avatarLeading: 2.0, + avatarSideLength: 16.0, + itemSpacing: 4) private weak var messageTextView: MXKMessageTextView? // MARK: - Override @@ -47,8 +49,7 @@ import UIKit let mainSession = AppDelegate.theDelegate().mxSessions.first as? MXSession - let pillView = PillAttachmentView(frame: CGRect(origin: .zero, size: Self.size(forDisplayText: pillData.displayText, - andFont: pillData.font)), + let pillView = PillAttachmentView(frame: CGRect(origin: .zero, size: textAttachment.size(forFont: pillData.font)), sizes: Self.pillAttachmentViewSizes, theme: ThemeService.shared().theme, mediaManager: mainSession?.mediaManager, @@ -57,23 +58,3 @@ import UIKit messageTextView?.registerPillView(pillView) } } - -@available(iOS 15.0, *) -extension PillAttachmentViewProvider { - /// Computes size required to display a pill for given display text. - /// - /// - Parameters: - /// - displayText: display text for the pill - /// - font: the text font - /// - Returns: required size for pill - static func size(forDisplayText displayText: String, andFont font: UIFont) -> CGSize { - let label = UILabel(frame: .zero) - label.text = displayText - label.font = font - let labelSize = label.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, - height: pillAttachmentViewSizes.pillBackgroundHeight)) - - return CGSize(width: labelSize.width + pillAttachmentViewSizes.totalWidthWithoutLabel, - height: pillAttachmentViewSizes.pillHeight) - } -} diff --git a/Riot/Modules/Pills/PillProvider.swift b/Riot/Modules/Pills/PillProvider.swift new file mode 100644 index 0000000000..5f41c0d9ae --- /dev/null +++ b/Riot/Modules/Pills/PillProvider.swift @@ -0,0 +1,296 @@ +// +// 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 Foundation + +@available (iOS 15.0, *) +private enum PillAttachmentKind { + case attachment(PillTextAttachment) + case string(NSAttributedString) +} + +@available (iOS 15.0, *) +struct PillProvider { + private let session: MXSession + private let eventFormatter: MXKEventFormatter + private let event: MXEvent + private let roomState: MXRoomState + private let latestRoomState: MXRoomState? + private let isEditMode: Bool + + init(withSession session: MXSession, + eventFormatter: MXKEventFormatter, + event: MXEvent, + roomState: MXRoomState, + andLatestRoomState latestRoomState: MXRoomState?, + isEditMode: Bool) { + + self.session = session + self.eventFormatter = eventFormatter + self.event = event + self.roomState = roomState + self.latestRoomState = latestRoomState + self.isEditMode = isEditMode + } + + func pillTextAttachmentString(forUrl url: URL, withLabel label: String, event: MXEvent) -> NSAttributedString? { + + // Try to get a pill from this url + guard let pillType = PillType.from(url: url) else { + return nil + } + + // Do not pillify an url if it is a markdown or an http link (except for user and room) with a custom text + + // First, we need to handle the case where the label can contains more than one # (room alias) + var urlFromLabel = URL(string: label)?.absoluteURL + if urlFromLabel == nil, label.filter({ $0 == "#" }).count > 1 { + if let escapedLabel = label.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: escapedLabel) { + urlFromLabel = Tools.fixURL(withSeveralHashKeys: url) + } + } + + let fixedUrl = Tools.fixURL(withSeveralHashKeys: url) + let isUrlMarkDownLink = urlFromLabel != fixedUrl + + let result: PillAttachmentKind + switch pillType { + case .user(let userId): + var userFound = false + result = pillTextAttachment(forUserId: userId, userFound: &userFound) + // if it is a markdown link and we didn't found the user, don't pillify it + if isUrlMarkDownLink && !userFound { + return nil + } + case .room(let roomId): + var roomFound = false + result = pillTextAttachment(forRoomId: roomId, roomFound: &roomFound) + // if it is a markdown link and we didn't found the room, don't pillify it + if isUrlMarkDownLink && !roomFound { + return nil + } + case .message(let roomId, let messageId): + // if it is a markdown link, don't pillify it + if isUrlMarkDownLink { + return nil + } + result = pillTextAttachment(forMessageId: messageId, inRoomId: roomId) + } + + switch result { + case .attachment(let pillTextAttachment): + return PillsFormatter.attributedStringWithAttachment(pillTextAttachment, link: isEditMode ? nil : url, font: eventFormatter.defaultTextFont) + case .string(let attributedString): + // if we don't have an attachment, use the fallback attributed string + let newAttrString = NSMutableAttributedString(attributedString: attributedString) + if let font = eventFormatter.defaultTextFont { + newAttrString.addAttribute(.font, value: font, range: .init(location: 0, length: newAttrString.length)) + } + newAttrString.addAttribute(.foregroundColor, value: ThemeService.shared().theme.colors.links, range: .init(location: 0, length: newAttrString.length)) + newAttrString.addAttribute(.link, value: url, range: .init(location: 0, length: newAttrString.length)) + return newAttrString + } + } + + /// Retrieve the latest available `MXRoomMember` from given data. + /// + /// - Parameters: + /// - userId: the id of the user + /// - Returns: the room member, if available + private func roomMember(withUserId userId: String) -> MXRoomMember? { + return latestRoomState?.members.member(withUserId: userId) ?? roomState.members.member(withUserId: userId) + } + + /// Create a pill representation for a given user + /// - Parameters: + /// - userId: the user MatrixID + /// - userFound: this flag will be set to true if a user is found locally with this userId + /// - Returns: a pill attachment + private func pillTextAttachment(forUserId userId: String, userFound: inout Bool) -> PillAttachmentKind { + // Search for a room member matching this user id + let roomMember = self.roomMember(withUserId: userId) + var user: MXUser? + + if roomMember == nil { + // fallback on getting the user from the session's store + user = session.user(withUserId: userId) + } + + + let avatarUrl = roomMember?.avatarUrl ?? user?.avatarUrl + let displayName = roomMember?.displayname ?? user?.displayName ?? userId + let isHighlighted = userId == session.myUserId + + let avatar: PillTextAttachmentItem + if roomMember == nil && user == nil { + avatar = .asset(named: "pill_user", + parameters: .init(tintColor: PillAssetColor(uiColor: ThemeService.shared().theme.colors.secondaryContent), + rawRenderingMode: UIImage.RenderingMode.alwaysOriginal.rawValue, + padding: 0.0)) + } else { + avatar = .avatar(url: avatarUrl, + string: displayName, + matrixId: userId) + } + + let data = PillTextAttachmentData(pillType: .user(userId: userId), + items: [ + avatar, + .text(displayName) + ], + isHighlighted: isHighlighted, + alpha: 1.0, + font: eventFormatter.defaultTextFont) + + userFound = roomMember != nil || user != nil + + if let attachment = PillTextAttachment(attachmentData: data) { + return .attachment(attachment) + } + + return .string(NSMutableAttributedString(string: displayName)) + } + + /// Create a pill representation for a given room + /// - Parameters: + /// - roomId: the room MXID or alias + /// - roomFound: this flag will be set to true if a room is found locally with this roomId + /// - Returns: a pill attachment + private func pillTextAttachment(forRoomId roomId: String, roomFound: inout Bool) -> PillAttachmentKind { + // Get the room matching this roomId + let room = roomId.starts(with: "#") ? session.room(withAlias: roomId) : session.room(withRoomId: roomId) + let displayName = room?.displayName ?? VectorL10n.pillRoomFallbackDisplayName + + let avatar: PillTextAttachmentItem + if let room { + if session.spaceService.getSpace(withId: roomId) != nil { + avatar = .spaceAvatar(url: room.avatarData.mxContentUri, + string: displayName, + matrixId: roomId) + } else { + avatar = .avatar(url: room.avatarData.mxContentUri, + string: displayName, + matrixId: roomId) + } + } else { + avatar = .asset(named: "link_icon", + parameters: .init(backgroundColor: PillAssetColor(uiColor: ThemeService.shared().theme.colors.links), + rawRenderingMode: UIImage.RenderingMode.alwaysTemplate.rawValue)) + } + + let data = PillTextAttachmentData(pillType: .room(roomId: roomId), + items: [ + avatar, + .text(displayName) + ], + isHighlighted: false, + alpha: 1.0, + font: eventFormatter.defaultTextFont) + + roomFound = room != nil + + if let attachment = PillTextAttachment(attachmentData: data) { + return .attachment(attachment) + } + + return .string(NSMutableAttributedString(string: displayName)) + } + + /// Create a pill representation for a message in a room + /// - Parameters: + /// - messageId: message eventId + /// - roomId: roomId of the message + /// - Returns: a pill attachment + private func pillTextAttachment(forMessageId messageId: String, inRoomId roomId: String) -> PillAttachmentKind { + + // Check if this is the current room + if roomId == roomState.roomId { + return pillTextAttachment(inCurrentRoomForMessageId: messageId) + } + + let room = session.room(withRoomId: roomId) + + let avatar: PillTextAttachmentItem + if let room { + avatar = .avatar(url: room.avatarData.mxContentUri, + string: room.displayName, + matrixId: roomId) + } else { + avatar = .asset(named: "link_icon", + parameters: .init(backgroundColor: PillAssetColor(uiColor: ThemeService.shared().theme.colors.links), + rawRenderingMode: UIImage.RenderingMode.alwaysTemplate.rawValue)) + + } + + let displayText = room?.displayName.flatMap { VectorL10n.pillMessageIn($0) } ?? VectorL10n.pillMessage + + let data = PillTextAttachmentData(pillType: .message(roomId: roomId, eventId: messageId), + items: [ + avatar, + .text(displayText) + ], + isHighlighted: false, + alpha: 1.0, + font: eventFormatter.defaultTextFont) + + if let attachment = PillTextAttachment(attachmentData: data) { + return .attachment(attachment) + } + + return .string(NSMutableAttributedString(string: displayText)) + } + + /// Create a pill representation for a message in the current room + /// - Parameters: + /// - messageId: message eventId + /// - Returns: a pill attachment + private func pillTextAttachment(inCurrentRoomForMessageId messageId: String) -> PillAttachmentKind { + var roomMember: MXRoomMember? + // If we have the event locally, try to get the room member + if let event = session.store.event(withEventId: messageId, inRoom: roomState.roomId) { + roomMember = self.roomMember(withUserId: event.sender) + } + + let displayText: String + let avatar: PillTextAttachmentItem + if let roomMember { + displayText = VectorL10n.pillMessageFrom(roomMember.displayname) + avatar = .avatar(url: roomMember.avatarUrl, + string: roomMember.displayname, + matrixId: roomMember.userId) + } else { + displayText = VectorL10n.pillMessage + avatar = .asset(named: "link_icon", + parameters: .init(backgroundColor: PillAssetColor(uiColor: ThemeService.shared().theme.colors.links), + rawRenderingMode: UIImage.RenderingMode.alwaysTemplate.rawValue)) + } + + let data = PillTextAttachmentData(pillType: .message(roomId: roomState.roomId, eventId: messageId), + items: [ + avatar, + .text(displayText) + ].compactMap { $0 }, + isHighlighted: false, + alpha: 1.0, + font: eventFormatter.defaultTextFont) + + if let attachment = PillTextAttachment(attachmentData: data) { + return .attachment(attachment) + } + + return .string(NSMutableAttributedString(string: displayText)) + } +} diff --git a/Riot/Modules/Pills/PillTextAttachment.swift b/Riot/Modules/Pills/PillTextAttachment.swift index 33a39e3168..5e46fe2348 100644 --- a/Riot/Modules/Pills/PillTextAttachment.swift +++ b/Riot/Modules/Pills/PillTextAttachment.swift @@ -45,6 +45,11 @@ class PillTextAttachment: NSTextAttachment { updateBounds() } + + convenience init?(attachmentData: PillTextAttachmentData) { + guard let encodedData = try? Self.serializationService.serialize(attachmentData) else { return nil } + self.init(data: encodedData, ofType: PillsFormatter.pillUTType) + } /// Create a Mention Pill text attachment for given room member. /// @@ -55,9 +60,13 @@ class PillTextAttachment: NSTextAttachment { convenience init?(withRoomMember roomMember: MXRoomMember, isHighlighted: Bool, font: UIFont) { - let data = PillTextAttachmentData(matrixItemId: roomMember.userId, - displayName: roomMember.displayname, - avatarUrl: roomMember.avatarUrl, + let data = PillTextAttachmentData(pillType: .user(userId: roomMember.userId), + items: [ + .avatar(url: roomMember.avatarUrl, + string: roomMember.displayname, + matrixId: roomMember.userId), + .text(roomMember.displayname) + ], isHighlighted: isHighlighted, alpha: 1.0, font: font) @@ -71,14 +80,63 @@ class PillTextAttachment: NSTextAttachment { updateBounds() } + + /// Computes size required to display a pill for given display text. + /// + /// - Parameters: + /// - font: the text font + /// - Returns: required size for pill + func size(forFont font: UIFont) -> CGSize { + guard let data else { + MXLog.debug("[PillTextAttachment]: data are missing") + return .zero + } + + let sizes = PillAttachmentViewProvider.pillAttachmentViewSizes + + var width: CGFloat = 0 + + var textContent = "" + for item in data.items { + switch item { + case .text(let text): + textContent += text + case .avatar, .asset, .spaceAvatar: + width += sizes.avatarSideLength + } + } + + // add texts + if !textContent.isEmpty { + let label = UILabel(frame: .zero) + label.font = font + label.text = textContent + width += label.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, + height: sizes.pillBackgroundHeight)).width + } + + // add spacing + width += CGFloat(max(0, data.items.count - 1)) * sizes.itemSpacing + // add margins + switch data.items.first { + case .asset, .avatar: + width += sizes.avatarLeading + sizes.horizontalMargin + default: + width += 2 * sizes.horizontalMargin + } + + return CGSize(width: width, + height: sizes.pillHeight) + } } // MARK: - Private @available (iOS 15.0, *) private extension PillTextAttachment { + func updateBounds() { guard let data = data else { return } - let pillSize = PillAttachmentViewProvider.size(forDisplayText: data.displayText, andFont: data.font) + let pillSize = size(forFont: data.font) // Offset to align pill centerY with text centerY. let offset = data.font.descender + (data.font.lineHeight - pillSize.height) / 2.0 self.bounds = CGRect(origin: CGPoint(x: 0.0, y: offset), size: pillSize) diff --git a/Riot/Modules/Pills/PillTextAttachmentData.swift b/Riot/Modules/Pills/PillTextAttachmentData.swift index 57e2a368b0..99877444d8 100644 --- a/Riot/Modules/Pills/PillTextAttachmentData.swift +++ b/Riot/Modules/Pills/PillTextAttachmentData.swift @@ -17,16 +17,55 @@ import Foundation import UIKit +@available (iOS 15.0, *) +struct PillAssetColor: Codable { + var red: CGFloat = 0.0, green: CGFloat = 0.0, blue: CGFloat = 0.0, alpha: CGFloat = 0.0 + + var uiColor: UIColor { + return UIColor(red: red, green: green, blue: blue, alpha: alpha) + } + + init(uiColor: UIColor) { + uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + } +} + +@available (iOS 15.0, *) +struct PillAssetParameter: Codable { + var tintColor: PillAssetColor? + var backgroundColor: PillAssetColor? + var rawRenderingMode: Int = UIImage.RenderingMode.automatic.rawValue + var padding: CGFloat = 2.0 +} + +@available (iOS 15.0, *) +enum PillTextAttachmentItem: Codable { + case text(String) + case avatar(url: String?, string: String?, matrixId: String) + case spaceAvatar(url: String?, string: String?, matrixId: String) + case asset(named: String, parameters: PillAssetParameter) +} + +@available (iOS 15.0, *) +extension PillTextAttachmentItem { + var string: String? { + switch self { + case .text(let text): + return text + default: + return nil + } + } +} + /// Data associated with a Pill text attachment. @available (iOS 15.0, *) struct PillTextAttachmentData: Codable { // MARK: - Properties - /// Matrix item identifier (user id or room id) - var matrixItemId: String - /// Matrix item display name (user or room display name) - var displayName: String? - /// Matrix item avatar URL (user or room avatar url) - var avatarUrl: String? + /// Pill type + var pillType: PillType + /// Items to render + var items: [PillTextAttachmentItem] /// Wether the pill should be highlighted var isHighlighted: Bool /// Alpha for pill display @@ -36,43 +75,36 @@ struct PillTextAttachmentData: Codable { /// Helper for preferred text to display. var displayText: String { - guard let displayName = displayName, - displayName.count > 0 else { - return matrixItemId - } - - return displayName + return items.map { $0.string } + .compactMap { $0 } + .joined(separator: " ") } - + // MARK: - Init /// Init. /// /// - Parameters: - /// - matrixItemId: Matrix item identifier (user id or room id) - /// - displayName: Matrix item display name (user or room display name) - /// - avatarUrl: Matrix item avatar URL (user or room avatar url) + /// - pillType: Type for the pill + /// - items: Items to display /// - isHighlighted: Wether the pill should be highlighted /// - alpha: Alpha for pill display /// - font: Font for the display name - init(matrixItemId: String, - displayName: String?, - avatarUrl: String?, + init(pillType: PillType, + items: [PillTextAttachmentItem], isHighlighted: Bool, alpha: CGFloat, font: UIFont) { - self.matrixItemId = matrixItemId - self.displayName = displayName - self.avatarUrl = avatarUrl + self.pillType = pillType + self.items = items self.isHighlighted = isHighlighted self.alpha = alpha self.font = font } - + // MARK: - Codable enum CodingKeys: String, CodingKey { - case matrixItemId - case displayName - case avatarUrl + case pillType + case items case isHighlighted case alpha case font @@ -84,9 +116,8 @@ struct PillTextAttachmentData: Codable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - matrixItemId = try container.decode(String.self, forKey: .matrixItemId) - displayName = try? container.decode(String.self, forKey: .displayName) - avatarUrl = try? container.decode(String.self, forKey: .avatarUrl) + pillType = try container.decode(PillType.self, forKey: .pillType) + items = try container.decode([PillTextAttachmentItem].self, forKey: .items) isHighlighted = try container.decode(Bool.self, forKey: .isHighlighted) alpha = try container.decode(CGFloat.self, forKey: .alpha) let fontData = try container.decode(Data.self, forKey: .font) @@ -99,12 +130,36 @@ struct PillTextAttachmentData: Codable { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(matrixItemId, forKey: .matrixItemId) - try? container.encode(displayName, forKey: .displayName) - try? container.encode(avatarUrl, forKey: .avatarUrl) + try container.encode(pillType, forKey: .pillType) + try container.encode(items, forKey: .items) try container.encode(isHighlighted, forKey: .isHighlighted) try container.encode(alpha, forKey: .alpha) let fontData = try NSKeyedArchiver.archivedData(withRootObject: font, requiringSecureCoding: false) try container.encode(fontData, forKey: .font) } + + // MARK: - Pill representations + var pillIdentifier: String { + switch pillType { + case .user(let userId): + return userId + case .room(let roomId): + return roomId + case .message(let roomId, let messageId): + return "\(roomId)/\(messageId)" + } + } + + var markdown: String { + var permalink: String + switch pillType { + case .user(let userId): + permalink = MXTools.permalinkToUser(withUserId: userId) + case .room(let roomId): + permalink = MXTools.permalink(toRoom: roomId) + case .message(let roomId, let messageId): + permalink = MXTools.permalink(toEvent: messageId, inRoom: roomId) + } + return "[\(displayText)](\(permalink))" + } } diff --git a/Riot/Modules/Pills/PillType.swift b/Riot/Modules/Pills/PillType.swift new file mode 100644 index 0000000000..8b90de15b8 --- /dev/null +++ b/Riot/Modules/Pills/PillType.swift @@ -0,0 +1,76 @@ +// +// 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 Foundation + +@available (iOS 15.0, *) +enum PillType: Codable { + case user(userId: String) /// userId + case room(roomId: String) /// roomId + case message(roomId: String, eventId: String) // roomId, eventId +} + +@available (iOS 15.0, *) +extension PillType { + private static var regexPermalinkTarget: NSRegularExpression? = { + let clientBaseUrl = BuildSettings.clientPermalinkBaseUrl ?? kMXMatrixDotToUrl + let pattern = #"\#(clientBaseUrl)/#/(?:(?:room|user)/)?((?:@|!|#)[^@!#/?\s]*)/?((?:\$)[^\$/?\s]*)?"# + return try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) + }() + + static func from(url: URL) -> PillType? { + guard let regex = regexPermalinkTarget else { + return nil + } + + var link = url.absoluteString + // we need to remove percent encoding (it's possible that it has been encoded multiple times) + while let cleaned = link.removingPercentEncoding, cleaned != link { + link = cleaned + } + + let pills = regex.matches(in: link, options: [], range: NSRange(link.startIndex..., in: link)) + .map { result -> [String]? in + guard result.numberOfRanges > 1 else { return nil } + return (1.. PillType? in + guard let matrixIds, !matrixIds.isEmpty else { + return nil + } + switch matrixIds[0].first { + case "@": + return .user(userId: matrixIds[0]) + case "!", "#": + if matrixIds.count > 1 { + if matrixIds[1].starts(with: "$") { + return .message(roomId: matrixIds[0], eventId: matrixIds[1]) + } + } + return .room(roomId: matrixIds[0]) + default: + return nil + } + } + + return pills.first + } +} diff --git a/Riot/Modules/Pills/PillsFormatter.swift b/Riot/Modules/Pills/PillsFormatter.swift index ccee483177..a9df99fd46 100644 --- a/Riot/Modules/Pills/PillsFormatter.swift +++ b/Riot/Modules/Pills/PillsFormatter.swift @@ -32,7 +32,7 @@ class PillsFormatter: NSObject { case identifier case markdown } - + // MARK: - Internal Methods /// Insert text attachments for pills inside given message attributed string. /// @@ -52,17 +52,21 @@ class PillsFormatter: NSObject { roomState: MXRoomState, andLatestRoomState latestRoomState: MXRoomState?, isEditMode: Bool = false) -> NSAttributedString { + let newAttr = NSMutableAttributedString(attributedString: attributedString) newAttr.vc_enumerateAttribute(.link) { (url: URL, range: NSRange, _) in - if let userId = userIdFromPermalink(url.absoluteString), - let roomMember = roomMember(withUserId: userId, - roomState: roomState, - andLatestRoomState: latestRoomState) { - let isHighlighted = roomMember.userId == session.myUserId && event.sender != session.myUserId - let attachmentString = mentionPill(withRoomMember: roomMember, - andUrl: isEditMode ? nil : url, - isHighlighted: isHighlighted, - font: eventFormatter.defaultTextFont) + + let provider = PillProvider(withSession: session, + eventFormatter: eventFormatter, + event: event, + roomState: roomState, + andLatestRoomState: latestRoomState, + isEditMode: isEditMode) + + // try to get a mention pill from the url + let label = Range(range, in: newAttr.string).flatMap { String(newAttr.string[$0]) } + if let attachmentString: NSAttributedString = provider.pillTextAttachmentString(forUrl: url, withLabel: label ?? "", event: event) { + // replace the url with the pill newAttr.replaceCharacters(in: range, with: attachmentString) } } @@ -80,25 +84,27 @@ class PillsFormatter: NSObject { mode: PillsReplacementTextMode = .displayname) -> String { let newAttr = NSMutableAttributedString(attributedString: attributedString) newAttr.vc_enumerateAttribute(.attachment) { (attachment: PillTextAttachment, range: NSRange, _) in - if let displayText = attachment.data?.displayText, - let userId = attachment.data?.matrixItemId, - let permalink = MXTools.permalinkToUser(withUserId: userId) { - let pillString: String - switch mode { - case .displayname: - pillString = displayText - case .identifier: - pillString = userId - case .markdown: - pillString = "[\(displayText)](\(permalink))" - } - newAttr.replaceCharacters(in: range, with: pillString) + guard let data = attachment.data else { + return } + + let pillString: String + switch mode { + case .displayname: + pillString = data.displayText + case .identifier: + pillString = data.pillIdentifier + case .markdown: + pillString = data.markdown + } + + newAttr.replaceCharacters(in: range, with: pillString) } return newAttr.string } + /// Creates an attributed string containing a pill for given room member. /// /// - Parameters: @@ -111,17 +117,13 @@ class PillsFormatter: NSObject { andUrl url: URL? = nil, isHighlighted: Bool, font: UIFont) -> NSAttributedString { + guard let attachment = PillTextAttachment(withRoomMember: roomMember, isHighlighted: isHighlighted, font: font) else { return NSAttributedString(string: roomMember.displayname) } - let string = NSMutableAttributedString(attachment: attachment) - string.addAttribute(.font, value: font, range: .init(location: 0, length: string.length)) - if let url = url { - string.addAttribute(.link, value: url, range: .init(location: 0, length: string.length)) - } - return string + return attributedStringWithAttachment(attachment, link: url, font: font) } - + /// Update alpha of all `PillTextAttachment` contained in given attributed string. /// /// - Parameters: @@ -140,43 +142,37 @@ class PillsFormatter: NSObject { /// - roomState: room state for refresh, should be the latest available static func refreshPills(in attributedString: NSAttributedString, with roomState: MXRoomState) { attributedString.vc_enumerateAttribute(.attachment) { (pill: PillTextAttachment, range: NSRange, _) in - guard let userId = pill.data?.matrixItemId, - let roomMember = roomState.members.member(withUserId: userId) else { - return - } + + switch pill.data?.pillType { + case .user(let userId): + guard let roomMember = roomState.members.member(withUserId: userId) else { + return + } - pill.data?.displayName = roomMember.displayname - pill.data?.avatarUrl = roomMember.avatarUrl + pill.data?.items = [ + .avatar(url: roomMember.avatarUrl, + string: roomMember.displayname, + matrixId: roomMember.userId), + .text(roomMember.displayname) + ] + default: + break + } } } + } // MARK: - Private Methods @available (iOS 15.0, *) -private extension PillsFormatter { - /// Extract user id from given permalink - /// - Parameter permalink: the permalink - /// - Returns: userId, if any - static func userIdFromPermalink(_ permalink: String) -> String? { - let baseUrl: String - if let clientBaseUrl = BuildSettings.clientPermalinkBaseUrl { - baseUrl = String(format: "%@/#/user/", clientBaseUrl) - } else { - baseUrl = String(format: "%@/#/", kMXMatrixDotToUrl) +extension PillsFormatter { + + static func attributedStringWithAttachment(_ attachment: PillTextAttachment, link: URL?, font: UIFont) -> NSAttributedString { + let string = NSMutableAttributedString(attachment: attachment) + string.addAttribute(.font, value: font, range: .init(location: 0, length: string.length)) + if let url = link { + string.addAttribute(.link, value: url, range: .init(location: 0, length: string.length)) } - return permalink.starts(with: baseUrl) ? String(permalink.dropFirst(baseUrl.count)) : nil - } - - /// Retrieve the latest available `MXRoomMember` from given data. - /// - /// - Parameters: - /// - userId: the id of the user - /// - roomState: room state for message - /// - latestRoomState: latest room state of the room containing this message - /// - Returns: the room member, if available - static func roomMember(withUserId userId: String, - roomState: MXRoomState, - andLatestRoomState latestRoomState: MXRoomState?) -> MXRoomMember? { - return latestRoomState?.members.member(withUserId: userId) ?? roomState.members.member(withUserId: userId) + return string } } diff --git a/Riot/Modules/Spaces/Avatar/SpaceAvatarView.swift b/Riot/Modules/Spaces/Avatar/SpaceAvatarView.swift index 3a109e20ee..5da26dc03e 100644 --- a/Riot/Modules/Spaces/Avatar/SpaceAvatarView.swift +++ b/Riot/Modules/Spaces/Avatar/SpaceAvatarView.swift @@ -57,7 +57,8 @@ final class SpaceAvatarView: AvatarView, NibOwnerLoadable { override func layoutSubviews() { super.layoutSubviews() - self.avatarImageView.layer.cornerRadius = Constants.cornerRadius + // Ensure we keep a rounded corner if the width is less than 2 * Constants.cornerRadius + self.avatarImageView.layer.cornerRadius = max(2.0, min(self.avatarImageView.bounds.width / 4, Constants.cornerRadius)) } // MARK: - Public diff --git a/Riot/Modules/User/Avatar/UserAvatarView.swift b/Riot/Modules/User/Avatar/UserAvatarView.swift index 9e57958f58..34324b1bed 100644 --- a/Riot/Modules/User/Avatar/UserAvatarView.swift +++ b/Riot/Modules/User/Avatar/UserAvatarView.swift @@ -23,6 +23,7 @@ final class UserAvatarView: AvatarView { private func commonInit() { let avatarImageView = MXKImageView() + avatarImageView.frame = self.frame self.vc_addSubViewMatchingParent(avatarImageView) self.avatarImageView = avatarImageView } diff --git a/RiotTests/MatrixKitTests/MXKEventFormatterTests.m b/RiotTests/MatrixKitTests/MXKEventFormatterTests.m index b21f577150..1e4d61d51e 100644 --- a/RiotTests/MatrixKitTests/MXKEventFormatterTests.m +++ b/RiotTests/MatrixKitTests/MXKEventFormatterTests.m @@ -423,7 +423,7 @@ - (void)testLinkWithRoomAliasLink } }]; - XCTAssertEqual(hasLink, false, @"There should be no link in this case. We let the UI manage the link"); + XCTAssertEqual(hasLink, true, @"There should be a link, so that a Pill can be rendered for this permalink."); } #pragma mark - Event sender/target info diff --git a/RiotTests/PillTypeTests.swift b/RiotTests/PillTypeTests.swift new file mode 100644 index 0000000000..765b0a4f33 --- /dev/null +++ b/RiotTests/PillTypeTests.swift @@ -0,0 +1,109 @@ +// +// 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 XCTest +@testable import Element + +@available (iOS 15.0, *) +final class PillTypeTests: XCTestCase { + + func testUserPill() throws { + let urls = [ + "https://matrix.to/#/@bob:matrix.org", + "https://matrix.to/#/user/@bob:matrix.org" + ] + + for url in urls { + switch PillType.from(url: URL(string: url)!) { + case .user(let userId): + XCTAssertEqual(userId, "@bob:matrix.org") + default: + XCTFail("Should be a .user pill") + } + } + } + + func testRoomPill() throws { + let urls = [ + "https://matrix.to/#/!JppIaYcVkyCiSBVzBn:localhost", + "https://matrix.to/#/!JppIaYcVkyCiSBVzBn:localhost?via=localhost", + "https://matrix.to/#/room/!JppIaYcVkyCiSBVzBn:localhost" + ] + + for url in urls { + switch PillType.from(url: URL(string: url)!) { + case .room(let roomId): + XCTAssertEqual(roomId, "!JppIaYcVkyCiSBVzBn:localhost") + default: + XCTFail("Should be a .room pill") + } + } + } + + func testRoomAlias() throws { + let urls = [ + "https://matrix.to/#/%23room-alias:localhost", + "https://matrix.to/#/room/%23room-alias:localhost" + ] + + for url in urls { + switch PillType.from(url: URL(string: url)!) { + case .room(let roomId): + XCTAssertEqual(roomId, "#room-alias:localhost") + default: + XCTFail("Should be a .room pill") + } + } + } + + func testMessagePill() throws { + let urls = [ + "https://matrix.to/#/!JppIaYcVkyCiSBVzBn:localhost/$4uvJnQsShl_2OqfqO4dkmUq-mKula7HUx-ictOTPmPc", + "https://matrix.to/#/!JppIaYcVkyCiSBVzBn:localhost/$4uvJnQsShl_2OqfqO4dkmUq-mKula7HUx-ictOTPmPc?via=localhost" + ] + + for url in urls { + switch PillType.from(url: URL(string: url)!) { + case .message(let roomId, let eventId): + XCTAssertEqual(roomId, "!JppIaYcVkyCiSBVzBn:localhost") + XCTAssertEqual(eventId, "$4uvJnQsShl_2OqfqO4dkmUq-mKula7HUx-ictOTPmPc") + default: + XCTFail("Should be a .message pill") + } + } + } + + func testMessagePillWithRoomAlias() throws { + let urls = [ + "https://matrix.to/#/%23room-alias:localhost/$4uvJnQsShl_2OqfqO4dkmUq-mKula7HUx-ictOTPmPc?via=localhost" + ] + + for url in urls { + switch PillType.from(url: URL(string: url)!) { + case .message(let roomId, let eventId): + XCTAssertEqual(roomId, "#room-alias:localhost") + XCTAssertEqual(eventId, "$4uvJnQsShl_2OqfqO4dkmUq-mKula7HUx-ictOTPmPc") + default: + XCTFail("Should be a .message pill") + } + } + } + + func testNotAPermalink() throws { + XCTAssertNil(PillType.from(url: URL(string: "matrix.org")!)) + } + +} diff --git a/RiotTests/PillsFormatterTests.swift b/RiotTests/PillsFormatterTests.swift index edc18a70b6..573fd234c7 100644 --- a/RiotTests/PillsFormatterTests.swift +++ b/RiotTests/PillsFormatterTests.swift @@ -27,10 +27,42 @@ private enum Inputs { static let aliceNewAvatarUrl = "mxc://matrix.org/VyNYAgaFdlLojoOeZETtQ" static let aliceMember = FakeMXRoomMember(displayname: aliceDisplayname, avatarUrl: aliceAvatarUrl, userId: aliceUserId) static let aliceMemberAway = FakeMXRoomMember(displayname: aliceAwayDisplayname, avatarUrl: aliceNewAvatarUrl, userId: "@alice:matrix.org") - static let bobMember = FakeMXRoomMember(displayname: "Bob", avatarUrl: "", userId: "@bob:matrix.org") static let alicePermalink = "https://matrix.to/#/@alice:matrix.org" static let mentionToAlice = NSAttributedString(string: aliceDisplayname, attributes: [.link: URL(string: alicePermalink)!]) static let markdownLinkToAlice = "[Alice](\(alicePermalink))" + + static let bobUserId = "@bob:matrix.org" + static let bobDisplayname = "Bob" + static let bobAvatarUrl = "mxc://matrix.org/VyNYBgahazAzUuOeZETtQ" + static let bobMember = FakeMXRoomMember(displayname: bobDisplayname, avatarUrl: bobAvatarUrl, userId: bobUserId) + + static let anotherUserId = "@another.user:matrix.org" + static let anotherUserPermalink = "https://matrix.to/#/@another.user:matrix.org" + static let markdownLinkToAnotherUser = "[Another user](\(alicePermalink))" + static let mentionToAnotherUser = NSAttributedString(string: anotherUserPermalink, attributes: [.link: URL(string: anotherUserPermalink)!]) + static let mentionToAnotherUserWithLabel = NSAttributedString(string: "Link text", attributes: [.link: URL(string: anotherUserPermalink)!]) + + static let roomId = "!vWieJcXcUdMwavNSvy:matrix.org" + static let roomAlias = "#fake_room_alias:matrix.org" + static let roomDisplayName = "Sample Room" + static let roomPermalink = "https://matrix.to/#/\(roomId)" + static let roomAliasPermalink = "https://matrix.to/%23/\(roomAlias)" + static let roomAvatarUrl = "mxc://matrix.org/VzNZAgahaiAzUoOeZETtQ" + static let mentionToRoom = NSAttributedString(string: roomPermalink, attributes: [.link: URL(string: roomPermalink)!]) + static let mentionToRoomWithLabel = NSAttributedString(string: roomDisplayName, attributes: [.link: URL(string: roomPermalink)!]) + static let mentionToRoomAlias = NSAttributedString(string: roomDisplayName, attributes: [.link: URL(string: roomAliasPermalink)!]) + + static let anotherRoomId = "!zWieBcUcUdMwavNSvy:matrix.org" + static let anotherRoomDisplayName = "Room/Space" + static let anotherRoomAvatarUrl = "mxc://matrix.org/VzNZBgajauAzUoOeZETtQ" + + static let messageEventId = "$JrEsoQO77MCdAubG6z-5oXlOBy1I5QL9FTut_Giztoc" + static let messagePermalink = "https://matrix.to/#/\(roomId)/\(messageEventId)?via=matrix.org" + static let messageAnotherRoomPermalink = "https://matrix.to/#/\(anotherRoomId)/\(messageEventId)?via=matrix.org" + + static let pillAnotherUserWithLinkText = "Link text" + static let pillMessageAnotherRoomText = "Message in Sample Room" + static let pillMessageFromBobText = "Message from Bob" } // MARK: - Tests @@ -47,11 +79,24 @@ class PillsFormatterTests: XCTestCase { // Attachment has correct type. XCTAssert(pillTextAttachment?.fileType == PillsFormatter.pillUTType) // Pill data contains Alice's displayname and avatar url. - XCTAssertEqual(pillTextAttachment?.data?.displayText, Inputs.aliceDisplayname) - XCTAssertEqual(pillTextAttachment?.data?.avatarUrl, Inputs.aliceAvatarUrl) + XCTAssertNotNil(pillTextAttachment?.data) + let pillTextAttachmentData: PillTextAttachmentData! = pillTextAttachment?.data + XCTAssertEqual(pillTextAttachmentData.displayText, Inputs.aliceDisplayname) + switch pillTextAttachmentData.pillType { + case .user(let userId): + XCTAssertEqual(userId, Inputs.aliceUserId) + switch pillTextAttachmentData.items.first { + case .avatar(let url, _, _): + XCTAssertEqual(url, Inputs.aliceAvatarUrl) + default: + XCTFail("First pill item should be the avatar") + } + default: + XCTFail("Pill should be of type .user") + } + // Pill has expected size. - let expectedSize = PillAttachmentViewProvider.size(forDisplayText: pillTextAttachment!.data!.displayText, - andFont: pillTextAttachment!.data!.font) + let expectedSize = pillTextAttachment?.size(forFont: pillTextAttachment!.data!.font) XCTAssertEqual(pillTextAttachment?.bounds.size, expectedSize) PillsFormatter.refreshPills(in: messageWithPills, @@ -60,11 +105,23 @@ class PillsFormatterTests: XCTestCase { // Alice's pill is still highlighted. XCTAssert(pillTextAttachment?.data?.isHighlighted == true) // Pill data is refreshed with correct data. - XCTAssertEqual(refreshedPillTextAttachment?.data?.displayText, Inputs.aliceAwayDisplayname) - XCTAssertEqual(refreshedPillTextAttachment?.data?.avatarUrl, Inputs.aliceNewAvatarUrl) + let updatedPillTextAttachmentData: PillTextAttachmentData! = pillTextAttachment?.data + XCTAssertEqual(updatedPillTextAttachmentData.displayText, Inputs.aliceAwayDisplayname) + switch updatedPillTextAttachmentData.pillType { + case .user(let userId): + XCTAssertEqual(userId, Inputs.aliceUserId) + switch updatedPillTextAttachmentData.items.first { + case .avatar(let url, _, _): + XCTAssertEqual(url, Inputs.aliceNewAvatarUrl) + default: + XCTFail("First pill item should be the avatar") + } + default: + XCTFail("Pill should be of type .user") + } + // Pill size is updated - let newExpectedSize = PillAttachmentViewProvider.size(forDisplayText: refreshedPillTextAttachment!.data!.displayText, - andFont: refreshedPillTextAttachment!.data!.font) + let newExpectedSize = pillTextAttachment?.size(forFont: refreshedPillTextAttachment!.data!.font) XCTAssertEqual(refreshedPillTextAttachment?.bounds.size, newExpectedSize) } @@ -72,8 +129,21 @@ class PillsFormatterTests: XCTestCase { let messageWithPills = createMessageWithMentionFromBobToAliceWithLatestRoomState() let pillTextAttachment = messageWithPills.attribute(.attachment, at: messageWithPills.length - 1, effectiveRange: nil) as? PillTextAttachment // Pill uses the latest room state data. - XCTAssertEqual(pillTextAttachment?.data?.displayText, Inputs.aliceAwayDisplayname) - XCTAssertEqual(pillTextAttachment?.data?.avatarUrl, Inputs.aliceNewAvatarUrl) + XCTAssertNotNil(pillTextAttachment?.data) + let pillTextAttachmentData: PillTextAttachmentData! = pillTextAttachment?.data + XCTAssertEqual(pillTextAttachmentData.displayText, Inputs.aliceAwayDisplayname) + switch pillTextAttachmentData.pillType { + case .user(let userId): + XCTAssertEqual(userId, Inputs.aliceUserId) + switch pillTextAttachmentData.items.first { + case .avatar(let url, _, _): + XCTAssertEqual(url, Inputs.aliceNewAvatarUrl) + default: + XCTFail("First pill item should be the avatar") + } + default: + XCTFail("Pill should be of type .message") + } } func testPillsToMarkdown() { @@ -89,6 +159,292 @@ class PillsFormatterTests: XCTestCase { XCTAssertEqual(messageWithDisplayname, Inputs.messageStart + Inputs.aliceDisplayname) XCTAssertEqual(messageWithUserId, Inputs.messageStart + Inputs.aliceUserId) } + + // Test case: a mention to an unknown user (not a room member) + func testPillMentionningRoomMember() { + let messageWithPills = createMessageWithMentionFromBobToAlice() + let pillTextAttachment = messageWithPills.attribute(.attachment, at: messageWithPills.length - 1, effectiveRange: nil) as? PillTextAttachment + // Pill uses the latest room state data. + XCTAssertNotNil(pillTextAttachment?.data) + let pillTextAttachmentData: PillTextAttachmentData! = pillTextAttachment?.data + XCTAssertEqual(pillTextAttachmentData.displayText, Inputs.aliceDisplayname) + switch pillTextAttachmentData.pillType { + case .user(let userId): + XCTAssertEqual(userId, Inputs.aliceUserId) + switch pillTextAttachmentData.items.first { + case .avatar(let url, _, _): + XCTAssertEqual(url, Inputs.aliceAvatarUrl) + default: + XCTFail("First pill item should be the avatar") + } + default: + XCTFail("Pill should be of type .user") + } + } + + // Test case: a mention to an unknown user (not a room member) + func testPillMentionningUnknownUser() { + let messageWithPills = createMessageWithMentionFromBobToAnotherUser() + let pillTextAttachment = messageWithPills.attribute(.attachment, at: messageWithPills.length - 1, effectiveRange: nil) as? PillTextAttachment + // Pill uses the latest room state data. + XCTAssertNotNil(pillTextAttachment?.data) + let pillTextAttachmentData: PillTextAttachmentData! = pillTextAttachment?.data + XCTAssertEqual(pillTextAttachmentData.displayText, Inputs.anotherUserId) + switch pillTextAttachmentData.pillType { + case .user(let userId): + XCTAssertEqual(userId, Inputs.anotherUserId) + switch pillTextAttachmentData.items.first { + case .asset(let name, _): + XCTAssertEqual(name, "pill_user") + default: + XCTFail("First pill item should be the asset") + } + default: + XCTFail("Pill should be of type .user") + } + } + + // Test case: a mention to an unknown user (not a room member) with a formatted text (HTML or MARKDOWN) + // In this case, we don't want to pillify the link + func testPillMentionningUnknownUserWithFormattedText() { + let messageWithPills = createMessageWithMentionFromBobToAnotherUser(withLinkText: true) + let pillTextAttachment = messageWithPills.attribute(.attachment, at: messageWithPills.length - 1, effectiveRange: nil) as? PillTextAttachment + XCTAssertNil(pillTextAttachment) + } + + // Test case: a mention to a room + func testPillMentionningRoom() { + let messageWithPills = createMessageWithMentionToRoom() + XCTAssertEqual(messageWithPills.length, Inputs.messageStart.count + 1) // +1 non-unicode character for the pill/textAttachment + XCTAssert(messageWithPills.attribute(.attachment, at: messageWithPills.length - 1, effectiveRange: nil) is PillTextAttachment) + + let pillTextAttachment = messageWithPills.attribute(.attachment, at: messageWithPills.length - 1, effectiveRange: nil) as? PillTextAttachment + // Pill is not highlighted. + XCTAssert(pillTextAttachment?.data?.isHighlighted == false) + // Attachment has correct type. + XCTAssert(pillTextAttachment?.fileType == PillsFormatter.pillUTType) + // Pill data contains the correct displayname and avatar url. + XCTAssertNotNil(pillTextAttachment?.data) + let pillTextAttachmentData: PillTextAttachmentData! = pillTextAttachment?.data + XCTAssertEqual(pillTextAttachmentData.displayText, Inputs.roomDisplayName) + switch pillTextAttachmentData.pillType { + case .room(let userId): + XCTAssertEqual(userId, Inputs.roomId) + switch pillTextAttachmentData.items.first { + case .avatar(let url, _, _): + XCTAssertEqual(url, Inputs.roomAvatarUrl) + default: + XCTFail("First pill item should be the avatar") + } + default: + XCTFail("Pill should be of type .room") + } + } + + // Test case: a mention to a space + func testPillMentionningSpace() { + let messageWithPills = createMessageWithMentionToRoom(isSpace: true) + + let pillTextAttachment = messageWithPills.attribute(.attachment, at: messageWithPills.length - 1, effectiveRange: nil) as? PillTextAttachment + // Pill is not highlighted. + XCTAssert(pillTextAttachment?.data?.isHighlighted == false) + // Attachment has correct type. + XCTAssert(pillTextAttachment?.fileType == PillsFormatter.pillUTType) + // Pill data contains the correct displayname and avatar url. + XCTAssertNotNil(pillTextAttachment?.data) + let pillTextAttachmentData: PillTextAttachmentData! = pillTextAttachment?.data + XCTAssertEqual(pillTextAttachmentData.displayText, Inputs.roomDisplayName) + switch pillTextAttachmentData.pillType { + case .room(let userId): + XCTAssertEqual(userId, Inputs.roomId) + switch pillTextAttachmentData.items.first { + case .spaceAvatar(let url, _, _): + XCTAssertEqual(url, Inputs.roomAvatarUrl) + default: + XCTFail("First pill item should be the spaceAvatar") + } + default: + XCTFail("Pill should be of type .room") + } + } + + // Test case: a mention to a room alias + func testPillMentionningRoomByAlias() { + let messageWithPills = createMessageWithMentionToRoom(usingAlias: true) + let pillTextAttachment = messageWithPills.attribute(.attachment, at: messageWithPills.length - 1, effectiveRange: nil) as? PillTextAttachment + // Pill is not highlighted. + XCTAssert(pillTextAttachment?.data?.isHighlighted == false) + // Attachment has correct type. + XCTAssert(pillTextAttachment?.fileType == PillsFormatter.pillUTType) + // Pill data contains the correct displayname and avatar url. + XCTAssertNotNil(pillTextAttachment?.data) + let pillTextAttachmentData: PillTextAttachmentData! = pillTextAttachment?.data + XCTAssertEqual(pillTextAttachmentData.displayText, Inputs.roomDisplayName) + switch pillTextAttachmentData.pillType { + case .room(let userId): + XCTAssertEqual(userId, Inputs.roomAlias) + switch pillTextAttachmentData.items.first { + case .avatar(let url, _, _): + XCTAssertEqual(url, Inputs.roomAvatarUrl) + default: + XCTFail("First pill item should be the avatar") + } + default: + XCTFail("Pill should be of type .room") + } + } + + // Test case: a mention to an unknown room + func testPillMentionningUnknownRoom() { + let messageWithPills = createMessageWithMentionToRoom(knownRoom: false) + let pillTextAttachment = messageWithPills.attribute(.attachment, at: messageWithPills.length - 1, effectiveRange: nil) as? PillTextAttachment + // Pill is not highlighted. + XCTAssert(pillTextAttachment?.data?.isHighlighted == false) + // Attachment has correct type. + XCTAssert(pillTextAttachment?.fileType == PillsFormatter.pillUTType) + // Pill data contains the correct displayname and avatar url. + XCTAssertNotNil(pillTextAttachment?.data) + let pillTextAttachmentData: PillTextAttachmentData! = pillTextAttachment?.data + XCTAssertEqual(pillTextAttachmentData.displayText, VectorL10n.pillRoomFallbackDisplayName) + switch pillTextAttachmentData.pillType { + case .room(let userId): + XCTAssertEqual(userId, Inputs.roomId) + switch pillTextAttachmentData.items.first { + case .asset(let assetName, let parameters): + XCTAssertEqual(assetName, "link_icon") + default: + XCTFail("First pill item should be the asset") + } + default: + XCTFail("Pill should be of type .room") + } + } + + // Test case: a mention to an unknown room using a formatted text (HTML or MARKDOWN) + func testPillMentionningUnknownRoomWithFormattedText() { + let messageWithPills = createMessageWithMentionToRoom(knownRoom: false, withLinkText: "Link label") + let pillTextAttachment = messageWithPills.attribute(.attachment, at: messageWithPills.length - 1, effectiveRange: nil) as? PillTextAttachment + XCTAssertNil(pillTextAttachment) + } + + // Test case: a mention to a message using a formatted text (HTML or MARKDOWN) + func testPillMentionningMessageWithLabel() { + let messageWithPills = createMessageWithMentionToMessage(from: Inputs.bobMember, withLabel: "Link label") + let pillTextAttachment = messageWithPills.attribute(.attachment, at: messageWithPills.length - 1, effectiveRange: nil) as? PillTextAttachment + XCTAssertNil(pillTextAttachment) + } + + // Test case: a mention to a message sent by a room member in the current room + func testPillMentionningMessageInCurrentRoomFromRoomMember() { + // Test: a mention to current room message, sent by a room member (Bob) + let messageWithPills = createMessageWithMentionToMessage(from: Inputs.bobMember, withLabel: Inputs.messagePermalink) + let pillTextAttachment = messageWithPills.attribute(.attachment, at: messageWithPills.length - 1, effectiveRange: nil) as? PillTextAttachment + // Pill is not highlighted. + XCTAssert(pillTextAttachment?.data?.isHighlighted == false) + // Attachment has correct type. + XCTAssert(pillTextAttachment?.fileType == PillsFormatter.pillUTType) + // Pill data contains the correct displayname and avatar url. + XCTAssertNotNil(pillTextAttachment?.data) + let pillTextAttachmentData: PillTextAttachmentData! = pillTextAttachment?.data + XCTAssertEqual(pillTextAttachmentData.displayText, Inputs.pillMessageFromBobText) + switch pillTextAttachmentData.pillType { + case .message(let roomId, let messageId): + XCTAssertEqual(roomId, Inputs.roomId) + XCTAssertEqual(messageId, Inputs.messageEventId) + let firstItem = pillTextAttachmentData.items[0] + switch firstItem { + case .avatar(let url, _, _): + XCTAssertEqual(url, Inputs.bobAvatarUrl) + default: + XCTFail("First pill item should be the avatar") + } + default: + XCTFail("Pill should be of type .message") + } + } + + // Test case: a mention to a message sent in the current room from an unknown user + func testPillMentionningMessageInCurrentRoomFromUnknownUser() { + let messageWithPills = createMessageWithMentionToMessage(sentBy: Inputs.anotherUserId, withLabel: Inputs.messagePermalink) + let pillTextAttachment = messageWithPills.attribute(.attachment, at: messageWithPills.length - 1, effectiveRange: nil) as? PillTextAttachment + // Pill is not highlighted. + XCTAssert(pillTextAttachment?.data?.isHighlighted == false) + // Attachment has correct type. + XCTAssert(pillTextAttachment?.fileType == PillsFormatter.pillUTType) + // Pill data contains the correct displayname and avatar url. + XCTAssertNotNil(pillTextAttachment?.data) + let pillTextAttachmentData: PillTextAttachmentData! = pillTextAttachment?.data + XCTAssertEqual(pillTextAttachmentData.displayText, VectorL10n.pillMessage) + + switch pillTextAttachmentData.pillType { + case .message(let roomId, let messageId): + XCTAssertEqual(roomId, Inputs.roomId) + XCTAssertEqual(messageId, Inputs.messageEventId) + let firstItem = pillTextAttachmentData.items[0] + switch firstItem { + case .asset(let name, _): + XCTAssertEqual(name, "link_icon") + default: + XCTFail("First pill item should be the asset") + } + default: + XCTFail("Pill should be of type .message") + } + } + + // Test case: a mention to a message in another room + func testPillMentionningMessageInAnotherRoom() { + let messageWithPills = createMessageWithMentionToAnotherRoomMessage(knownRoom: true, withLabel: Inputs.messageAnotherRoomPermalink) + let pillTextAttachment = messageWithPills.attribute(.attachment, at: messageWithPills.length - 1, effectiveRange: nil) as? PillTextAttachment + // Pill is not highlighted. + XCTAssert(pillTextAttachment?.data?.isHighlighted == false) + // Attachment has correct type. + XCTAssert(pillTextAttachment?.fileType == PillsFormatter.pillUTType) + // Pill data contains the correct displayname and avatar url. + XCTAssertNotNil(pillTextAttachment?.data) + let pillTextAttachmentData: PillTextAttachmentData! = pillTextAttachment?.data + XCTAssertEqual(pillTextAttachmentData.displayText, VectorL10n.pillMessageIn(Inputs.anotherRoomDisplayName)) + switch pillTextAttachmentData.pillType { + case .message(let roomId, let messageId): + XCTAssertEqual(roomId, Inputs.anotherRoomId) + XCTAssertEqual(messageId, Inputs.messageEventId) + switch pillTextAttachmentData.items.first { + case .avatar(let url, _, _): + XCTAssertEqual(url, Inputs.anotherRoomAvatarUrl) + default: + XCTFail("First pill item should be the avatar") + } + default: + XCTFail("Pill should be of type .message") + } + } + + // Test case: a mention to a message in an unknown room + func testPillMentionningMessageInUnknownRoom() { + let messageWithPills = createMessageWithMentionToAnotherRoomMessage(knownRoom: false, withLabel: Inputs.messageAnotherRoomPermalink) + let pillTextAttachment = messageWithPills.attribute(.attachment, at: messageWithPills.length - 1, effectiveRange: nil) as? PillTextAttachment + // Pill is not highlighted. + XCTAssert(pillTextAttachment?.data?.isHighlighted == false) + // Attachment has correct type. + XCTAssert(pillTextAttachment?.fileType == PillsFormatter.pillUTType) + // Pill data contains the correct displayname and avatar url. + XCTAssertNotNil(pillTextAttachment?.data) + let pillTextAttachmentData: PillTextAttachmentData! = pillTextAttachment?.data + XCTAssertEqual(pillTextAttachmentData.displayText, VectorL10n.pillMessage) + switch pillTextAttachmentData.pillType { + case .message(let roomId, let messageId): + XCTAssertEqual(roomId, Inputs.anotherRoomId) + XCTAssertEqual(messageId, Inputs.messageEventId) + switch pillTextAttachmentData.items.first { + case .asset(let name, let parameters): + XCTAssertEqual(name, "link_icon") + default: + XCTFail("First pill item should be the asset") + } + default: + XCTFail("Pill should be of type .message") + } + } } @available(iOS 15.0, *) @@ -105,6 +461,24 @@ private extension PillsFormatterTests { andLatestRoomState: nil) return messageWithPills } + + func createMessageWithMentionFromBobToAnotherUser(withLinkText: Bool = false) -> NSAttributedString { + let formattedMessage = NSMutableAttributedString(string: Inputs.messageStart) + if withLinkText { + formattedMessage.append(Inputs.mentionToAnotherUserWithLabel) + } else { + formattedMessage.append(Inputs.mentionToAnotherUser) + } + + let session = FakeMXSession(myUserId: Inputs.aliceMember.userId) + let messageWithPills = PillsFormatter.insertPills(in: formattedMessage, + withSession: session, + eventFormatter: EventFormatter(matrixSession: session), + event: FakeMXEvent(sender: Inputs.anotherUserId), + roomState: FakeMXRoomState(roomMembers: FakeMXRoomMembers()), + andLatestRoomState: nil) + return messageWithPills + } func createMessageWithMentionFromBobToAliceWithLatestRoomState() -> NSAttributedString { let formattedMessage = NSMutableAttributedString(string: Inputs.messageStart) @@ -118,35 +492,269 @@ private extension PillsFormatterTests { andLatestRoomState: FakeMXRoomState(roomMembers: FakeMXUpdatedRoomMembers())) return messageWithPills } + + func createMessageWithMentionToRoom(isSpace: Bool = false, knownRoom: Bool = true, usingAlias: Bool = false, withLinkText: String? = nil) -> NSAttributedString { + let formattedMessage = NSMutableAttributedString(string: Inputs.messageStart) + let mention: NSAttributedString + if usingAlias { + mention = NSAttributedString(string: withLinkText ?? Inputs.roomAliasPermalink , attributes: [.link: URL(string: Inputs.roomAliasPermalink)!]) + } else { + mention = NSAttributedString(string: withLinkText ?? Inputs.roomPermalink , attributes: [.link: URL(string: Inputs.roomPermalink)!]) + } + formattedMessage.append(mention) + let session = FakeMXSession(myUserId: Inputs.aliceMember.userId) + let event = FakeMXEvent(eventId: Inputs.messageEventId, sender: Inputs.bobMember.userId) + session.store = FakeMXStore(withEvents: [event]) + if knownRoom { + let room = FakeMXRoom(roomId: Inputs.roomId, matrixSession: session, andStore: nil)! + let roomSummary = FakeMXRoomSummary(roomId: Inputs.roomId, + displayName: Inputs.roomDisplayName, + alias: Inputs.roomAlias, + avatar: Inputs.roomAvatarUrl, + matrixSession: session) + if isSpace { + roomSummary.roomType = .space + } + session.addFakeRoom(room) + session.addFakeRoomSummary(roomSummary) + } + + let messageWithPills = PillsFormatter.insertPills(in: formattedMessage, + withSession: session, + eventFormatter: EventFormatter(matrixSession: session), + event: event, + roomState: FakeMXRoomState(roomMembers: FakeMXRoomMembers()), + andLatestRoomState: nil) + return messageWithPills + } + + func createMessageWithMentionToMessage(from sender: MXRoomMember, withLabel string: String) -> NSAttributedString { + let formattedMessage = NSMutableAttributedString(string: Inputs.messageStart) + formattedMessage.append(NSAttributedString(string: string, attributes: [.link: URL(string: Inputs.messagePermalink)!])) + let session = FakeMXSession(myUserId: Inputs.aliceMember.userId) + let event = FakeMXEvent(eventId: Inputs.messageEventId, sender: sender.userId) + session.store = FakeMXStore(withEvents: [event]) + let room = FakeMXRoom(roomId: Inputs.roomId, matrixSession: session, andStore: nil)! + let roomSummary = FakeMXRoomSummary(roomId: Inputs.roomId, + displayName: Inputs.roomDisplayName, + alias: Inputs.roomAlias, + avatar: Inputs.roomAvatarUrl, + matrixSession: session) + session.addFakeRoom(room) + session.addFakeRoomSummary(roomSummary) + + + let messageWithPills = PillsFormatter.insertPills(in: formattedMessage, + withSession: session, + eventFormatter: EventFormatter(matrixSession: session), + event: event, + roomState: FakeMXRoomState(roomMembers: FakeMXRoomMembers(), roomId: Inputs.roomId), + andLatestRoomState: nil) + return messageWithPills + } + + func createMessageWithMentionToMessage(sentBy senderId: String, withLabel string: String) -> NSAttributedString { + let formattedMessage = NSMutableAttributedString(string: Inputs.messageStart) + formattedMessage.append(NSAttributedString(string: string, attributes: [.link: URL(string: Inputs.messagePermalink)!])) + let session = FakeMXSession(myUserId: Inputs.aliceMember.userId) + let event = FakeMXEvent(eventId: Inputs.messageEventId, sender: senderId) + session.store = FakeMXStore(withEvents: [event]) + let room = FakeMXRoom(roomId: Inputs.roomId, matrixSession: session, andStore: nil)! + let roomSummary = FakeMXRoomSummary(roomId: Inputs.roomId, + displayName: Inputs.roomDisplayName, + alias: Inputs.roomAlias, + avatar: Inputs.roomAvatarUrl, + matrixSession: session) + session.addFakeRoom(room) + session.addFakeRoomSummary(roomSummary) + + + let messageWithPills = PillsFormatter.insertPills(in: formattedMessage, + withSession: session, + eventFormatter: EventFormatter(matrixSession: session), + event: event, + roomState: FakeMXRoomState(roomMembers: FakeMXRoomMembers(), roomId: Inputs.roomId), + andLatestRoomState: nil) + return messageWithPills + } + + func createMessageWithMentionToAnotherRoomMessage(knownRoom: Bool, withLabel string: String) -> NSAttributedString { + let formattedMessage = NSMutableAttributedString(string: Inputs.messageStart) + formattedMessage.append(NSAttributedString(string: string, attributes: [.link: URL(string: Inputs.messageAnotherRoomPermalink)!])) + let session = FakeMXSession(myUserId: Inputs.aliceMember.userId) + let event = FakeMXEvent(eventId: Inputs.messageEventId, sender: Inputs.anotherUserId) + session.store = FakeMXStore(withEvents: [event]) + if knownRoom { + let room = FakeMXRoom(roomId: Inputs.anotherRoomId, matrixSession: session, andStore: nil)! + let roomSummary = FakeMXRoomSummary(roomId: Inputs.anotherRoomId, + displayName: Inputs.anotherRoomDisplayName, + alias: nil, + avatar: Inputs.anotherRoomAvatarUrl, + matrixSession: session) + session.addFakeRoom(room) + session.addFakeRoomSummary(roomSummary) + } + + let messageWithPills = PillsFormatter.insertPills(in: formattedMessage, + withSession: session, + eventFormatter: EventFormatter(matrixSession: session), + event: event, + roomState: FakeMXRoomState(roomMembers: FakeMXRoomMembers(), roomId: Inputs.roomId), + andLatestRoomState: nil) + return messageWithPills + } + } // MARK: - Mock objects private class FakeMXSession: MXSession { private var mockMyUserId: String - + private var mockRooms: [FakeMXRoom] = [] + private var mockRoomSummaries: [String: FakeMXRoomSummary] = [:] + private var mockStore: FakeMXStore? + init(myUserId: String) { mockMyUserId = myUserId - - super.init() + let credentials = MXCredentials(homeServer: "mock_home_server", + userId: "mock_user_id", + accessToken: "mock_access_token") + let client = MXRestClient(credentials: credentials) + super.init(matrixRestClient: client) } override var myUserId: String! { return mockMyUserId } + + func addFakeRoom(_ room: FakeMXRoom) { + mockRooms.append(room) + } + + override func room(withRoomId roomId: String!) -> MXRoom! { + return mockRooms.first(where: { $0.roomId == roomId }) + } + + override func room(withAlias roomAlias: String) -> MXRoom? { + for (roomId, summary) in mockRoomSummaries { + if summary.aliases.contains(roomAlias) { + return room(withRoomId: roomId) + } + } + return nil + } + + override func roomSummary(withRoomId roomId: String!) -> MXRoomSummary? { + return mockRoomSummaries[roomId] + } + + func addFakeRoomSummary(_ roomSummary: FakeMXRoomSummary) { + self.mockRoomSummaries[roomSummary.roomId] = roomSummary + } + + override var store: MXStore! { + get { return mockStore } + set { mockStore = newValue as? FakeMXStore } + } +} + +private class FakeMXStore: MXMemoryStore { + private var mockEvents: [MXEvent] + + init(withEvents events: [MXEvent]) { + self.mockEvents = events + super.init() + } + + override func event(withEventId eventId: String, inRoom roomId: String) -> MXEvent? { + return mockEvents.first(where: { $0.eventId == eventId }) + } +} + +private class FakeMXRoom: MXRoom { + private var mockDisplayName: String? = nil + + override init() { + super.init() + } + + override init!(roomId: String!, matrixSession mxSession: MXSession!, andStore store: MXStore!) { + super.init(roomId: roomId, matrixSession: mxSession, andStore: store) + } + + override var summary: MXRoomSummary! { + return mxSession?.roomSummary(withRoomId: self.roomId) + } +} + +private class FakeMXRoomSummary: MXRoomSummary { + private var mockDisplayName: String? + private var mockAliases: [String]? + private var mockAvatar: String? = nil + + override init() { + super.init() + } + + init(roomId: String, displayName: String, alias: String?, avatar: String?, matrixSession mxSession: MXSession) { + super.init(roomId: roomId, andMatrixSession: mxSession) + self.mockDisplayName = displayName + self.mockAliases = alias.flatMap { [$0] } ?? [] + self.mockAvatar = avatar + } + + override init!(roomId: String!, matrixSession mxSession: MXSession!, andStore store: MXStore!) { + super.init(roomId: roomId, matrixSession: mxSession, andStore: store) + } + + override init!(roomId: String!, andMatrixSession mxSession: MXSession!) { + super.init(roomId: roomId, andMatrixSession: mxSession) + } + + required init?(coder: NSCoder) { + fatalError() + } + + override var displayName: String! { + get { return mockDisplayName } + set { mockDisplayName = newValue } + } + + override var avatar: String! { + get { return mockAvatar } + set { mockAvatar = newValue } + } + + override var aliases: [String]! { + get { return mockAliases } + set { mockAliases = newValue } + } } private class FakeMXRoomState: MXRoomState { private let mockRoomMembers: MXRoomMembers + private let mockRoomId: String? init(roomMembers: MXRoomMembers) { mockRoomMembers = roomMembers + mockRoomId = nil super.init() } + + init(roomMembers: MXRoomMembers, roomId: String) { + mockRoomMembers = roomMembers + mockRoomId = roomId + + super.init() + } override var members: MXRoomMembers! { return mockRoomMembers } + + override var roomId: String! { + return mockRoomId + } } private class FakeMXUpdatedRoomMembers: MXRoomMembers { @@ -202,12 +810,21 @@ private class FakeMXRoomMember: MXRoomMember { private class FakeMXEvent: MXEvent { private var mockSender: String + private var mockEventId: String? init(sender: String) { mockSender = sender + mockEventId = nil super.init() } + + init(eventId: String, sender: String) { + mockEventId = eventId + mockSender = sender + + super.init() + } required init?(coder: NSCoder) { fatalError() @@ -217,4 +834,9 @@ private class FakeMXEvent: MXEvent { get { return mockSender } set { mockSender = newValue } } + + override var eventId: String! { + get { return mockEventId } + set { mockEventId = newValue } + } } diff --git a/changelog.d/7409.change b/changelog.d/7409.change new file mode 100644 index 0000000000..648bc7c624 --- /dev/null +++ b/changelog.d/7409.change @@ -0,0 +1 @@ +Permalinks to a room/space are pillified diff --git a/changelog.d/7411.change b/changelog.d/7411.change new file mode 100644 index 0000000000..e7862bb5fe --- /dev/null +++ b/changelog.d/7411.change @@ -0,0 +1 @@ +Permalinks to a matrix user are pillified diff --git a/changelog.d/7412.change b/changelog.d/7412.change new file mode 100644 index 0000000000..f542012bc1 --- /dev/null +++ b/changelog.d/7412.change @@ -0,0 +1 @@ +Permalinks to messages are pillified