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
+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
}