From 5125cc3397c789e246b17819ba388510b3756025 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 18 Oct 2020 12:22:12 -0400 Subject: [PATCH] Show custom emojis in display names in follow/favorite/reblog notifications --- Tusker.xcodeproj/project.pbxproj | 8 ++ Tusker/Views/BaseEmojiLabel.swift | 83 +++++++++++++++++++ Tusker/Views/EmojiLabel.swift | 60 ++------------ Tusker/Views/MultiSourceEmojiLabel.swift | 50 +++++++++++ ...ActionNotificationGroupTableViewCell.swift | 36 ++++---- .../ActionNotificationGroupTableViewCell.xib | 21 +++-- ...FollowNotificationGroupTableViewCell.swift | 36 ++++---- .../FollowNotificationGroupTableViewCell.xib | 21 +++-- 8 files changed, 219 insertions(+), 96 deletions(-) create mode 100644 Tusker/Views/BaseEmojiLabel.swift create mode 100644 Tusker/Views/MultiSourceEmojiLabel.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 716e21fb..878cc652 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -185,6 +185,8 @@ D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; }; D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; }; D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* SearchViewController.swift */; }; + D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */; }; + D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */; }; D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; }; D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */; }; D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; }; @@ -523,6 +525,8 @@ D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = ""; }; D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = ""; }; D68E525C24A3E8F00054355A /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; + D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSourceEmojiLabel.swift; sourceTree = ""; }; + D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseEmojiLabel.swift; sourceTree = ""; }; D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = ""; }; D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBezierPath+Helpers.swift"; sourceTree = ""; }; D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedNavigationViewController.swift; sourceTree = ""; }; @@ -1278,7 +1282,9 @@ D620483323D3801D008A63EF /* LinkTextView.swift */, D620483523D38075008A63EF /* ContentTextView.swift */, D620483723D38190008A63EF /* StatusContentTextView.swift */, + D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */, D6969E9F240C8384002843CE /* EmojiLabel.swift */, + D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */, D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */, 04ED00B021481ED800567C53 /* SteppedProgressView.swift */, D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */, @@ -1866,6 +1872,7 @@ D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */, D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */, D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */, + D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */, D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */, D6AEBB4523216AF800E5038B /* FollowAccountActivity.swift in Sources */, D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */, @@ -1942,6 +1949,7 @@ D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */, D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */, D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */, + D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */, D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */, D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */, 0427037C22B316B9000D31B6 /* SilentActionPrefs.swift in Sources */, diff --git a/Tusker/Views/BaseEmojiLabel.swift b/Tusker/Views/BaseEmojiLabel.swift new file mode 100644 index 00000000..c214f023 --- /dev/null +++ b/Tusker/Views/BaseEmojiLabel.swift @@ -0,0 +1,83 @@ +// +// BaseEmojiLabel.swift +// Tusker +// +// Created by Shadowfacts on 10/18/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) + +protocol BaseEmojiLabel: class { + var emojiIdentifier: String? { get set } + var emojiRequests: [ImageCache.Request] { get set } + var emojiFont: UIFont { get } + var emojiTextColor: UIColor { get } +} + +extension BaseEmojiLabel { + func replaceEmojis(in string: String, emojis: [Emoji], identifier: String, completion: @escaping (NSAttributedString) -> Void) { + let matches = emojiRegex.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) + guard !matches.isEmpty else { + completion(NSAttributedString(string: string)) + return + } + + let emojiImages = MultiThreadDictionary(name: "BaseEmojiLabel Emoji Images") + var foundEmojis = false + + let group = DispatchGroup() + + for emoji in emojis { + // only make requests for emojis that are present in the text to avoid making unnecessary network requests + guard matches.contains(where: { (match) in + let matchShortcode = (string as NSString).substring(with: match.range(at: 1)) + return emoji.shortcode == matchShortcode + }) else { + continue + } + + foundEmojis = true + + group.enter() + let request = ImageCache.emojis.get(emoji.url) { (data) in + defer { group.leave() } + guard let data = data, let image = UIImage(data: data) else { + return + } + emojiImages[emoji.shortcode] = image + } + if let request = request { + emojiRequests.append(request) + } + } + + guard foundEmojis else { + completion(NSAttributedString(string: string)) + return + } + + group.notify(queue: .main) { [weak self] in + // if e.g. the account changes before all emojis are loaded, don't bother trying to set them + guard let self = self, self.emojiIdentifier == identifier else { return } + + let mutAttrString = NSMutableAttributedString(string: string) + // replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis + for match in matches.reversed() { + let shortcode = (string as NSString).substring(with: match.range(at: 1)) + guard let emojiImage = emojiImages[shortcode] else { + continue + } + + let attachment = NSTextAttachment(emojiImage: emojiImage, in: self.emojiFont, with: self.emojiTextColor) + let attachmentStr = NSAttributedString(attachment: attachment) + mutAttrString.replaceCharacters(in: match.range, with: attachmentStr) + } + + completion(mutAttrString) + } + } +} diff --git a/Tusker/Views/EmojiLabel.swift b/Tusker/Views/EmojiLabel.swift index cc771614..04944ca2 100644 --- a/Tusker/Views/EmojiLabel.swift +++ b/Tusker/Views/EmojiLabel.swift @@ -9,67 +9,23 @@ import UIKit import Pachyderm -private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) - -class EmojiLabel: UILabel { +class EmojiLabel: UILabel, BaseEmojiLabel { - private var emojiIdentifier: String? - private var emojiRequests: [ImageCache.Request] = [] + var emojiIdentifier: String? + var emojiRequests: [ImageCache.Request] = [] + var emojiFont: UIFont { font } + var emojiTextColor: UIColor { textColor } func setEmojis(_ emojis: [Emoji], identifier: String) { guard emojis.count > 0, let attributedText = attributedText else { return } - + self.emojiIdentifier = identifier emojiRequests.forEach { $0.cancel() } emojiRequests = [] - let matches = emojiRegex.matches(in: attributedText.string, options: [], range: attributedText.fullRange) - guard !matches.isEmpty else { return } - - let emojiImages = MultiThreadDictionary(name: "EmojiLabel Emoji Images") - - let group = DispatchGroup() - - for emoji in emojis { - // only make requests for emojis that are present in the text to avoid making unnecessary network requests - guard matches.contains(where: { (match) in - let matchShortcode = (attributedText.string as NSString).substring(with: match.range(at: 1)) - return emoji.shortcode == matchShortcode - }) else { - continue - } - - group.enter() - let request = ImageCache.emojis.get(emoji.url) { (data) in - defer { group.leave() } - guard let data = data, let image = UIImage(data: data) else { - return - } - emojiImages[emoji.shortcode] = image - } - if let request = request { - emojiRequests.append(request) - } - } - - group.notify(queue: .main) { [weak self] in - // if e.g. the account changes before all emojis are loaded, don't bother trying to set them + replaceEmojis(in: attributedText.string, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText) in guard let self = self, self.emojiIdentifier == identifier else { return } - - let mutAttrString = NSMutableAttributedString(attributedString: attributedText) - // replaces the emojis starting from the end of the string as to not alter the indicies of preceeding emojis - for match in matches.reversed() { - let shortcode = (mutAttrString.string as NSString).substring(with: match.range(at: 1)) - guard let emojiImage = emojiImages[shortcode] else { - continue - } - - let attachment = NSTextAttachment(emojiImage: emojiImage, in: self.font, with: self.textColor) - let attachmentStr = NSAttributedString(attachment: attachment) - mutAttrString.replaceCharacters(in: match.range, with: attachmentStr) - } - - self.attributedText = mutAttrString + self.attributedText = newAttributedText self.setNeedsLayout() self.setNeedsDisplay() } diff --git a/Tusker/Views/MultiSourceEmojiLabel.swift b/Tusker/Views/MultiSourceEmojiLabel.swift new file mode 100644 index 00000000..75fc5c7f --- /dev/null +++ b/Tusker/Views/MultiSourceEmojiLabel.swift @@ -0,0 +1,50 @@ +// +// MultiSourceEmojiLabel.swift +// Tusker +// +// Created by Shadowfacts on 10/18/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) + +class MultiSourceEmojiLabel: UILabel, BaseEmojiLabel { + var emojiIdentifier: String? + var emojiRequests = [ImageCache.Request]() + var emojiFont: UIFont { font } + var emojiTextColor: UIColor { textColor } + + var combiner: (([NSAttributedString]) -> NSAttributedString)? + + func setEmojis(pairs: [(String, [Emoji])], identifier: String) { + guard pairs.count > 0 else { return } + + self.emojiIdentifier = identifier + emojiRequests.forEach { $0.cancel() } + emojiRequests = [] + + var attributedStrings = pairs.map { NSAttributedString(string: $0.0) } + + func recombine() { + if let combiner = self.combiner { + self.attributedText = combiner(attributedStrings) + } + } + + recombine() + + for (index, (string, emojis)) in pairs.enumerated() { + self.replaceEmojis(in: string, emojis: emojis, identifier: identifier) { (attributedString) in + attributedStrings[index] = attributedString + DispatchQueue.main.async { [weak self] in + guard let self = self, self.emojiIdentifier == identifier else { return } + recombine() + } + } + } + } + +} diff --git a/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift b/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift index f5ade795..dc6b30d2 100644 --- a/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift +++ b/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift @@ -19,7 +19,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell { @IBOutlet weak var verticalStackView: UIStackView! @IBOutlet weak var actionAvatarStackView: UIStackView! @IBOutlet weak var timestampLabel: UILabel! - @IBOutlet weak var actionLabel: UILabel! + @IBOutlet weak var actionLabel: MultiSourceEmojiLabel! @IBOutlet weak var statusContentLabel: UILabel! var group: NotificationGroup! @@ -35,14 +35,12 @@ class ActionNotificationGroupTableViewCell: UITableViewCell { override func awakeFromNib() { super.awakeFromNib() + actionLabel.combiner = self.updateActionLabel + NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) } @objc func updateUIForPreferences() { - // todo: is this compactMap necessary? - let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) } - updateActionLabel(people: people) - for case let imageView as UIImageView in actionAvatarStackView.arrangedSubviews { imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: imageView) } @@ -100,8 +98,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell { NSLayoutConstraint.activate(imageViews.map { $0.widthAnchor.constraint(equalTo: $0.heightAnchor) }) updateTimestamp() - - updateActionLabel(people: people) + actionLabel.setEmojis(pairs: people.map { ($0.displayOrUserName, $0.emojis) }, identifier: group.id) let doc = try! SwiftSoup.parse(status.content) statusContentLabel.text = try! doc.text() @@ -135,7 +132,7 @@ class ActionNotificationGroupTableViewCell: UITableViewCell { } } - func updateActionLabel(people: [AccountMO]) { + func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString { let verb: String switch group.kind { case .favourite: @@ -145,18 +142,27 @@ class ActionNotificationGroupTableViewCell: UITableViewCell { default: fatalError() } - let peopleStr: String + // todo: figure out how to localize this - // todo: update to use managed objects - switch people.count { + let str = NSMutableAttributedString(string: "\(verb) by ") + switch names.count { case 1: - peopleStr = people.first!.displayName + str.append(names.first!) case 2: - peopleStr = people.first!.displayName + " and " + people.last!.displayName + str.append(names.first!) + str.append(NSAttributedString(string: " and ")) + str.append(names.last!) default: - peopleStr = people.dropLast().map { $0.displayName }.joined(separator: ", ") + ", and " + people.last!.displayName + for (index, name) in names.enumerated() { + str.append(name) + if index < names.count - 2 { + str.append(NSAttributedString(string: ", ")) + } else if index == names.count - 2 { + str.append(NSAttributedString(string: ", and ")) + } + } } - actionLabel.text = "\(verb) by \(peopleStr)" + return str } override func prepareForReuse() { diff --git a/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.xib b/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.xib index cc42d57c..658cc7cc 100644 --- a/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.xib +++ b/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.xib @@ -1,9 +1,11 @@ - + - + + + @@ -29,17 +31,17 @@ - + - + + + + + diff --git a/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.swift b/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.swift index e0e5fc7e..e41d5c1a 100644 --- a/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.swift +++ b/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.swift @@ -16,7 +16,7 @@ class FollowNotificationGroupTableViewCell: UITableViewCell { @IBOutlet weak var avatarStackView: UIStackView! @IBOutlet weak var timestampLabel: UILabel! - @IBOutlet weak var actionLabel: UILabel! + @IBOutlet weak var actionLabel: MultiSourceEmojiLabel! var group: NotificationGroup! @@ -30,13 +30,12 @@ class FollowNotificationGroupTableViewCell: UITableViewCell { override func awakeFromNib() { super.awakeFromNib() + actionLabel.combiner = self.updateActionLabel + NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) } @objc func updateUIForPreferences() { - let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) } - updateActionLabel(people: people) - for case let imageView as UIImageView in avatarStackView.arrangedSubviews { imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: imageView) } @@ -47,7 +46,9 @@ class FollowNotificationGroupTableViewCell: UITableViewCell { let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) } - updateActionLabel(people: people) + actionLabel.setEmojis(pairs: people.map { + ($0.displayOrUserName, $0.emojis) + }, identifier: group.id) updateTimestamp() avatarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } @@ -71,20 +72,27 @@ class FollowNotificationGroupTableViewCell: UITableViewCell { } } - func updateActionLabel(people: [AccountMO]) { - // todo: custom emoji in people display names + func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString { // todo: figure out how to localize this - let peopleStr: String - switch people.count { + let str = NSMutableAttributedString(string: "Followed by ") + switch names.count { case 1: - peopleStr = people.first!.displayOrUserName + str.append(names.first!) case 2: - peopleStr = people.first!.displayOrUserName + " and " + people.last!.displayOrUserName + str.append(names.first!) + str.append(NSAttributedString(string: " and ")) + str.append(names.last!) default: - peopleStr = people.dropLast().map { $0.displayOrUserName }.joined(separator: ", ") + ", and " + people.last!.displayOrUserName - + for (index, name) in names.enumerated() { + str.append(name) + if index < names.count - 2 { + str.append(NSAttributedString(string: ", ")) + } else if index == names.count - 2 { + str.append(NSAttributedString(string: ", and ")) + } + } } - actionLabel.text = "Followed by \(peopleStr)" + return str } func updateTimestamp() { diff --git a/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.xib b/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.xib index 654c1f5f..a1859139 100644 --- a/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.xib +++ b/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.xib @@ -1,9 +1,11 @@ - + - + + + @@ -29,17 +31,17 @@ - + - - + + + +