diff --git a/Riot/Categories/NSAttributedString.swift b/Riot/Categories/NSAttributedString.swift index 65c2db8a7..033410b06 100644 --- a/Riot/Categories/NSAttributedString.swift +++ b/Riot/Categories/NSAttributedString.swift @@ -24,4 +24,24 @@ public extension NSAttributedString { result.removeAttribute(.link, range: NSRange(location: 0, length: length)) return result } + + /// Enumerate attribute for given key and conveniently ignore any attribute that doesn't match given generic type. + /// + /// - Parameters: + /// - attrName: The name of the attribute to enumerate. + /// - enumerationRange: The range over which the attribute values are enumerated. + /// - opts: The options used by the enumeration. For possible values, see NSAttributedStringEnumerationOptions. + /// - block: The block to apply to ranges of the specified attribute in the attributed string. + func vc_enumerateAttribute(_ attrName: NSAttributedString.Key, + in enumerationRange: NSRange, + options opts: NSAttributedString.EnumerationOptions = [], + using block: (T, NSRange, UnsafeMutablePointer) -> Void) { + self.enumerateAttribute(attrName, + in: enumerationRange, + options: opts) { (attr: Any?, range: NSRange, stop: UnsafeMutablePointer) in + guard let typedAttr = attr as? T else { return } + + block(typedAttr, range, stop) + } + } } diff --git a/Riot/Modules/Application/AppDelegate.swift b/Riot/Modules/Application/AppDelegate.swift index 5ab09997b..c05e42f15 100644 --- a/Riot/Modules/Application/AppDelegate.swift +++ b/Riot/Modules/Application/AppDelegate.swift @@ -59,6 +59,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let window = UIWindow(frame: UIScreen.main.bounds) self.window = window + // Register pills provider. + if #available(iOS 15.0, *) { + NSTextAttachment.registerViewProviderClass(PillTextAttachmentProvider.self, forFileType: "im.vector.app.pills") + } + // Create AppCoordinator self.rootRouter = RootRouter(window: window) diff --git a/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.swift b/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.swift index 2807c3f49..dfb18eee8 100644 --- a/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.swift +++ b/Riot/Modules/MatrixKit/Categories/UITextView+MatrixKit.swift @@ -18,8 +18,8 @@ import UIKit extension UITextView { private enum Constants { - /// Distance threshold at which link can still be considered as "near" from location. - static let linkHorizontalDistanceThreshold: CGFloat = 16.0 + /// Distance threshold at which linkified text attachment can still be considered as "near" location. + static let attachmentLinkHorizontalDistanceThreshold: CGFloat = 16.0 } /// Determine if there is a link near a location point in UITextView bounds. @@ -27,40 +27,63 @@ extension UITextView { /// - Parameters: /// - point: The point inside the UITextView bounds /// - Returns: true to indicate that a link has been detected near the location point. - @objc func isThereALinkNearPoint(_ point: CGPoint) -> Bool { + @objc func isThereALinkNearLocation(_ point: CGPoint) -> Bool { + return urlForLinkAtLocation(point) != nil + } + + /// Detect link near a location point in UITextView bounds. + /// + /// - Parameter point: The point inside the UITextView bounds + /// - Returns: link detected at given location + @objc func urlForLinkAtLocation(_ point: CGPoint) -> URL? { guard bounds.contains(point), let textPosition = closestPosition(to: point) else { - return false + return nil } - + + // The value of `NSLinkAttributeName` attribute could be an URL or a String object. + func attributeToLink(_ attribute: Any) -> URL? { + if let link = attribute as? URL { + return link + } else if let stringURL = attribute as? String { + return URL(string: stringURL) + } else { + return nil + } + } + // Depending on cursor position on a character containing both an attachment // and a link (e.g. a mention pill), a positive result can be retrieved either // from textStylingAtPosition or tokenizer's rangeEnclosingPosition. if let attributes = textStyling(at: textPosition, in: .forward), - attributes[.link] != nil { + let linkAttribute = attributes[.link] { // Using textStyling shouldn't provide false positives. - return true + return attributeToLink(linkAttribute) } else if let textRange = tokenizer.rangeEnclosingPosition(textPosition, with: .character, inDirection: .layout(.left)) { let startIndex = offset(from: beginningOfDocument, to: textRange.start) - if attributedText.attribute(.link, at: startIndex, effectiveRange: nil) != nil { + if let linkAttribute = attributedText.attribute(.link, at: startIndex, effectiveRange: nil) { // Fix false positives from tokenizer's rangeEnclosingPosition. // These occur if given point is located on the same line as a - // trailing character containing a mention pill. Detected link is + // trailing linkified text attachment. Detected link is // rejected if actual distance from attachment trailing to point // is greater than linkHorizontalDistanceThreshold. let glyphIndex = layoutManager.glyphIndexForCharacter(at: startIndex) let attachmentWidth = layoutManager.attachmentSize(forGlyphAt: glyphIndex).width - let glyphStartX = layoutManager.location(forGlyphAt: glyphIndex).x - let distance = point.x - (glyphStartX + attachmentWidth) - - // TODO: improve using a range perhaps (beware of negative values when no attachment) - return distance < Constants.linkHorizontalDistanceThreshold + // Width is -1 when there is no attachment. + if attachmentWidth > 0 { + let glyphStartX = layoutManager.location(forGlyphAt: glyphIndex).x + let start = glyphStartX - Constants.attachmentLinkHorizontalDistanceThreshold + let end = glyphStartX + attachmentWidth + Constants.attachmentLinkHorizontalDistanceThreshold + let range = (start...end) + + return range.contains(point.x) ? attributeToLink(linkAttribute) : nil + } } } - return false + return nil } } diff --git a/Riot/Modules/MatrixKit/Views/MXKMessageTextView.m b/Riot/Modules/MatrixKit/Views/MXKMessageTextView.m index 416e2ac65..d6a0b75bb 100644 --- a/Riot/Modules/MatrixKit/Views/MXKMessageTextView.m +++ b/Riot/Modules/MatrixKit/Views/MXKMessageTextView.m @@ -51,7 +51,7 @@ return NO; } - return [self isThereALinkNearPoint:point]; + return [self isThereALinkNearLocation:point]; } @end diff --git a/Riot/Modules/Pills/PillSnapshoter.swift b/Riot/Modules/Pills/PillSnapshoter.swift index 915c6cdbf..46c2d8fc8 100644 --- a/Riot/Modules/Pills/PillSnapshoter.swift +++ b/Riot/Modules/Pills/PillSnapshoter.swift @@ -16,72 +16,108 @@ import Foundation -// TODO: replace this with directly creating NSAttributedString with both link and attachment (removes weird interaction between two objects here) @objcMembers class PillTextAttachment: NSTextAttachment { - convenience init(withSession session: MXSession, url: NSURL, andRoomMember roomMember: MXRoomMember) { - self.init() + var roomMember: MXRoomMember? + var alpha: CGFloat = 1.0 + + convenience init(withRoomMember roomMember: MXRoomMember) { + self.init(data: nil, ofType: "im.vector.app.pills") + + let image = PillSnapshoter.snapshotView(forRoomMember: roomMember) + + self.roomMember = roomMember - let image = PillSnapshoter.snapshot(withSession: session, andRoomMember: roomMember) - self.image = image // FIXME: handle vertical offset better - self.bounds = CGRect(x: 0.0, y: -5.0, width: image.size.width * 0.3, height: image.size.height * 0.3) + self.bounds = CGRect(x: 0.0, y: -6.0, width: image.frame.width, height: image.frame.height) } } @objcMembers class PillSnapshoter: NSObject { - static func mentionPill(withSession session: MXSession, url: NSURL, andRoomMember roomMember: MXRoomMember) -> NSAttributedString { - let attachment = PillTextAttachment(withSession: session, url: url, andRoomMember: roomMember) + private enum Constants { + static let commonVerticalMargin: CGFloat = 1.0 + static let commonHorizontalMargin: CGFloat = 4.0 + static let avatarSideLength: CGFloat = 16.0 + static let pillBackgroundHeight: CGFloat = avatarSideLength + 2 * commonVerticalMargin + static let displaynameLabelLeading: CGFloat = avatarSideLength + 2 * commonHorizontalMargin + static let pillHeight: CGFloat = pillBackgroundHeight + 2 * commonVerticalMargin + static let displaynameLabelTrailing: CGFloat = 1 * commonHorizontalMargin + static let totalWidthWithoutLabel: CGFloat = displaynameLabelLeading + displaynameLabelTrailing + } + + static func mentionPill(withRoomMember roomMember: MXRoomMember, andUrl url: URL) -> NSAttributedString { + let attachment = PillTextAttachment(withRoomMember: roomMember) let string = NSAttributedString(attachment: attachment) let mutable = NSMutableAttributedString(attributedString: string) mutable.addAttribute(.link, value: url, range: .init(location: 0, length: mutable.length)) return mutable } - static func snapshot(withSession session: MXSession, andRoomMember roomMember: MXRoomMember) -> UIImage { - let view = snapshotView(withSession: session, andRoomMember: roomMember) - let rect: CGRect = view.frame - - UIGraphicsBeginImageContext(rect.size) - let context: CGContext = UIGraphicsGetCurrentContext()! - view.layer.render(in: context) - let img = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - - return img! - } - - // TODO: Improve how image scale is handled - // TODO: Implement a solution with image cache to increase performance - private static func snapshotView(withSession session: MXSession, andRoomMember roomMember: MXRoomMember) -> UIView { + static func snapshotView(forRoomMember roomMember: MXRoomMember) -> UIView { let label = UILabel(frame: .zero) label.text = roomMember.displayname - label.font = ThemeService.shared().theme.fonts.body.withSize(ThemeService.shared().theme.fonts.body.pointSize * 2.0) + label.font = ThemeService.shared().theme.fonts.body.withSize(ThemeService.shared().theme.fonts.body.pointSize * 0.7) label.textColor = ThemeService.shared().theme.textPrimaryColor let labelSize = label.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)) - label.frame = CGRect(x: 52 + 16, y: 0, width: labelSize.width, height: 60) + label.frame = CGRect(x: Constants.displaynameLabelLeading, + y: 0, + width: labelSize.width, + height: Constants.pillBackgroundHeight) - let view = UIView(frame: CGRect(x: 0, y: 0, width: labelSize.width + 16 + 52 + 16, height: 60)) + let view = UIView(frame: CGRect(x: 0, + y: Constants.commonVerticalMargin, + width: labelSize.width + Constants.totalWidthWithoutLabel, + height: Constants.pillBackgroundHeight)) - // FIXME: handle avatar not being in cache at snapshot time - let imageView = MXKImageView(frame: CGRect(x: 8, y: 4, width: 60, height: 60)) + let imageView = MXKImageView(frame: CGRect(x: Constants.commonHorizontalMargin, + y: Constants.commonVerticalMargin, + width: Constants.avatarSideLength, + height: Constants.avatarSideLength)) imageView.setImageURI(roomMember.avatarUrl, withType: nil, andImageOrientation: .up, toFitViewSize: imageView.frame.size, with: MXThumbnailingMethodCrop, previewImage: Asset.Images.userIcon.image, - mediaManager: session.mediaManager) + // Pills rely only on cached images since `MXKImageView` image loading + // is not handled properly for a `NSTextAttachment` view. + mediaManager: nil) imageView.clipsToBounds = true - imageView.frame = CGRect(x: 8, y: 4, width: 52, height: 52) - imageView.layer.cornerRadius = 26.0 + imageView.layer.cornerRadius = Constants.avatarSideLength / 2.0 view.addSubview(imageView) view.addSubview(label) view.backgroundColor = ThemeService.shared().theme.secondaryCircleButtonBackgroundColor - view.layer.cornerRadius = 30 + view.layer.cornerRadius = Constants.pillBackgroundHeight / 2.0 - return view + let pillView = UIView(frame: CGRect(x: 0, + y: 0, + width: labelSize.width + Constants.totalWidthWithoutLabel, + height: Constants.pillHeight)) + pillView.addSubview(view) + + return pillView + } +} + + +@available(iOS 15.0, *) +@objc class PillTextAttachmentProvider: NSTextAttachmentViewProvider { + override func loadView() { + super.loadView() + + guard let textAttachment = self.textAttachment as? PillTextAttachment else { + MXLog.debug("[PillTextAttachmentProvider]: attachment is not of correct class") + return + } + + guard let roomMember = textAttachment.roomMember else { + MXLog.debug("[PillTextAttachmentProvider]: attachment misses room member") + return + } + + view = PillSnapshoter.snapshotView(forRoomMember: roomMember) + view?.alpha = textAttachment.alpha } } diff --git a/Riot/Modules/Pills/StringPillsUtils.swift b/Riot/Modules/Pills/StringPillsUtils.swift index fa7324710..dfa6363b9 100644 --- a/Riot/Modules/Pills/StringPillsUtils.swift +++ b/Riot/Modules/Pills/StringPillsUtils.swift @@ -16,6 +16,7 @@ import Foundation +@available (iOS 15.0, *) @objcMembers class StringPillsUtils: NSObject { // MARK: - Private Constants @@ -26,8 +27,7 @@ class StringPillsUtils: NSObject { // MARK: - Internal Methods static func insertPills(in attributedString: NSAttributedString, - withSession session: MXSession, - andRoomState roomState: MXRoomState) -> NSAttributedString { + withRoomState roomState: MXRoomState) -> NSAttributedString { // TODO: Improve algorithm & cleanup this method let newAttr = NSMutableAttributedString(attributedString: attributedString) var lastIndex: Int = 0 @@ -53,7 +53,7 @@ class StringPillsUtils: NSObject { lastIndex += linkRange.length continue } - let attachmentString = PillSnapshoter.mentionPill(withSession: session, url: url, andRoomMember: roomMember) + let attachmentString = PillSnapshoter.mentionPill(withRoomMember: roomMember, andUrl: url as URL) newAttr.replaceCharacters(in: linkRange, with: attachmentString) lastIndex += attachmentString.length } else { diff --git a/Riot/Modules/Room/CellData/RoomBubbleCellData.m b/Riot/Modules/Room/CellData/RoomBubbleCellData.m index 5a4e7b3ca..1a91903c2 100644 --- a/Riot/Modules/Room/CellData/RoomBubbleCellData.m +++ b/Riot/Modules/Room/CellData/RoomBubbleCellData.m @@ -433,6 +433,12 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat { // Apply alpha to blur this component componentString = [Tools setTextColorAlpha:.2 inAttributedString:componentString]; + [Tools setPillAlpha:.2 inAttributedString:componentString]; + } + else + { + // PillTextAttachment are not created again every time, we have to set alpha back to standard if needed. + [Tools setPillAlpha:1.f inAttributedString:componentString]; } // Check whether the timestamp is displayed for this component, and check whether a vertical whitespace is required @@ -472,6 +478,12 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat { // Apply alpha to blur this component componentString = [Tools setTextColorAlpha:.2 inAttributedString:componentString]; + [Tools setPillAlpha:.2 inAttributedString:componentString]; + } + else + { + // PillTextAttachment are not created again every time, we have to set alpha back to standard if needed. + [Tools setPillAlpha:1.f inAttributedString:componentString]; } // Check whether the timestamp is displayed diff --git a/Riot/Modules/Room/TimelineCells/Common/MXKRoomBubbleTableViewCell.m b/Riot/Modules/Room/TimelineCells/Common/MXKRoomBubbleTableViewCell.m index 54d7641b4..8f119b429 100644 --- a/Riot/Modules/Room/TimelineCells/Common/MXKRoomBubbleTableViewCell.m +++ b/Riot/Modules/Room/TimelineCells/Common/MXKRoomBubbleTableViewCell.m @@ -1391,23 +1391,7 @@ static NSMutableDictionary *childClasses; { UITextView *textView = self.messageTextView; CGPoint tapLocation = [sender locationInView:textView]; - UITextPosition *textPosition = [textView closestPositionToPoint:tapLocation]; - NSDictionary *attributes = [textView textStylingAtPosition:textPosition inDirection:UITextStorageDirectionForward]; - - // The value of `NSLinkAttributeName` attribute could be an NSURL or an NSString object. - id tappedURLObject = attributes[NSLinkAttributeName]; - - if (tappedURLObject) - { - if ([tappedURLObject isKindOfClass:[NSURL class]]) - { - tappedUrl = (NSURL*)tappedURLObject; - } - else if ([tappedURLObject isKindOfClass:[NSString class]]) - { - tappedUrl = [NSURL URLWithString:(NSString*)tappedURLObject]; - } - } + tappedUrl = [textView urlForLinkAtLocation:tapLocation]; } MXKRoomBubbleComponent *tappedComponent = [self closestBubbleComponentForGestureRecognizer:sender locationInView:sender.view]; @@ -1652,7 +1636,7 @@ static NSMutableDictionary *childClasses; UITextView *textView = (UITextView*)touchedView; CGPoint touchLocation = [touch locationInView:textView]; - return [textView isThereALinkNearPoint:touchLocation] == NO; + return [textView isThereALinkNearLocation:touchLocation] == NO; } } diff --git a/Riot/SupportingFiles/Info.plist b/Riot/SupportingFiles/Info.plist index 9d95fe929..e80dcf500 100644 --- a/Riot/SupportingFiles/Info.plist +++ b/Riot/SupportingFiles/Info.plist @@ -114,5 +114,33 @@ $(BASE_BUNDLE_IDENTIFIER) keychainAccessGroup $(KEYCHAIN_ACCESS_GROUP) + CFBundleDocumentTypes + + + CFBundleTypeName + Mention Pills + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + + im.vector.app.pills + + + + UTExportedTypeDeclarations + + + UTTypeConformsTo + + public.text + + UTTypeDescription + Mention Pills + UTTypeIdentifier + im.vector.app.pills + + diff --git a/Riot/Utils/EventFormatter.m b/Riot/Utils/EventFormatter.m index 08d195be9..555aa1355 100644 --- a/Riot/Utils/EventFormatter.m +++ b/Riot/Utils/EventFormatter.m @@ -86,9 +86,12 @@ static NSString *const kEventFormatterTimeFormat = @"HH:mm"; } } - if (event.eventType == MXEventTypeRoomMessage) + if (@available(iOS 15.0, *)) { - string = [StringPillsUtils insertPillsIn:string withSession:mxSession andRoomState:roomState]; + if (event.eventType == MXEventTypeRoomMessage) + { + string = [StringPillsUtils insertPillsIn:string withRoomState:roomState]; + } } return string; diff --git a/Riot/Utils/Tools.m b/Riot/Utils/Tools.m index f27ab4c33..77ae6837e 100644 --- a/Riot/Utils/Tools.m +++ b/Riot/Utils/Tools.m @@ -113,29 +113,4 @@ return fixedURL; } -#pragma mark - String utilities - -+ (NSAttributedString *)setTextColorAlpha:(CGFloat)alpha inAttributedString:(NSAttributedString*)attributedString -{ - NSMutableAttributedString *string = [[NSMutableAttributedString alloc] initWithAttributedString:attributedString]; - - // Check all attributes one by one - [string enumerateAttributesInRange:NSMakeRange(0, attributedString.length) options:0 usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) - { - // Replace only colored texts - if (attrs[NSForegroundColorAttributeName]) - { - UIColor *color = attrs[NSForegroundColorAttributeName]; - color = [color colorWithAlphaComponent:alpha]; - - NSMutableDictionary *newAttrs = [NSMutableDictionary dictionaryWithDictionary:attrs]; - newAttrs[NSForegroundColorAttributeName] = color; - - [string setAttributes:newAttrs range:range]; - } - }]; - - return string; -} - @end diff --git a/Riot/Utils/Tools.swift b/Riot/Utils/Tools.swift new file mode 100644 index 000000000..1dc840222 --- /dev/null +++ b/Riot/Utils/Tools.swift @@ -0,0 +1,51 @@ +// +// Copyright 2022 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 CoreGraphics +import Foundation + +extension Tools { + /// Creates a new attributed string with given alpha applied to all texts. + /// + /// - Parameters: + /// - alpha: Alpha value to apply + /// - attributedString: Attributed string to update + /// - Returns: New attributed string with updated alpha + @objc static func setTextColorAlpha(_ alpha: CGFloat, inAttributedString attributedString: NSAttributedString) -> NSAttributedString { + let totalRange = NSRange(location: 0, length: attributedString.length) + let mutableString = NSMutableAttributedString(attributedString: attributedString) + attributedString.vc_enumerateAttribute(.foregroundColor, + in: totalRange) { (color: UIColor, range: NSRange, _) in + let colorWithAlpha = color.withAlphaComponent(alpha) + mutableString.addAttribute(.foregroundColor, value: colorWithAlpha, range: range) + } + + return mutableString + } + + /// Update alpha of all `PillTextAttachment` contained in given attributed string. + /// + /// - Parameters: + /// - alpha: Alpha value to apply + /// - attributedString: Attributed string containing the pills + @objc static func setPillAlpha(_ alpha: CGFloat, inAttributedString attributedString: NSAttributedString) { + let totalRange = NSRange(location: 0, length: attributedString.length) + attributedString.vc_enumerateAttribute(.attachment, + in: totalRange) { (pill: PillTextAttachment, range: NSRange, _) in + pill.alpha = alpha + } + } +}