From 168066b7a2e1a9d4d02ec9da85f09bc2ade2c8b6 Mon Sep 17 00:00:00 2001 From: nielsandriesse Date: Wed, 17 Feb 2021 14:26:43 +1100 Subject: [PATCH] Implement rough mentions system --- Session.xcodeproj/project.pbxproj | 4 + .../ConversationVC+Interaction.swift | 81 +++++++- Session/Conversations V2/ConversationVC.swift | 6 +- .../Input View/InputView.swift | 51 ++++- .../Input View/MentionSelectionView.swift | 179 ++++++++++++++++++ 5 files changed, 314 insertions(+), 7 deletions(-) create mode 100644 Session/Conversations V2/Input View/MentionSelectionView.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 363621cd8..40c8c33cb 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -327,6 +327,7 @@ C300A60D2554B31900555489 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5CE2553860700C340D1 /* Logging.swift */; }; C300A6322554B6D100555489 /* NSDate+Timestamp.mm in Sources */ = {isa = PBXBuildFile; fileRef = C300A6312554B6D100555489 /* NSDate+Timestamp.mm */; }; C300A63B2554B72200555489 /* NSDate+Timestamp.h in Headers */ = {isa = PBXBuildFile; fileRef = C300A6302554B68200555489 /* NSDate+Timestamp.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C302093D25DCBF07001F572D /* MentionSelectionView.swift */; }; C31A6C5A247F214E001123EF /* UIView+Glow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31A6C59247F214E001123EF /* UIView+Glow.swift */; }; C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */; }; C31D1DE32521718E005D4DA8 /* UserSelectionVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31D1DE22521718E005D4DA8 /* UserSelectionVC.swift */; }; @@ -1371,6 +1372,7 @@ C300A5FB2554B0A000555489 /* MessageReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiver.swift; sourceTree = ""; }; C300A6302554B68200555489 /* NSDate+Timestamp.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSDate+Timestamp.h"; sourceTree = ""; }; C300A6312554B6D100555489 /* NSDate+Timestamp.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = "NSDate+Timestamp.mm"; sourceTree = ""; }; + C302093D25DCBF07001F572D /* MentionSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionSelectionView.swift; sourceTree = ""; }; C31A6C59247F214E001123EF /* UIView+Glow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Glow.swift"; sourceTree = ""; }; C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGRect+Utilities.swift"; sourceTree = ""; }; C31D1DDC25217014005D4DA8 /* UserCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCell.swift; sourceTree = ""; }; @@ -2295,6 +2297,7 @@ B8269D3225C7A8C600488AB4 /* InputViewButton.swift */, B8269D3C25C7B34D00488AB4 /* InputTextView.swift */, C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */, + C302093D25DCBF07001F572D /* MentionSelectionView.swift */, ); path = "Input View"; sourceTree = ""; @@ -5161,6 +5164,7 @@ B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */, 346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */, 45E5A6991F61E6DE001E4A8A /* MarqueeLabel.swift in Sources */, + C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */, 34D1F0B01F867BFC0066283D /* OWSSystemMessageCell.m in Sources */, C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */, B86BD08423399ACF000F5AE3 /* Modal.swift in Sources */, diff --git a/Session/Conversations V2/ConversationVC+Interaction.swift b/Session/Conversations V2/ConversationVC+Interaction.swift index 9649728a1..303003d8a 100644 --- a/Session/Conversations V2/ConversationVC+Interaction.swift +++ b/Session/Conversations V2/ConversationVC+Interaction.swift @@ -44,7 +44,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) { sendAttachments(attachments, with: messageText ?? "") scrollToBottom(isAnimated: false) - // TODO: Reset mentions + resetMentions() self.snInputView.text = "" dismiss(animated: true) { } } @@ -60,7 +60,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) { sendAttachments(attachments, with: messageText ?? "") scrollToBottom(isAnimated: false) - // TODO: Reset mentions + resetMentions() self.snInputView.text = "" dismiss(animated: true) { } } @@ -186,7 +186,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc func sendMessage() { guard !showBlockedModalIfNeeded() else { return } - let text = snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines) + let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines)) let thread = self.thread guard !text.isEmpty else { return } let message = VisibleMessage() @@ -220,7 +220,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc let thread = self.thread let message = VisibleMessage() message.sentTimestamp = NSDate.millisecondTimestamp() - message.text = text + message.text = replaceMentions(in: text) let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread) Storage.write(with: { transaction in tsMessage.save(with: transaction) @@ -233,7 +233,7 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } func handleMessageSent() { - // TODO: Reset mentions + resetMentions() self.snInputView.text = "" self.snInputView.quoteDraftInfo = nil self.markAllAsRead() @@ -244,6 +244,77 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc SSKEnvironment.shared.typingIndicators.didSendOutgoingMessage(inThread: thread) } + // MARK: Input View + func inputTextViewDidChangeContent(_ inputTextView: InputTextView) { + let newText = inputTextView.text ?? "" + if !newText.isEmpty { + SSKEnvironment.shared.typingIndicators.didStartTypingOutgoingInput(inThread: thread) + } + updateMentions(for: newText) + } + + // MARK: Mentions + private func updateMentions(for newText: String) { + if newText.count < oldText.count { + currentMentionStartIndex = nil + snInputView.hideMentionsUI() + mentions = mentions.filter { $0.isContained(in: newText) } + } + if !newText.isEmpty { + let lastCharacterIndex = newText.index(before: newText.endIndex) + let lastCharacter = newText[lastCharacterIndex] + // Check if there is a whitespace before the '@' or the '@' is the first character + let isCharacterBeforeLastAtSignOrStartOfLine: Bool + if newText.count == 1 { + isCharacterBeforeLastAtSignOrStartOfLine = true // Start of line + } else { + let characterBeforeLast = newText[newText.index(before: lastCharacterIndex)] + isCharacterBeforeLastAtSignOrStartOfLine = (characterBeforeLast == "@") + } + if lastCharacter == "@" && isCharacterBeforeLastAtSignOrStartOfLine { + let candidates = MentionsManager.getMentionCandidates(for: "", in: thread.uniqueId!) + currentMentionStartIndex = lastCharacterIndex + snInputView.showMentionsUI(for: candidates, in: thread) + } else if lastCharacter.isWhitespace { + currentMentionStartIndex = nil + snInputView.hideMentionsUI() + } else { + if let currentMentionStartIndex = currentMentionStartIndex { + let query = String(newText[newText.index(after: currentMentionStartIndex)...]) // + 1 to get rid of the @ + let candidates = MentionsManager.getMentionCandidates(for: query, in: thread.uniqueId!) + snInputView.showMentionsUI(for: candidates, in: thread) + } + } + } + oldText = newText + } + + private func resetMentions() { + oldText = "" + currentMentionStartIndex = nil + mentions = [] + } + + private func replaceMentions(in text: String) -> String { + var result = text + for mention in mentions { + guard let range = result.range(of: "@\(mention.displayName)") else { continue } + result = result.replacingCharacters(in: range, with: "@\(mention.publicKey)") + } + return result + } + + func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) { + guard let currentMentionStartIndex = currentMentionStartIndex else { return } + mentions.append(mention) + let oldText = snInputView.text + let newText = oldText.replacingCharacters(in: currentMentionStartIndex..., with: "@\(mention.displayName)") + snInputView.text = newText + self.currentMentionStartIndex = nil + snInputView.hideMentionsUI() + self.oldText = newText + } + // MARK: View Item Interaction func handleViewItemLongPressed(_ viewItem: ConversationViewItem) { guard let index = viewItems.firstIndex(where: { $0 === viewItem }), diff --git a/Session/Conversations V2/ConversationVC.swift b/Session/Conversations V2/ConversationVC.swift index a74bacc5e..524355c69 100644 --- a/Session/Conversations V2/ConversationVC.swift +++ b/Session/Conversations V2/ConversationVC.swift @@ -1,7 +1,7 @@ // TODO // • Tapping replies -// • Mentions +// • Moderator icons // • Slight paging glitch // • Scrolling bug // • Scroll button bug @@ -19,6 +19,10 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, UITableViewD // Context menu var contextMenuWindow: ContextMenuWindow? var contextMenuVC: ContextMenuVC? + // Mentions + var oldText = "" + var currentMentionStartIndex: String.Index? + var mentions: [Mention] = [] // Scrolling & paging private var isUserScrolling = false private var hasPerformedInitialScroll = false diff --git a/Session/Conversations V2/Input View/InputView.swift b/Session/Conversations V2/Input View/InputView.swift index e51c87caa..225c955cb 100644 --- a/Session/Conversations V2/Input View/InputView.swift +++ b/Session/Conversations V2/Input View/InputView.swift @@ -1,9 +1,10 @@ -final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, QuoteViewDelegate, LinkPreviewViewV2Delegate { +final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, QuoteViewDelegate, LinkPreviewViewV2Delegate, MentionSelectionViewDelegate { private let delegate: InputViewDelegate var quoteDraftInfo: (model: OWSQuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } var linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)? private var voiceMessageRecordingView: VoiceMessageRecordingView? + private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0) private lazy var linkPreviewView: LinkPreviewViewV2 = { let maxWidth = self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset @@ -29,6 +30,13 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, return result }() private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton) + + private lazy var mentionsView: MentionSelectionView = { + let result = MentionSelectionView() + result.alpha = 0 + result.delegate = self + return result + }() private lazy var inputTextView = InputTextView(delegate: self) @@ -93,6 +101,11 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, mainStackView.pin(.top, to: .bottom, of: separator) mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) mainStackView.pin(.bottom, to: .bottom, of: self, withInset: -2) + // Mentions + addSubview(mentionsView) + mentionsViewHeightConstraint.isActive = true + mentionsView.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self) + mentionsView.pin(.bottom, to: .top, of: self) // Voice message button addSubview(voiceMessageButtonContainer) voiceMessageButtonContainer.center(in: sendButton) @@ -108,6 +121,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, sendButton.isHidden = !hasText voiceMessageButtonContainer.isHidden = hasText autoGenerateLinkPreviewIfPossible() + delegate.inputTextViewDidChangeContent(inputTextView) } private func handleQuoteDraftChanged() { @@ -174,6 +188,14 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, } // MARK: Interaction + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + if mentionsView.frame.contains(point) { + return true + } else { + return super.point(inside: point, with: event) + } + } + func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) { if inputViewButton == cameraButton { delegate.handleCameraButtonTapped() } if inputViewButton == libraryButton { delegate.handleLibraryButtonTapped() } @@ -243,6 +265,32 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, }) } + func hideMentionsUI() { + UIView.animate(withDuration: 0.25, animations: { + self.mentionsView.alpha = 0 + }, completion: { _ in + self.mentionsViewHeightConstraint.constant = 0 + self.mentionsView.tableView.contentOffset = CGPoint.zero + }) + } + + func showMentionsUI(for candidates: [Mention], in thread: TSThread) { + if let openGroup = Storage.shared.getOpenGroup(for: thread.uniqueId!) { + mentionsView.publicChatServer = openGroup.server + mentionsView.publicChatChannel = openGroup.channel + } + mentionsView.mentionCandidates = candidates + mentionsViewHeightConstraint.constant = CGFloat(candidates.count * 42) + layoutIfNeeded() + UIView.animate(withDuration: 0.25) { + self.mentionsView.alpha = 1 + } + } + + func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) { + print(mention.displayName) + } + // MARK: Convenience private func container(for button: InputViewButton) -> UIView { let result = UIView() @@ -264,4 +312,5 @@ protocol InputViewDelegate : VoiceMessageRecordingViewDelegate { func handleDocumentButtonTapped() func handleSendButtonTapped() func handleQuoteViewCancelButtonTapped() + func inputTextViewDidChangeContent(_ inputTextView: InputTextView) } diff --git a/Session/Conversations V2/Input View/MentionSelectionView.swift b/Session/Conversations V2/Input View/MentionSelectionView.swift new file mode 100644 index 000000000..a1ce1d4b2 --- /dev/null +++ b/Session/Conversations V2/Input View/MentionSelectionView.swift @@ -0,0 +1,179 @@ + +final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDelegate { + @objc var mentionCandidates: [Mention] = [] { didSet { tableView.reloadData() } } + @objc var publicChatServer: String? + var publicChatChannel: UInt64? + @objc var delegate: MentionSelectionViewDelegate? + + // MARK: Convenience + @objc(setPublicChatChannel:) + func setPublicChatChannel(to publicChatChannel: UInt64) { + self.publicChatChannel = publicChatChannel != 0 ? publicChatChannel : nil + } + + // MARK: Components + @objc lazy var tableView: UITableView = { // TODO: Make this private + let result = UITableView() + result.dataSource = self + result.delegate = self + result.register(Cell.self, forCellReuseIdentifier: "Cell") + result.separatorStyle = .none + result.backgroundColor = .clear + result.showsVerticalScrollIndicator = false + 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) + let topSeparator = UIView() + topSeparator.backgroundColor = Colors.separator + topSeparator.set(.height, to: Values.separatorThickness) + addSubview(topSeparator) + topSeparator.pin(.leading, to: .leading, of: self) + topSeparator.pin(.top, to: .top, of: self) + topSeparator.pin(.trailing, to: .trailing, of: self) + let bottomSeparator = UIView() + bottomSeparator.backgroundColor = Colors.separator + bottomSeparator.set(.height, to: Values.separatorThickness) + addSubview(bottomSeparator) + bottomSeparator.pin(.leading, to: .leading, of: self) + bottomSeparator.pin(.trailing, to: .trailing, of: self) + bottomSeparator.pin(.bottom, to: .bottom, of: self) + } + + // MARK: Data + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return mentionCandidates.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! Cell + let mentionCandidate = mentionCandidates[indexPath.row] + cell.mentionCandidate = mentionCandidate + cell.publicChatServer = publicChatServer + cell.publicChatChannel = publicChatChannel + cell.separator.isHidden = (indexPath.row == (mentionCandidates.count - 1)) + return cell + } + + // MARK: Interaction + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let mentionCandidate = mentionCandidates[indexPath.row] + delegate?.handleMentionSelected(mentionCandidate, from: self) + } +} + +// MARK: - Cell + +private extension MentionSelectionView { + + final class Cell : UITableViewCell { + var mentionCandidate = Mention(publicKey: "", displayName: "") { didSet { update() } } + var publicChatServer: String? + var publicChatChannel: UInt64? + + // MARK: Components + private lazy var profilePictureView = ProfilePictureView() + + private lazy var moderatorIconImageView: UIImageView = { + let result = UIImageView(image: #imageLiteral(resourceName: "Crown")) + return result + }() + + private lazy var displayNameLabel: UILabel = { + let result = UILabel() + result.textColor = Colors.text + result.font = .systemFont(ofSize: Values.smallFontSize) + result.lineBreakMode = .byTruncatingTail + return result + }() + + lazy var separator: UIView = { + let result = UIView() + result.backgroundColor = Colors.separator + result.set(.height, to: Values.separatorThickness) + 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() { + // Set the cell background color + backgroundColor = Colors.cellBackground + // Set up the highlight color + let selectedBackgroundView = UIView() + selectedBackgroundView.backgroundColor = Colors.cellBackground // Intentionally not Colors.cellSelected + self.selectedBackgroundView = selectedBackgroundView + // Set up the profile picture image view + let profilePictureViewSize = Values.verySmallProfilePictureSize + profilePictureView.set(.width, to: profilePictureViewSize) + profilePictureView.set(.height, to: profilePictureViewSize) + profilePictureView.size = profilePictureViewSize + // Set up the main stack view + let stackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel ]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = Values.mediumSpacing + stackView.set(.height, to: profilePictureViewSize) + contentView.addSubview(stackView) + stackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing) + stackView.pin(.top, to: .top, of: contentView, withInset: Values.smallSpacing) + contentView.pin(.trailing, to: .trailing, of: stackView, withInset: Values.mediumSpacing) + contentView.pin(.bottom, to: .bottom, of: stackView, withInset: Values.smallSpacing) + stackView.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing) + // 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: profilePictureView) + moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 3.5) + // Set up the separator + addSubview(separator) + separator.pin(.leading, to: .leading, of: self) + separator.pin(.trailing, to: .trailing, of: self) + separator.pin(.bottom, to: .bottom, of: self) + } + + // MARK: Updating + private func update() { + displayNameLabel.text = mentionCandidate.displayName + profilePictureView.hexEncodedPublicKey = mentionCandidate.publicKey + profilePictureView.update() + if let server = publicChatServer, let channel = publicChatChannel { + let isUserModerator = OpenGroupAPI.isUserModerator(mentionCandidate.publicKey, for: channel, on: server) + moderatorIconImageView.isHidden = !isUserModerator + } else { + moderatorIconImageView.isHidden = true + } + } + } +} + +// MARK: - Delegate + +@objc(LKMentionSelectionViewDelegate) +protocol MentionSelectionViewDelegate { + + func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) +}