From 10eead529f511d709d224b238806e53b0fe22a40 Mon Sep 17 00:00:00 2001 From: Niels Andriesse Date: Wed, 9 Oct 2019 14:46:21 +1100 Subject: [PATCH] Implement user selection UI --- Signal.xcodeproj/project.pbxproj | 8 ++ Signal/src/Loki/UserSelectionView.swift | 133 ++++++++++++++++++ .../src/Loki/UserSelectionViewDelegate.swift | 6 + .../Loki/Utilities/UIView+Constraint.swift | 9 +- .../Cells/OWSMessageBubbleView.m | 33 +++-- .../ConversationInputToolbar.h | 10 ++ .../ConversationInputToolbar.m | 34 ++++- .../ConversationViewController.m | 34 ++++- SignalServiceKit/src/Loki/API/LokiAPI.swift | 24 ++++ 9 files changed, 268 insertions(+), 23 deletions(-) create mode 100644 Signal/src/Loki/UserSelectionView.swift create mode 100644 Signal/src/Loki/UserSelectionViewDelegate.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index afdbb038c..3cc371d00 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -578,6 +578,8 @@ B894D0712339D6F300B4D94D /* DeviceLinkingModalDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B894D0702339D6F300B4D94D /* DeviceLinkingModalDelegate.swift */; }; B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B894D0742339EDCF00B4D94D /* NukeDataModal.swift */; }; B89841E322B7579F00B1BDC6 /* NewConversationVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B89841E222B7579F00B1BDC6 /* NewConversationVC.swift */; }; + B8B26C8F234D629C004ED98C /* UserSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B26C8E234D629C004ED98C /* UserSelectionView.swift */; }; + B8B26C91234D8CBD004ED98C /* UserSelectionViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B26C90234D8CBD004ED98C /* UserSelectionViewDelegate.swift */; }; B90418E6183E9DD40038554A /* DateUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = B90418E5183E9DD40038554A /* DateUtil.m */; }; B9EB5ABD1884C002007CBB57 /* MessageUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B9EB5ABC1884C002007CBB57 /* MessageUI.framework */; }; BFF3FB9730634F37D25903F4 /* Pods_Signal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D17BB5C25D615AB49813100C /* Pods_Signal.framework */; }; @@ -1385,6 +1387,8 @@ B894D0702339D6F300B4D94D /* DeviceLinkingModalDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLinkingModalDelegate.swift; sourceTree = ""; }; B894D0742339EDCF00B4D94D /* NukeDataModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NukeDataModal.swift; sourceTree = ""; }; B89841E222B7579F00B1BDC6 /* NewConversationVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewConversationVC.swift; sourceTree = ""; }; + B8B26C8E234D629C004ED98C /* UserSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSelectionView.swift; sourceTree = ""; }; + B8B26C90234D8CBD004ED98C /* UserSelectionViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSelectionViewDelegate.swift; sourceTree = ""; }; B90418E4183E9DD40038554A /* DateUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DateUtil.h; sourceTree = ""; }; B90418E5183E9DD40038554A /* DateUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DateUtil.m; sourceTree = ""; }; B97940251832BD2400BD66CB /* UIUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UIUtil.h; sourceTree = ""; }; @@ -2651,6 +2655,8 @@ B89841E222B7579F00B1BDC6 /* NewConversationVC.swift */, B8258491230FA5DA001B41CB /* ScanQRCodeVC.h */, B8258492230FA5E9001B41CB /* ScanQRCodeVC.m */, + B8B26C8E234D629C004ED98C /* UserSelectionView.swift */, + B8B26C90234D8CBD004ED98C /* UserSelectionViewDelegate.swift */, ); path = Loki; sourceTree = ""; @@ -3840,6 +3846,7 @@ 4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */, 34D8C0281ED3673300188D7C /* DebugUITableViewController.m in Sources */, 45F32C222057297A00A300D5 /* MediaDetailViewController.m in Sources */, + B8B26C8F234D629C004ED98C /* UserSelectionView.swift in Sources */, 34B3F8851E8DF1700035BE1A /* NewGroupViewController.m in Sources */, 34ABC0E421DD20C500ED9469 /* ConversationMessageMapping.swift in Sources */, B821F2F82272CED3002C88C0 /* DisplayNameVC.swift in Sources */, @@ -3862,6 +3869,7 @@ 45FBC5C81DF8575700E9B410 /* CallKitCallManager.swift in Sources */, 4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */, 45FBC5D11DF8592E00E9B410 /* SignalCall.swift in Sources */, + B8B26C91234D8CBD004ED98C /* UserSelectionViewDelegate.swift in Sources */, 340FC8BB204DAC8D007AEB0F /* OWSAddToContactViewController.m in Sources */, 45F32C232057297A00A300D5 /* MediaPageViewController.swift in Sources */, 452C468F1E427E200087B011 /* OutboundCallInitiator.swift in Sources */, diff --git a/Signal/src/Loki/UserSelectionView.swift b/Signal/src/Loki/UserSelectionView.swift new file mode 100644 index 000000000..16f3b6473 --- /dev/null +++ b/Signal/src/Loki/UserSelectionView.swift @@ -0,0 +1,133 @@ + +// MARK: - User Selection View + +@objc(LKUserSelectionView) +final class UserSelectionView : UIView, UITableViewDataSource, UITableViewDelegate { + @objc var users: [String] = [] { didSet { tableView.reloadData() } } + @objc var hasGroupContext = false + @objc var delegate: UserSelectionViewDelegate? + + // MARK: Components + private lazy var tableView: UITableView = { + let result = UITableView() + result.dataSource = self + result.delegate = self + result.register(Cell.self, forCellReuseIdentifier: "Cell") + result.separatorStyle = .none + result.backgroundColor = .clear + result.contentInset = UIEdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0) + return result + }() + + // MARK: Initialization + override init(frame: CGRect) { + super.init(frame: frame) + setUpViewHierarchy() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUpViewHierarchy() + } + + private func setUpViewHierarchy() { + addSubview(tableView) + tableView.pin(to: self) + } + + // MARK: Data + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return users.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! Cell + let user = users[indexPath.row] + cell.user = user + cell.hasGroupContext = hasGroupContext + return cell + } + + // MARK: Interaction + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let user = users[indexPath.row] + delegate?.handleUserSelected(user, from: self) + } +} + +// MARK: - Cell + +private extension UserSelectionView { + + final class Cell : UITableViewCell { + var user = "" { didSet { update() } } + var hasGroupContext = false + + // MARK: Components + private lazy var profilePictureImageView = AvatarImageView() + + private lazy var moderatorIconImageView: UIImageView = { + let result = UIImageView(image: #imageLiteral(resourceName: "Crown")) + return result + }() + + private lazy var displayNameLabel: UILabel = { + let result = UILabel() + result.textColor = Theme.primaryColor + result.font = UIFont.ows_dynamicTypeSubheadlineClamped + result.lineBreakMode = .byTruncatingTail + return result + }() + + // MARK: Initialization + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setUpViewHierarchy() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUpViewHierarchy() + } + + private func setUpViewHierarchy() { + // Make the cell transparent + backgroundColor = .clear + // Set up the profile picture image view + profilePictureImageView.set(.width, to: 36) + profilePictureImageView.set(.height, to: 36) + // Set up the main stack view + let stackView = UIStackView(arrangedSubviews: [ profilePictureImageView, displayNameLabel ]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 16 + stackView.set(.height, to: 44) + contentView.addSubview(stackView) + stackView.pin(.leading, to: .leading, of: contentView, withInset: 16) + stackView.pin(.top, to: .top, of: contentView, withInset: 4) + contentView.pin(.trailing, to: .trailing, of: stackView, withInset: 16) + contentView.pin(.bottom, to: .bottom, of: stackView, withInset: 4) + stackView.set(.width, to: UIScreen.main.bounds.width - 2 * 16) + // Set up the moderator icon image view + moderatorIconImageView.set(.width, to: 20) + moderatorIconImageView.set(.height, to: 20) + contentView.addSubview(moderatorIconImageView) + moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureImageView) + moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureImageView, withInset: 3.5) + } + + // MARK: Updating + private func update() { + var displayName: String = "" + OWSPrimaryStorage.shared().dbReadConnection.read { transaction in + let collection = "\(LokiGroupChatAPI.publicChatServer).\(LokiGroupChatAPI.publicChatServerID)" + displayName = transaction.object(forKey: self.user, inCollection: collection) as! String + } + displayNameLabel.text = displayName + let profilePicture = OWSContactAvatarBuilder(signalId: user, colorName: .blue, diameter: 36).build() + profilePictureImageView.image = profilePicture + let isUserModerator = LokiGroupChatAPI.isUserModerator(user, for: LokiGroupChatAPI.publicChatServerID, on: LokiGroupChatAPI.publicChatServer) + moderatorIconImageView.isHidden = !isUserModerator || !hasGroupContext + } + } +} diff --git a/Signal/src/Loki/UserSelectionViewDelegate.swift b/Signal/src/Loki/UserSelectionViewDelegate.swift new file mode 100644 index 000000000..cdeff62dc --- /dev/null +++ b/Signal/src/Loki/UserSelectionViewDelegate.swift @@ -0,0 +1,6 @@ + +@objc(LKUserSelectionViewDelegate) +protocol UserSelectionViewDelegate { + + func handleUserSelected(_ user: String, from userSelectionView: UserSelectionView) +} diff --git a/Signal/src/Loki/Utilities/UIView+Constraint.swift b/Signal/src/Loki/Utilities/UIView+Constraint.swift index 22c2dea98..55d1d26c3 100644 --- a/Signal/src/Loki/Utilities/UIView+Constraint.swift +++ b/Signal/src/Loki/Utilities/UIView+Constraint.swift @@ -22,16 +22,21 @@ extension UIView { } } - func pin(_ constraineeEdge: HorizontalEdge, to constrainerEdge: HorizontalEdge, of view: UIView, withInset inset: CGFloat) { + func pin(_ constraineeEdge: HorizontalEdge, to constrainerEdge: HorizontalEdge, of view: UIView, withInset inset: CGFloat = 0) { translatesAutoresizingMaskIntoConstraints = false anchor(from: constraineeEdge).constraint(equalTo: view.anchor(from: constrainerEdge), constant: inset).isActive = true } - func pin(_ constraineeEdge: VerticalEdge, to constrainerEdge: VerticalEdge, of view: UIView, withInset inset: CGFloat) { + func pin(_ constraineeEdge: VerticalEdge, to constrainerEdge: VerticalEdge, of view: UIView, withInset inset: CGFloat = 0) { translatesAutoresizingMaskIntoConstraints = false anchor(from: constraineeEdge).constraint(equalTo: view.anchor(from: constrainerEdge), constant: inset).isActive = true } + func pin(to view: UIView) { + [ HorizontalEdge.leading, HorizontalEdge.trailing ].forEach { pin($0, to: $0, of: view) } + [ VerticalEdge.top, VerticalEdge.bottom ].forEach { pin($0, to: $0, of: view) } + } + func center(_ direction: Direction, in view: UIView) { translatesAutoresizingMaskIntoConstraints = false switch direction { diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m index 511c48594..8ce956299 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageBubbleView.m @@ -676,14 +676,14 @@ NS_ASSUME_NONNULL_BEGIN TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction; shouldIgnoreEvents = outgoingMessage.messageState != TSOutgoingMessageStateSent; } - NSString *threadID = self.viewItem.interaction.uniqueThreadId; + [self.class loadForTextDisplay:self.bodyTextView displayableText:self.displayableBodyText searchText:self.delegate.lastSearchedText textColor:self.bodyTextColor font:self.textMessageFont shouldIgnoreEvents:shouldIgnoreEvents - threadID:threadID]; + thread:self.viewItem.interaction.thread]; } + (void)loadForTextDisplay:(OWSMessageTextView *)textView @@ -692,7 +692,7 @@ NS_ASSUME_NONNULL_BEGIN textColor:(UIColor *)textColor font:(UIFont *)font shouldIgnoreEvents:(BOOL)shouldIgnoreEvents - threadID:(NSString *)threadID + thread:(TSThread *)thread { textView.hidden = NO; textView.textColor = textColor; @@ -709,27 +709,26 @@ NS_ASSUME_NONNULL_BEGIN NSError *error1; NSRegularExpression *regex1 = [[NSRegularExpression alloc] initWithPattern:@"@\\w*" options:0 error:&error1]; OWSAssertDebug(error1 == nil); - NSSet *knownUserIDs = LKAPI.userIDCache[threadID]; + NSSet *knownUserIDs = LKAPI.userIDCache[thread.uniqueId]; NSMutableSet *mentions = [NSMutableSet new]; NSTextCheckingResult *match1 = [regex1 firstMatchInString:text options:NSMatchingWithoutAnchoringBounds range:NSMakeRange(0, text.length)]; - if (match1 != nil) { + 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 = [Environment.shared.contactsManager attributedContactOrProfileNameForPhoneIdentifier:userID primaryFont:font secondaryFont:font].string; - if ([userDisplayName isEqual:userID]) { - [OWSPrimaryStorage.sharedManager.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { - NSString *collection = [NSString stringWithFormat:@"%@.%llu", LKGroupChatAPI.publicChatServer, LKGroupChatAPI.publicChatServerID]; - NSString *userDisplayNameCandidate = [transaction objectForKey:userID inCollection:collection]; - if (userDisplayNameCandidate != nil) { - userDisplayName = userDisplayNameCandidate; - } - }]; + __block NSString *userDisplayName; + [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; } - 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; } diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h index 7ac47aa47..8a1b8f91d 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h @@ -5,9 +5,11 @@ NS_ASSUME_NONNULL_BEGIN @class ConversationStyle; +@class LKUserSelectionView; @class OWSLinkPreviewDraft; @class OWSQuotedReplyModel; @class SignalAttachment; +@class TSThread; @protocol ConversationInputToolbarDelegate @@ -27,6 +29,8 @@ NS_ASSUME_NONNULL_BEGIN - (void)voiceMemoGestureDidUpdateCancelWithRatioComplete:(CGFloat)cancelAlpha; +- (void)handleUserSelected:(NSString *)user from:(LKUserSelectionView *)userSelectionView; + @end #pragma mark - @@ -80,6 +84,12 @@ NS_ASSUME_NONNULL_BEGIN - (void)hideInputMethod; +#pragma mark - User Selection View + +- (void)showUserSelectionViewFor:(NSArray *)users in:(TSThread *)thread; + +- (void)hideUserSelectionView; + @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m index 83044897c..44cc78e88 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m @@ -50,7 +50,8 @@ const CGFloat kMaxTextViewHeight = 98; @interface ConversationInputToolbar () + LinkPreviewViewDraftDelegate, + LKUserSelectionViewDelegate> @property (nonatomic, readonly) ConversationStyle *conversationStyle; @@ -85,6 +86,8 @@ const CGFloat kMaxTextViewHeight = 98; @property (nonatomic, nullable) InputLinkPreview *inputLinkPreview; @property (nonatomic) BOOL wasLinkPreviewCancelled; @property (nonatomic, nullable, weak) LinkPreviewView *linkPreviewView; +@property (nonatomic) LKUserSelectionView *userSelectionView; +@property (nonatomic) NSLayoutConstraint *userSelectionViewSizeConstraint; @end @@ -219,6 +222,14 @@ const CGFloat kMaxTextViewHeight = 98; [vStackWrapper setContentHuggingHorizontalLow]; [vStackWrapper setCompressionResistanceHorizontalLow]; + // User Selection View + _userSelectionView = [LKUserSelectionView new]; + [self addSubview:self.userSelectionView]; + [self.userSelectionView autoPinEdgeToSuperviewEdge:ALEdgeTop]; + [self.userSelectionView autoPinWidthToSuperview]; + self.userSelectionViewSizeConstraint = [self.userSelectionView autoSetDimension:ALDimensionHeight toSize:0]; + self.userSelectionView.delegate = self; + // H Stack _hStack = [[UIStackView alloc] initWithArrangedSubviews:@[ /*self.attachmentButton,*/ vStackWrapper, /*self.voiceMemoButton,*/ self.sendButton ]]; @@ -229,7 +240,7 @@ const CGFloat kMaxTextViewHeight = 98; self.hStack.spacing = 8; [self addSubview:self.hStack]; - [self.hStack autoPinEdgeToSuperviewEdge:ALEdgeTop]; + [self.hStack autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:self.userSelectionView]; [self.hStack autoPinEdgeToSuperviewSafeArea:ALEdgeBottom]; [self.hStack setContentHuggingHorizontalLow]; [self.hStack setCompressionResistanceHorizontalLow]; @@ -1078,6 +1089,25 @@ const CGFloat kMaxTextViewHeight = 98; self.borderView.hidden = YES; } +#pragma mark - User Selection View + +- (void)showUserSelectionViewFor:(NSArray *)users in:(TSThread *)thread +{ + self.userSelectionView.hasGroupContext = thread.isGroupThread; // Must happen before setting the users + self.userSelectionView.users = users; + self.userSelectionViewSizeConstraint.constant = 4 + MIN(users.count, 4) * 52 + 4; +} + +- (void)hideUserSelectionView +{ + self.userSelectionViewSizeConstraint.constant = 0; +} + +- (void)handleUserSelected:(NSString *)user from:(LKUserSelectionView *)userSelectionView +{ + [self.inputToolbarDelegate handleUserSelected:user from:userSelectionView]; +} + @end NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 8c36f16ba..be2caca11 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -213,6 +213,8 @@ typedef enum : NSUInteger { @property (nonatomic) CGFloat extraContentInsetPadding; @property (nonatomic) CGFloat contentInsetBottom; +@property (nonatomic) NSInteger mentionStartIndex; + @end #pragma mark - @@ -256,6 +258,8 @@ typedef enum : NSUInteger { _recordVoiceNoteAudioActivity = [[OWSAudioActivity alloc] initWithAudioDescription:audioActivityDescription behavior:OWSAudioBehavior_PlayAndRecord]; self.scrollContinuity = kScrollContinuityBottom; + + _mentionStartIndex = -1; } #pragma mark - Dependencies @@ -598,7 +602,7 @@ typedef enum : NSUInteger { [super viewDidLoad]; [self createContents]; - + [self registerCellClasses]; [self createConversationScrollButtons]; @@ -670,7 +674,7 @@ typedef enum : NSUInteger { self.inputToolbar.inputTextViewDelegate = self; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _inputToolbar); [self updateInputToolbar]; - + self.loadMoreHeader = [UILabel new]; self.loadMoreHeader.text = NSLocalizedString(@"CONVERSATION_VIEW_LOADING_MORE_MESSAGES", @"Indicates that the app is loading more messages in this conversation."); @@ -3768,6 +3772,32 @@ typedef enum : NSUInteger { if (textView.text.length > 0) { [self.typingIndicators didStartTypingOutgoingInputInThread:self.thread]; } + NSUInteger currentEndIndex = (textView.text.length != 0) ? textView.text.length - 1 : 0; + unichar lastCharacter = [textView.text characterAtIndex:currentEndIndex]; + NSMutableCharacterSet *allowedCharacters = NSMutableCharacterSet.lowercaseLetterCharacterSet; + [allowedCharacters formUnionWithCharacterSet:NSCharacterSet.uppercaseLetterCharacterSet]; + if (lastCharacter == '@') { + NSArray *userIDs = [LKAPI getUserIDsFor:@"" in:self.thread.uniqueId]; + self.mentionStartIndex = (NSInteger)currentEndIndex + 1; + [self.inputToolbar showUserSelectionViewFor:userIDs in:self.thread]; + } else if (![allowedCharacters characterIsMember:lastCharacter]) { + self.mentionStartIndex = -1; + [self.inputToolbar hideUserSelectionView]; + } else { + if (self.mentionStartIndex != -1) { + NSString *query = [textView.text substringFromIndex:(NSUInteger)self.mentionStartIndex]; + NSArray *userIDs = [LKAPI getUserIDsFor:query in:self.thread.uniqueId]; + [self.inputToolbar showUserSelectionViewFor:userIDs in:self.thread]; + } + } +} + +- (void)handleUserSelected:(NSString *)user from:(LKUserSelectionView *)userSelectionView +{ + NSString *oldText = self.inputToolbar.messageText; + NSUInteger mentionStartIndex = (NSUInteger)self.mentionStartIndex; + NSString *newText = [oldText stringByReplacingCharactersInRange:NSMakeRange(mentionStartIndex, oldText.length - mentionStartIndex) withString:user]; + [self.inputToolbar setMessageText:newText animated:NO]; } - (void)inputTextViewSendMessagePressed diff --git a/SignalServiceKit/src/Loki/API/LokiAPI.swift b/SignalServiceKit/src/Loki/API/LokiAPI.swift index a8a935854..11c7e805f 100644 --- a/SignalServiceKit/src/Loki/API/LokiAPI.swift +++ b/SignalServiceKit/src/Loki/API/LokiAPI.swift @@ -306,6 +306,30 @@ public final class LokiAPI : NSObject { } } + @objc public static func getUserIDs(for query: String, in threadID: String) -> [String] { + // Prepare + guard let cache = userIDCache[threadID] else { return [] } + var candidates: [(id: String, displayName: String)] = [] + // Gather candidates + storage.dbReadConnection.read { transaction in + let collection = "\(LokiGroupChatAPI.publicChatServer).\(LokiGroupChatAPI.publicChatServerID)" + candidates = cache.flatMap { id in + guard let displayName = transaction.object(forKey: id, inCollection: collection) as! String? else { return nil } + return (id: id, displayName: displayName) + } + } + // Sort alphabetically first + candidates.sort { $0.displayName < $1.displayName } + if query.count >= 2 { + // Filter out any non-matching candidates + candidates = candidates.filter { $0.displayName.contains(query) } + // Sort based on where in the candidate the query occurs + candidates.sort { $0.displayName.range(of: query)!.lowerBound < $1.displayName.range(of: query)!.lowerBound } + } + // Return + return candidates.map { $0.id } // Inefficient to do this and then look up the display name again later, but easy to interface with Obj-C + } + @objc public static func populateUserIDCacheIfNeeded(for threadID: String, in transaction: YapDatabaseReadWriteTransaction? = nil) { guard userIDCache[threadID] == nil else { return } var result: Set = []