Merge pull request #7432 from vector-im/nimau/PSB-59-pills

Turning permalinks into pills
This commit is contained in:
Nicolas Mauri
2023-03-21 14:36:54 +01:00
committed by GitHub
24 changed files with 1513 additions and 169 deletions
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+6
View File
@@ -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 %@";
+1
View File
@@ -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")
+16
View File
@@ -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")
+29 -1
View File
@@ -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 @@ static NSRegularExpression *httpLinksRegex;
// 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 @@ static NSDataDetector *linkDetector;
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 @@ manualChangeMessageForVideo:(NSString*)manualChangeMessageForVideo
{
[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)
+97 -31
View File
@@ -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
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
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: 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)))
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: labelSize.width + sizes.totalWidthWithoutLabel,
width: computedWidth,
height: sizes.pillBackgroundHeight))
let avatarView = UserAvatarView(frame: CGRect(x: sizes.horizontalMargin,
y: sizes.verticalMargin,
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
pillBackgroundView.addSubview(avatarView)
pillBackgroundView.addSubview(label)
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 {
@@ -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)
}
}
+296
View File
@@ -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))
}
}
+62 -4
View File
@@ -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)
+87 -32
View File
@@ -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))"
}
}
+76
View File
@@ -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..<result.numberOfRanges)
.map { Range(result.range(at: $0), in: link) }
.compactMap { $0 }
.map { String(link[$0]).removingPercentEncoding }
.compactMap { $0 }
}
.compactMap { matrixIds -> 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
}
}
+56 -60
View File
@@ -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
}
}
@@ -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
@@ -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
}
@@ -423,7 +423,7 @@
}
}];
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
+109
View File
@@ -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")!))
}
}
+636 -14
View File
@@ -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 }
}
}
+1
View File
@@ -0,0 +1 @@
Permalinks to a room/space are pillified
+1
View File
@@ -0,0 +1 @@
Permalinks to a matrix user are pillified
+1
View File
@@ -0,0 +1 @@
Permalinks to messages are pillified