Implement user selection UI

This commit is contained in:
Niels Andriesse 2019-10-09 14:46:21 +11:00
parent ea3da42faf
commit 10eead529f
9 changed files with 268 additions and 23 deletions

View File

@ -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 = "<group>"; };
B894D0742339EDCF00B4D94D /* NukeDataModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NukeDataModal.swift; sourceTree = "<group>"; };
B89841E222B7579F00B1BDC6 /* NewConversationVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewConversationVC.swift; sourceTree = "<group>"; };
B8B26C8E234D629C004ED98C /* UserSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSelectionView.swift; sourceTree = "<group>"; };
B8B26C90234D8CBD004ED98C /* UserSelectionViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSelectionViewDelegate.swift; sourceTree = "<group>"; };
B90418E4183E9DD40038554A /* DateUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DateUtil.h; sourceTree = "<group>"; };
B90418E5183E9DD40038554A /* DateUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DateUtil.m; sourceTree = "<group>"; };
B97940251832BD2400BD66CB /* UIUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UIUtil.h; sourceTree = "<group>"; };
@ -2651,6 +2655,8 @@
B89841E222B7579F00B1BDC6 /* NewConversationVC.swift */,
B8258491230FA5DA001B41CB /* ScanQRCodeVC.h */,
B8258492230FA5E9001B41CB /* ScanQRCodeVC.m */,
B8B26C8E234D629C004ED98C /* UserSelectionView.swift */,
B8B26C90234D8CBD004ED98C /* UserSelectionViewDelegate.swift */,
);
path = Loki;
sourceTree = "<group>";
@ -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 */,

View File

@ -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
}
}
}

View File

@ -0,0 +1,6 @@
@objc(LKUserSelectionViewDelegate)
protocol UserSelectionViewDelegate {
func handleUserSelected(_ user: String, from userSelectionView: UserSelectionView)
}

View File

@ -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 {

View File

@ -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<NSString *> *knownUserIDs = LKAPI.userIDCache[threadID];
NSSet<NSString *> *knownUserIDs = LKAPI.userIDCache[thread.uniqueId];
NSMutableSet<NSValue *> *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;
}

View File

@ -5,9 +5,11 @@
NS_ASSUME_NONNULL_BEGIN
@class ConversationStyle;
@class LKUserSelectionView;
@class OWSLinkPreviewDraft;
@class OWSQuotedReplyModel;
@class SignalAttachment;
@class TSThread;
@protocol ConversationInputToolbarDelegate <NSObject>
@ -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<NSString *> *)users in:(TSThread *)thread;
- (void)hideUserSelectionView;
@end
NS_ASSUME_NONNULL_END

View File

@ -50,7 +50,8 @@ const CGFloat kMaxTextViewHeight = 98;
@interface ConversationInputToolbar () <ConversationTextViewToolbarDelegate,
QuotedReplyPreviewDelegate,
LinkPreviewViewDraftDelegate>
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<NSString *> *)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

View File

@ -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<NSString *> *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<NSString *> *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

View File

@ -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<String> = []