Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Turning permalinks into pills #7432

Merged
merged 10 commits into from
Mar 21, 2023
6 changes: 6 additions & 0 deletions Riot/Assets/en.lproj/Vector.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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 %@";
16 changes: 16 additions & 0 deletions Riot/Generated/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
31 changes: 30 additions & 1 deletion Riot/Modules/MatrixKit/Utils/MXKTools.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -47,6 +51,8 @@
// A regex to find all HTML tags
static NSRegularExpression *htmlTagsRegex;
static NSDataDetector *linkDetector;
// A regex to detect permalinks
static NSRegularExpression* permalinkRegex;

@implementation MXKTools

Expand All @@ -63,6 +69,9 @@ + (void)initialize
httpLinksRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)\\b(https?://\\S*)\\b" options:NSRegularExpressionCaseInsensitive error:nil];
htmlTagsRegex = [NSRegularExpression regularExpressionWithPattern:@"<(\\w+)[^>]*>" options:NSRegularExpressionCaseInsensitive error:nil];
linkDetector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeLink error:nil];

NSString *permalinkPattern = [NSString stringWithFormat:@"%@%@", BuildSettings.clientPermalinkBaseUrl ?: kMXMatrixDotToUrl, kMXKToolsRegexStringForPermalink];
permalinkRegex = [NSRegularExpression regularExpressionWithPattern:permalinkPattern options:NSRegularExpressionCaseInsensitive error:nil];
});
}

Expand Down Expand Up @@ -1039,10 +1048,30 @@ + (void)createLinksInMutableAttributedString:(NSMutableAttributedString*)mutable
{
[MXKTools createLinksInMutableAttributedString:mutableAttributedString matchingRegex:eventIdRegex];
}

// Permalinks
NSArray* matches = [httpLinksRegex matchesInString: [mutableAttributedString string] options:0 range: NSMakeRange(0,mutableAttributedString.length)];
if (matches) {
for (NSTextCheckingResult *match in matches)
{
NSRange matchRange = [match range];

NSString *link = [mutableAttributedString.string substringWithRange:matchRange];
// Handle potential permalinks
if ([permalinkRegex numberOfMatchesInString:link options:0 range:NSMakeRange(0, link.length)]) {
MXLogDebug(@"[MXKTools] Permalink detected: %@", link);
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)
Expand Down
143 changes: 112 additions & 31 deletions Riot/Modules/Pills/PillAttachmentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,18 @@ 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
}
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
}
}

Expand All @@ -56,44 +55,126 @@ 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
stack.translatesAutoresizingMaskIntoConstraints = false

let pillBackgroundView = UIView(frame: CGRect(x: 0,
y: sizes.verticalMargin,
width: labelSize.width + sizes.totalWidthWithoutLabel,
height: sizes.pillBackgroundHeight))
var computedWidth: CGFloat = 0
for item in pillData.items {
switch item {
case .text(let string):
let label = UILabel(frame: .zero)
label.text = string
label.font = pillData.font
label.textColor = pillData.isHighlighted ? theme.baseTextPrimaryColor : theme.textPrimaryColor
label.translatesAutoresizingMaskIntoConstraints = false
label.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
stack.addArrangedSubview(label)

computedWidth += label.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, height: sizes.pillBackgroundHeight)).width

let avatarView = UserAvatarView(frame: CGRect(x: sizes.horizontalMargin,
y: sizes.verticalMargin,
width: sizes.avatarSideLength,
height: sizes.avatarSideLength))
case .avatar(let url, let alt, let matrixId):
let avatarView = UserAvatarView(frame: CGRect(origin: .zero, size: CGSize(width: sizes.avatarSideLength, height: sizes.avatarSideLength)))

avatarView.fill(with: AvatarViewData(matrixItemId: pillData.matrixItemId,
displayName: pillData.displayName,
avatarUrl: pillData.avatarUrl,
mediaManager: mediaManager,
fallbackImage: .matrixItem(pillData.matrixItemId, pillData.displayName)))
avatarView.isUserInteractionEnabled = false
avatarView.fill(with: AvatarViewData(matrixItemId: matrixId,
displayName: alt,
avatarUrl: url,
mediaManager: mediaManager,
fallbackImage: .matrixItem(matrixId, alt)))
avatarView.isUserInteractionEnabled = false
avatarView.translatesAutoresizingMaskIntoConstraints = false
stack.addArrangedSubview(avatarView)
NSLayoutConstraint.activate([
avatarView.widthAnchor.constraint(equalToConstant: sizes.avatarSideLength),
avatarView.heightAnchor.constraint(equalToConstant: sizes.avatarSideLength)
])

