diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 0bb047337..9a4b6c076 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -568,6 +568,7 @@ B845B4D4230CD09100D759F0 /* GroupChatPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = B845B4D3230CD09000D759F0 /* GroupChatPoller.swift */; }; B846365B22B7418B00AF1514 /* Identicon+ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B846365A22B7418B00AF1514 /* Identicon+ObjC.swift */; }; B84664F3234FE4540083A1CD /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84664F2234FE4530083A1CD /* Mention.swift */; }; + B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84664F4235022F30083A1CD /* MentionUtilities.swift */; }; B86BD08123399883000F5AE3 /* QRCodeModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08023399883000F5AE3 /* QRCodeModal.swift */; }; B86BD08423399ACF000F5AE3 /* Modal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08323399ACF000F5AE3 /* Modal.swift */; }; B86BD08623399CEF000F5AE3 /* SeedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08523399CEF000F5AE3 /* SeedModal.swift */; }; @@ -1380,6 +1381,7 @@ B845B4D3230CD09000D759F0 /* GroupChatPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatPoller.swift; sourceTree = ""; }; B846365A22B7418B00AF1514 /* Identicon+ObjC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Identicon+ObjC.swift"; sourceTree = ""; }; B84664F2234FE4530083A1CD /* Mention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = ""; }; + B84664F4235022F30083A1CD /* MentionUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionUtilities.swift; sourceTree = ""; }; B86BD08023399883000F5AE3 /* QRCodeModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeModal.swift; sourceTree = ""; }; B86BD08323399ACF000F5AE3 /* Modal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modal.swift; sourceTree = ""; }; B86BD08523399CEF000F5AE3 /* SeedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedModal.swift; sourceTree = ""; }; @@ -2701,6 +2703,7 @@ children = ( B86BD08323399ACF000F5AE3 /* Modal.swift */, B885D5F52334A32100EE0D8E /* UIView+Constraint.swift */, + B84664F4235022F30083A1CD /* MentionUtilities.swift */, ); path = Utilities; sourceTree = ""; @@ -3882,6 +3885,7 @@ 345BC30C2047030700257B7C /* OWS2FASettingsViewController.m in Sources */, 340FC8B7204DAC8D007AEB0F /* OWSConversationSettingsViewController.m in Sources */, 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, + B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */, B84664F3234FE4540083A1CD /* Mention.swift in Sources */, 34D1F0C01F8EC1760066283D /* MessageRecipientStatusUtils.swift in Sources */, 45F659731E1BD99C00444429 /* CallKitCallUIAdaptee.swift in Sources */, diff --git a/Signal/src/Loki/Utilities/MentionUtilities.swift b/Signal/src/Loki/Utilities/MentionUtilities.swift new file mode 100644 index 000000000..425a25244 --- /dev/null +++ b/Signal/src/Loki/Utilities/MentionUtilities.swift @@ -0,0 +1,49 @@ + +@objc(LKMentionUtilities) +public final class MentionUtilities : NSObject { + + override private init() { } + + @objc public static func highlightMentions(in string: String, thread: TSThread) -> String { + return highlightMentions(in: string, isOutgoingMessage: false, thread: thread, attributes: [:]).string // isOutgoingMessage and attributes are irrelevant + } + + @objc public static func highlightMentions(in string: String, isOutgoingMessage: Bool, thread: TSThread, attributes: [NSAttributedString.Key:Any]) -> NSAttributedString { + var string = string + let regex = try! NSRegularExpression(pattern: "@[0-9a-fA-F]*", options: []) + let knownUserIDs = LokiAPI.userIDCache[thread.uniqueId!] ?? [] // Should always be populated at this point + var mentions: [NSRange] = [] + var outerMatch = regex.firstMatch(in: string, options: .withoutAnchoringBounds, range: NSRange(location: 0, length: string.count)) + while let match = outerMatch, thread.isGroupThread() { + let userID = String((string as NSString).substring(with: match.range).dropFirst()) // Drop the @ + let matchEnd: Int + if knownUserIDs.contains(userID) { + var userDisplayName: String? + if userID == OWSIdentityManager.shared().identityKeyPair()!.hexEncodedPublicKey { + userDisplayName = OWSProfileManager.shared().localProfileName() + } else { + OWSPrimaryStorage.shared().dbReadConnection.read { transaction in + let collection = "\(LokiGroupChatAPI.publicChatServer).\(LokiGroupChatAPI.publicChatServerID)" + userDisplayName = transaction.object(forKey: userID, inCollection: collection) as! String? + } + } + if let userDisplayName = userDisplayName { + string = (string as NSString).replacingCharacters(in: match.range, with: "@\(userDisplayName)") + mentions.append(NSRange(location: match.range.location, length: userDisplayName.count + 1)) // + 1 to include the @ + matchEnd = match.range.location + userDisplayName.count + } else { + matchEnd = match.range.location + match.range.length + } + } else { + matchEnd = match.range.location + match.range.length + } + outerMatch = regex.firstMatch(in: string, options: .withoutAnchoringBounds, range: NSRange(location: matchEnd, length: string.count - matchEnd)) + } + let result = NSMutableAttributedString(string: string, attributes: attributes) + mentions.forEach { mention in + let color: UIColor = isOutgoingMessage ? .lokiDarkGray() : .lokiGreen() + result.addAttribute(.backgroundColor, value: color, range: mention) + } + return result + } +} diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m index 43e2915b6..588fb06f8 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m @@ -19,8 +19,6 @@ #import "UIColor+OWS.h" #import -// TODO: Reduce code duplication around mention detection - NS_ASSUME_NONNULL_BEGIN @interface OWSMessageBubbleView () @@ -685,7 +683,7 @@ NS_ASSUME_NONNULL_BEGIN font:self.textMessageFont shouldIgnoreEvents:shouldIgnoreEvents thread:self.viewItem.interaction.thread - isOutgoing:[self.viewItem.interaction isKindOfClass:TSOutgoingMessage.self]]; + isOutgoingMessage:[self.viewItem.interaction isKindOfClass:TSOutgoingMessage.self]]; } + (void)loadForTextDisplay:(OWSMessageTextView *)textView @@ -695,7 +693,7 @@ NS_ASSUME_NONNULL_BEGIN font:(UIFont *)font shouldIgnoreEvents:(BOOL)shouldIgnoreEvents thread:(TSThread *)thread - isOutgoing:(BOOL)isOutgoing + isOutgoingMessage:(BOOL)isOutgoingMessage { textView.hidden = NO; textView.textColor = textColor; @@ -709,57 +707,18 @@ NS_ASSUME_NONNULL_BEGIN NSString *text = displayableText.displayText; - NSError *error1; - NSRegularExpression *regex1 = [[NSRegularExpression alloc] initWithPattern:@"@[0-9a-fA-F]*" options:0 error:&error1]; - OWSAssertDebug(error1 == nil); - NSSet *knownUserIDs = LKAPI.userIDCache[thread.uniqueId]; - NSMutableArray *mentions = [NSMutableArray new]; - NSTextCheckingResult *match1 = [regex1 firstMatchInString:text options:NSMatchingWithoutAnchoringBounds range:NSMakeRange(0, text.length)]; - if (match1 != nil && thread.isGroupThread) { - while (YES) { - NSString *userID = [[text substringWithRange:match1.range] stringByReplacingCharactersInRange:NSMakeRange(0, 1) withString:@""]; - NSUInteger matchEnd; - if ([knownUserIDs containsObject:userID]) { - __block NSString *userDisplayName; - if ([userID isEqual:OWSIdentityManager.sharedManager.identityKeyPair.hexEncodedPublicKey]) { - userDisplayName = OWSProfileManager.sharedManager.localProfileName; - } else { - [OWSPrimaryStorage.sharedManager.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - NSString *collection = [NSString stringWithFormat:@"%@.%llu", LKGroupChatAPI.publicChatServer, LKGroupChatAPI.publicChatServerID]; - userDisplayName = [transaction objectForKey:userID inCollection:collection]; - }]; - } - if (userDisplayName != nil) { - text = [text stringByReplacingCharactersInRange:match1.range withString:[NSString stringWithFormat:@"@%@", userDisplayName]]; - [mentions addObject:[NSValue valueWithRange:NSMakeRange(match1.range.location, userDisplayName.length + 1)]]; - matchEnd = match1.range.location + userDisplayName.length; - } else { - matchEnd = match1.range.location + match1.range.length; - } - } else { - matchEnd = match1.range.location + match1.range.length; - } - match1 = [regex1 firstMatchInString:text options:NSMatchingWithoutAnchoringBounds range:NSMakeRange(matchEnd, text.length - matchEnd)]; - if (match1 == nil) { break; } - } - } - NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text attributes:@{ NSFontAttributeName : font, NSForegroundColorAttributeName : textColor }]; - for (NSValue *mention in mentions) { - NSRange range = mention.rangeValue; - UIColor *highlightColor = isOutgoing ? UIColor.lokiDarkGray : UIColor.lokiGreen; - [attributedText addAttribute:NSBackgroundColorAttributeName value:highlightColor range:range]; - } + NSMutableAttributedString *attributedText = [LKMentionUtilities highlightMentionsIn:text isOutgoingMessage:isOutgoingMessage thread:thread attributes:@{ NSFontAttributeName : font, NSForegroundColorAttributeName : textColor }].mutableCopy; if (searchText.length >= ConversationSearchController.kMinimumSearchTextLength) { NSString *searchableText = [FullTextSearchFinder normalizeWithText:searchText]; - NSError *error2; - NSRegularExpression *regex2 = [[NSRegularExpression alloc] initWithPattern:[NSRegularExpression escapedPatternForString:searchableText] options:NSRegularExpressionCaseInsensitive error:&error2]; - OWSAssertDebug(error2 == nil); - for (NSTextCheckingResult *match2 in - [regex2 matchesInString:text options:NSMatchingWithoutAnchoringBounds range:NSMakeRange(0, text.length)]) { - OWSAssertDebug(match2.range.length >= ConversationSearchController.kMinimumSearchTextLength); - [attributedText addAttribute:NSBackgroundColorAttributeName value:UIColor.yellowColor range:match2.range]; - [attributedText addAttribute:NSForegroundColorAttributeName value:UIColor.ows_blackColor range:match2.range]; + NSError *error; + NSRegularExpression *regex = [[NSRegularExpression alloc] initWithPattern:[NSRegularExpression escapedPatternForString:searchableText] options:NSRegularExpressionCaseInsensitive error:&error]; + OWSAssertDebug(error == nil); + for (NSTextCheckingResult *match in + [regex matchesInString:text options:NSMatchingWithoutAnchoringBounds range:NSMakeRange(0, text.length)]) { + OWSAssertDebug(match.range.length >= ConversationSearchController.kMinimumSearchTextLength); + [attributedText addAttribute:NSBackgroundColorAttributeName value:UIColor.yellowColor range:match.range]; + [attributedText addAttribute:NSForegroundColorAttributeName value:UIColor.ows_blackColor range:match.range]; } } @@ -1197,40 +1156,9 @@ NS_ASSUME_NONNULL_BEGIN - (DisplayableText *)getDisplayableQuotedText { if (!self.viewItem.hasQuotedText) { return nil; } - NSString *text = self.viewItem.displayableQuotedText.fullText; + NSString *rawText = self.viewItem.displayableQuotedText.fullText; TSThread *thread = self.viewItem.interaction.thread; - NSError *error; - NSRegularExpression *regex = [[NSRegularExpression alloc] initWithPattern:@"@[0-9a-fA-F]*" options:0 error:&error]; - OWSAssertDebug(error == nil); - NSSet *knownUserIDs = LKAPI.userIDCache[thread.uniqueId]; - NSTextCheckingResult *match = [regex firstMatchInString:text options:NSMatchingWithoutAnchoringBounds range:NSMakeRange(0, text.length)]; - if (match != nil && thread.isGroupThread) { - while (YES) { - NSString *userID = [[text substringWithRange:match.range] stringByReplacingCharactersInRange:NSMakeRange(0, 1) withString:@""]; - NSUInteger matchEnd; - if ([knownUserIDs containsObject:userID]) { - __block NSString *userDisplayName; - if ([userID isEqual:OWSIdentityManager.sharedManager.identityKeyPair.hexEncodedPublicKey]) { - userDisplayName = OWSProfileManager.sharedManager.localProfileName; - } else { - [OWSPrimaryStorage.sharedManager.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - NSString *collection = [NSString stringWithFormat:@"%@.%llu", LKGroupChatAPI.publicChatServer, LKGroupChatAPI.publicChatServerID]; - userDisplayName = [transaction objectForKey:userID inCollection:collection]; - }]; - } - if (userDisplayName != nil) { - text = [text stringByReplacingCharactersInRange:match.range withString:[NSString stringWithFormat:@"@%@", userDisplayName]]; - matchEnd = match.range.location + userDisplayName.length; - } else { - matchEnd = match.range.location + match.range.length; - } - } else { - matchEnd = match.range.location + match.range.length; - } - match = [regex firstMatchInString:text options:NSMatchingWithoutAnchoringBounds range:NSMakeRange(matchEnd, text.length - matchEnd)]; - if (match == nil) { break; } - } - } + NSString *text = [LKMentionUtilities highlightMentionsIn:rawText thread:thread]; return [DisplayableText displayableText:text]; } diff --git a/Signal/src/ViewControllers/HomeView/HomeViewCell.m b/Signal/src/ViewControllers/HomeView/HomeViewCell.m index 0c558dd73..e725933e2 100644 --- a/Signal/src/ViewControllers/HomeView/HomeViewCell.m +++ b/Signal/src/ViewControllers/HomeView/HomeViewCell.m @@ -395,6 +395,8 @@ NS_ASSUME_NONNULL_BEGIN } NSString *displayableText = thread.lastMessageText; if (displayableText) { + [LKAPI populateUserIDCacheIfNeededFor:thread.threadRecord.uniqueId in:nil]; // TODO: Terrible place to do this, but okay for now + displayableText = [LKMentionUtilities highlightMentionsIn:displayableText thread:thread.threadRecord]; [snippetText appendAttributedString:[[NSAttributedString alloc] initWithString:displayableText attributes:@{ diff --git a/SignalServiceKit/src/Loki/API/LokiAPI.swift b/SignalServiceKit/src/Loki/API/LokiAPI.swift index f8e89501b..3c7528613 100644 --- a/SignalServiceKit/src/Loki/API/LokiAPI.swift +++ b/SignalServiceKit/src/Loki/API/LokiAPI.swift @@ -3,7 +3,7 @@ import PromiseKit @objc(LKAPI) public final class LokiAPI : NSObject { private static var lastDeviceLinkUpdate: [String:Date] = [:] // Hex encoded public key to date - @objc static var userIDCache: [String:Set] = [:] // Thread ID to set of user hex encoded public keys + @objc public static var userIDCache: [String:Set] = [:] // Thread ID to set of user hex encoded public keys // MARK: Convenience internal static let storage = OWSPrimaryStorage.shared()