computedWidth += sizes.avatarSideLength

case .spaceAvatar(let url, let alt, let matrixId):
let avatarView = SpaceAvatarView(frame: CGRect(origin: .zero, size: CGSize(width: sizes.avatarSideLength, height: sizes.avatarSideLength)))

pillBackgroundView.addSubview(avatarView)
pillBackgroundView.addSubview(label)
avatarView.fill(with: AvatarViewData(matrixItemId: matrixId,
displayName: alt,
avatarUrl: url,
mediaManager: mediaManager,
fallbackImage: .matrixItem(matrixId, alt)))
avatarView.isUserInteractionEnabled = false
avatarView.translatesAutoresizingMaskIntoConstraints = false
stack.addArrangedSubview(avatarView)
NSLayoutConstraint.activate([
avatarView.widthAnchor.constraint(equalToConstant: sizes.avatarSideLength),
avatarView.heightAnchor.constraint(equalToConstant: sizes.avatarSideLength)
])

computedWidth += sizes.avatarSideLength

case .asset(let name):
let assetView = UIView(frame: CGRect(x: 0, y: 0, width: sizes.avatarSideLength, height: sizes.avatarSideLength))
assetView.backgroundColor = theme.colors.links
assetView.layer.cornerRadius = sizes.avatarSideLength / 2
assetView.isUserInteractionEnabled = false
assetView.translatesAutoresizingMaskIntoConstraints = false

let imageView = UIImageView(frame: .zero)
imageView.image = ImageAsset(name: name).image.withRenderingMode(.alwaysTemplate)
imageView.tintColor = theme.baseIconPrimaryColor
imageView.contentMode = .scaleAspectFit
imageView.translatesAutoresizingMaskIntoConstraints = false

assetView.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.leadingAnchor.constraint(equalTo: assetView.leadingAnchor, constant: 2),
imageView.trailingAnchor.constraint(equalTo: assetView.trailingAnchor, constant: -2),
imageView.topAnchor.constraint(equalTo: assetView.topAnchor, constant: 2),
imageView.bottomAnchor.constraint(equalTo: assetView.bottomAnchor, constant: -2)
])

stack.addArrangedSubview(assetView)
NSLayoutConstraint.activate([
assetView.widthAnchor.constraint(equalToConstant: sizes.avatarSideLength),
assetView.heightAnchor.constraint(equalToConstant: sizes.avatarSideLength)
])

computedWidth += sizes.avatarSideLength
}
}
computedWidth += max(0, CGFloat(stack.arrangedSubviews.count - 1) * stack.spacing)

let leadingStackMargin: CGFloat
switch pillData.items.first {
case .asset, .avatar:
leadingStackMargin = sizes.avatarLeading
computedWidth += sizes.avatarLeading + sizes.horizontalMargin
default:
leadingStackMargin = sizes.horizontalMargin
computedWidth += 2 * sizes.horizontalMargin
}

let pillBackgroundView = UIView(frame: CGRect(x: 0,
y: sizes.verticalMargin,
width: computedWidth,
height: sizes.pillBackgroundHeight))

pillBackgroundView.addSubview(stack)

NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: pillBackgroundView.leadingAnchor, constant: leadingStackMargin),
stack.trailingAnchor.constraint(equalTo: pillBackgroundView.trailingAnchor, constant: -sizes.horizontalMargin),
stack.topAnchor.constraint(equalTo: pillBackgroundView.topAnchor, constant: sizes.verticalMargin),
stack.bottomAnchor.constraint(equalTo: pillBackgroundView.bottomAnchor, constant: -sizes.verticalMargin)
])
aringenbach marked this conversation as resolved.
Show resolved Hide resolved

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 {
Expand Down
31 changes: 6 additions & 25 deletions Riot/Modules/Pills/PillAttachmentViewProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: 20.0,
itemSpacing: 4)
private weak var messageTextView: MXKMessageTextView?

// MARK: - Override
Expand All @@ -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,
Expand All @@ -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)
}
}
Loading