From 299cfedc02b2ba454be574b29933c303b05badda Mon Sep 17 00:00:00 2001 From: nielsandriesse Date: Wed, 17 Feb 2021 15:57:07 +1100 Subject: [PATCH] Clean up mentions UI --- .../ConversationVC+Interaction.swift | 44 +++++++++ Session/Conversations V2/ConversationVC.swift | 46 +--------- .../Input View/InputView.swift | 40 ++++++--- .../Input View/MentionSelectionView.swift | 85 +++++++++--------- .../Meta/Images.xcassets/Loki/Contents.json | 6 +- .../Loki/Crown.imageset/Contents.json | 10 +-- .../Loki/Crown.imageset/crown.pdf | Bin 1802 -> 7165 bytes 7 files changed, 125 insertions(+), 106 deletions(-) diff --git a/Session/Conversations V2/ConversationVC+Interaction.swift b/Session/Conversations V2/ConversationVC+Interaction.swift index 303003d8a..0df2678d8 100644 --- a/Session/Conversations V2/ConversationVC+Interaction.swift +++ b/Session/Conversations V2/ConversationVC+Interaction.swift @@ -253,6 +253,15 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc updateMentions(for: newText) } + func showLinkPreviewSuggestionModal() { + let linkPreviewModel = LinkPreviewModal() { [weak self] in + self?.snInputView.autoGenerateLinkPreview() + } + linkPreviewModel.modalPresentationStyle = .overFullScreen + linkPreviewModel.modalTransitionStyle = .crossDissolve + present(linkPreviewModel, animated: true, completion: nil) + } + // MARK: Mentions private func updateMentions(for newText: String) { if newText.count < oldText.count { @@ -378,6 +387,33 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } } + func showFailedMessageSheet(for tsMessage: TSOutgoingMessage) { + let thread = self.thread + let sheet = UIAlertController(title: tsMessage.mostRecentFailureText, message: nil, preferredStyle: .actionSheet) + sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) + sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in + Storage.write { transaction in + tsMessage.remove(with: transaction) + Storage.shared.cancelPendingMessageSendJobIfNeeded(for: tsMessage.timestamp, using: transaction) + } + })) + sheet.addAction(UIAlertAction(title: "Resend", style: .default, handler: { _ in + let message = VisibleMessage.from(tsMessage) + Storage.write { transaction in + var attachments: [TSAttachmentStream] = [] + tsMessage.attachmentIds.forEach { attachmentID in + guard let attachmentID = attachmentID as? String else { return } + let attachment = TSAttachment.fetch(uniqueId: attachmentID, transaction: transaction) + guard let stream = attachment as? TSAttachmentStream else { return } + attachments.append(stream) + } + MessageSender.prep(attachments, for: message, using: transaction) + MessageSender.send(message, in: thread, using: transaction) + } + })) + present(sheet, animated: true, completion: nil) + } + func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem) { switch viewItem.messageCellType { case .audio: speedUpAudio(for: viewItem) @@ -452,6 +488,14 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } // MARK: Voice Message Playback + @objc func handleAudioDidFinishPlayingNotification(_ notification: Notification) { + guard let audioPlayer = audioPlayer, let viewItem = audioPlayer.owner as? ConversationViewItem, + let index = viewItems.firstIndex(where: { $0 === viewItem }), index < (viewItems.endIndex - 1) else { return } + let nextViewItem = viewItems[index + 1] + guard nextViewItem.messageCellType == .audio else { return } + playOrPauseAudio(for: nextViewItem) + } + func playOrPauseAudio(for viewItem: ConversationViewItem) { guard let attachment = viewItem.attachmentStream else { return } let fileManager = FileManager.default diff --git a/Session/Conversations V2/ConversationVC.swift b/Session/Conversations V2/ConversationVC.swift index 524355c69..cd37e2a56 100644 --- a/Session/Conversations V2/ConversationVC.swift +++ b/Session/Conversations V2/ConversationVC.swift @@ -124,6 +124,8 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, UITableViewD notificationCenter.addObserver(self, selector: #selector(handleKeyboardWillHideNotification(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) notificationCenter.addObserver(self, selector: #selector(handleAudioDidFinishPlayingNotification(_:)), name: .SNAudioDidFinishPlaying, object: nil) notificationCenter.addObserver(self, selector: #selector(addOrRemoveBlockedBanner), name: NSNotification.Name(rawValue: kNSNotificationName_BlockListDidChange), object: nil) + // Mentions + MentionsManager.populateUserPublicKeyCacheIfNeeded(for: thread.uniqueId!) } override func viewDidLayoutSubviews() { @@ -323,14 +325,6 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, UITableViewD func getMediaCache() -> NSCache { return mediaCache } - - @objc private func handleAudioDidFinishPlayingNotification(_ notification: Notification) { - guard let audioPlayer = audioPlayer, let viewItem = audioPlayer.owner as? ConversationViewItem, - let index = viewItems.firstIndex(where: { $0 === viewItem }), index < (viewItems.endIndex - 1) else { return } - let nextViewItem = viewItems[index + 1] - guard nextViewItem.messageCellType == .audio else { return } - playOrPauseAudio(for: nextViewItem) - } func scrollToBottom(isAnimated: Bool) { guard !isUserScrolling else { return } @@ -363,42 +357,6 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, UITableViewD isLoadingMore = true viewModel.loadAnotherPageOfMessages() } - - func showLinkPreviewSuggestionModal() { - let linkPreviewModel = LinkPreviewModal() { [weak self] in - self?.snInputView.autoGenerateLinkPreview() - } - linkPreviewModel.modalPresentationStyle = .overFullScreen - linkPreviewModel.modalTransitionStyle = .crossDissolve - present(linkPreviewModel, animated: true, completion: nil) - } - - func showFailedMessageSheet(for tsMessage: TSOutgoingMessage) { - let thread = self.thread - let sheet = UIAlertController(title: tsMessage.mostRecentFailureText, message: nil, preferredStyle: .actionSheet) - sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) - sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in - Storage.write { transaction in - tsMessage.remove(with: transaction) - Storage.shared.cancelPendingMessageSendJobIfNeeded(for: tsMessage.timestamp, using: transaction) - } - })) - sheet.addAction(UIAlertAction(title: "Resend", style: .default, handler: { _ in - let message = VisibleMessage.from(tsMessage) - Storage.write { transaction in - var attachments: [TSAttachmentStream] = [] - tsMessage.attachmentIds.forEach { attachmentID in - guard let attachmentID = attachmentID as? String else { return } - let attachment = TSAttachment.fetch(uniqueId: attachmentID, transaction: transaction) - guard let stream = attachment as? TSAttachmentStream else { return } - attachments.append(stream) - } - MessageSender.prep(attachments, for: message, using: transaction) - MessageSender.send(message, in: thread, using: transaction) - } - })) - present(sheet, animated: true, completion: nil) - } // MARK: Convenience private func getScrollButtonOpacity() -> CGFloat { diff --git a/Session/Conversations V2/Input View/InputView.swift b/Session/Conversations V2/Input View/InputView.swift index 225c955cb..26b483eca 100644 --- a/Session/Conversations V2/Input View/InputView.swift +++ b/Session/Conversations V2/Input View/InputView.swift @@ -33,10 +33,22 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, private lazy var mentionsView: MentionSelectionView = { let result = MentionSelectionView() - result.alpha = 0 result.delegate = self return result }() + + private lazy var mentionsViewContainer: UIView = { + let result = UIView() + let backgroundView = UIView() + backgroundView.backgroundColor = isLightMode ? .white : .black + backgroundView.alpha = Values.lowOpacity + result.addSubview(backgroundView) + backgroundView.pin(to: result) + let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + result.addSubview(blurView) + blurView.pin(to: result) + return result + }() private lazy var inputTextView = InputTextView(delegate: self) @@ -102,10 +114,12 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) mainStackView.pin(.bottom, to: .bottom, of: self, withInset: -2) // Mentions - addSubview(mentionsView) + addSubview(mentionsViewContainer) + mentionsViewContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self) + mentionsViewContainer.pin(.bottom, to: .top, of: self) + mentionsViewContainer.addSubview(mentionsView) + mentionsView.pin(to: mentionsViewContainer) 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) @@ -189,7 +203,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, // MARK: Interaction override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - if mentionsView.frame.contains(point) { + if mentionsViewContainer.frame.contains(point) { return true } else { return super.point(inside: point, with: event) @@ -267,7 +281,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, func hideMentionsUI() { UIView.animate(withDuration: 0.25, animations: { - self.mentionsView.alpha = 0 + self.mentionsViewContainer.alpha = 0 }, completion: { _ in self.mentionsViewHeightConstraint.constant = 0 self.mentionsView.tableView.contentOffset = CGPoint.zero @@ -276,19 +290,20 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, 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.openGroupServer = openGroup.server + mentionsView.openGroupChannel = openGroup.channel } - mentionsView.mentionCandidates = candidates - mentionsViewHeightConstraint.constant = CGFloat(candidates.count * 42) + mentionsView.candidates = candidates + let mentionCellHeight = Values.smallProfilePictureSize + 2 * Values.smallSpacing + mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight layoutIfNeeded() UIView.animate(withDuration: 0.25) { - self.mentionsView.alpha = 1 + self.mentionsViewContainer.alpha = 1 } } func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) { - print(mention.displayName) + delegate.handleMentionSelected(mention, from: view) } // MARK: Convenience @@ -313,4 +328,5 @@ protocol InputViewDelegate : VoiceMessageRecordingViewDelegate { func handleSendButtonTapped() func handleQuoteViewCancelButtonTapped() func inputTextViewDidChangeContent(_ inputTextView: InputTextView) + func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) } diff --git a/Session/Conversations V2/Input View/MentionSelectionView.swift b/Session/Conversations V2/Input View/MentionSelectionView.swift index a1ce1d4b2..3debdc275 100644 --- a/Session/Conversations V2/Input View/MentionSelectionView.swift +++ b/Session/Conversations V2/Input View/MentionSelectionView.swift @@ -1,18 +1,17 @@ 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 + var candidates: [Mention] = [] { + didSet { + tableView.isScrollEnabled = (candidates.count > 4) + tableView.reloadData() + } } + var openGroupServer: String? + var openGroupChannel: UInt64? + var delegate: MentionSelectionViewDelegate? // MARK: Components - @objc lazy var tableView: UITableView = { // TODO: Make this private + lazy var tableView: UITableView = { // TODO: Make this private let result = UITableView() result.dataSource = self result.delegate = self @@ -35,8 +34,10 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel } private func setUpViewHierarchy() { + // Table view addSubview(tableView) tableView.pin(to: self) + // Top separator let topSeparator = UIView() topSeparator.backgroundColor = Colors.separator topSeparator.set(.height, to: Values.separatorThickness) @@ -44,6 +45,7 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel topSeparator.pin(.leading, to: .leading, of: self) topSeparator.pin(.top, to: .top, of: self) topSeparator.pin(.trailing, to: .trailing, of: self) + // Bottom separator let bottomSeparator = UIView() bottomSeparator.backgroundColor = Colors.separator bottomSeparator.set(.height, to: Values.separatorThickness) @@ -55,22 +57,22 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel // MARK: Data func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return mentionCandidates.count + return candidates.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! Cell - let mentionCandidate = mentionCandidates[indexPath.row] + let mentionCandidate = candidates[indexPath.row] cell.mentionCandidate = mentionCandidate - cell.publicChatServer = publicChatServer - cell.publicChatChannel = publicChatChannel - cell.separator.isHidden = (indexPath.row == (mentionCandidates.count - 1)) + cell.openGroupServer = openGroupServer + cell.openGroupChannel = openGroupChannel + cell.separator.isHidden = (indexPath.row == (candidates.count - 1)) return cell } // MARK: Interaction func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let mentionCandidate = mentionCandidates[indexPath.row] + let mentionCandidate = candidates[indexPath.row] delegate?.handleMentionSelected(mentionCandidate, from: self) } } @@ -81,8 +83,8 @@ private extension MentionSelectionView { final class Cell : UITableViewCell { var mentionCandidate = Mention(publicKey: "", displayName: "") { didSet { update() } } - var publicChatServer: String? - var publicChatChannel: UInt64? + var openGroupServer: String? + var openGroupChannel: UInt64? // MARK: Components private lazy var profilePictureView = ProfilePictureView() @@ -119,36 +121,36 @@ private extension MentionSelectionView { } private func setUpViewHierarchy() { - // Set the cell background color - backgroundColor = Colors.cellBackground - // Set up the highlight color + // Cell background color + backgroundColor = .clear + // Highlight color let selectedBackgroundView = UIView() - selectedBackgroundView.backgroundColor = Colors.cellBackground // Intentionally not Colors.cellSelected + selectedBackgroundView.backgroundColor = .clear self.selectedBackgroundView = selectedBackgroundView - // Set up the profile picture image view - let profilePictureViewSize = Values.verySmallProfilePictureSize + // Profile picture image view + let profilePictureViewSize = Values.smallProfilePictureSize 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 + // Main stack view + let mainStackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel ]) + mainStackView.axis = .horizontal + mainStackView.alignment = .center + mainStackView.spacing = Values.mediumSpacing + mainStackView.set(.height, to: profilePictureViewSize) + contentView.addSubview(mainStackView) + mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing) + mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.smallSpacing) + contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.mediumSpacing) + contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.smallSpacing) + mainStackView.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing) + // 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 + moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1) + moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5) + // Separator addSubview(separator) separator.pin(.leading, to: .leading, of: self) separator.pin(.trailing, to: .trailing, of: self) @@ -160,7 +162,7 @@ private extension MentionSelectionView { displayNameLabel.text = mentionCandidate.displayName profilePictureView.hexEncodedPublicKey = mentionCandidate.publicKey profilePictureView.update() - if let server = publicChatServer, let channel = publicChatChannel { + if let server = openGroupServer, let channel = openGroupChannel { let isUserModerator = OpenGroupAPI.isUserModerator(mentionCandidate.publicKey, for: channel, on: server) moderatorIconImageView.isHidden = !isUserModerator } else { @@ -172,7 +174,6 @@ private extension MentionSelectionView { // MARK: - Delegate -@objc(LKMentionSelectionViewDelegate) protocol MentionSelectionViewDelegate { func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) diff --git a/Session/Meta/Images.xcassets/Loki/Contents.json b/Session/Meta/Images.xcassets/Loki/Contents.json index da4a164c9..73c00596a 100644 --- a/Session/Meta/Images.xcassets/Loki/Contents.json +++ b/Session/Meta/Images.xcassets/Loki/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Session/Meta/Images.xcassets/Loki/Crown.imageset/Contents.json b/Session/Meta/Images.xcassets/Loki/Crown.imageset/Contents.json index 97010b1ad..f3c747220 100644 --- a/Session/Meta/Images.xcassets/Loki/Crown.imageset/Contents.json +++ b/Session/Meta/Images.xcassets/Loki/Crown.imageset/Contents.json @@ -1,12 +1,12 @@ { "images" : [ { - "idiom" : "universal", - "filename" : "crown.pdf" + "filename" : "Crown.pdf", + "idiom" : "universal" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Session/Meta/Images.xcassets/Loki/Crown.imageset/crown.pdf b/Session/Meta/Images.xcassets/Loki/Crown.imageset/crown.pdf index 5e5c424ca7b0782bf0bb5c82a1d06ed4690c386c..3e067f0b51d268543727ca31c0c1a6496cf144d9 100644 GIT binary patch literal 7165 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�@M-Uu}KZbZ9?1NS;+TwoK)|1GE&|d#O~wFQQM3m%Vq$LM7rt{6rR@)`;xD%NAN@YddPf=>Gub00Juj literal 1802 zcmY!laB(;DX8Cu`1g=e5W#2a!&tua<6o0wxL74p`Wi0|9#Bhq`x-cX!DUP-k<*0eXsu~ z`}g~QndNdGHF~yRqW-qO7kPEizIOFOv0H7^oV&QPFGxr6Trp1I+o}Fse}49Y76!rm zpzoIL^J-SKKa6plf^tcl`NhgaJ6~d zcTMd{-(!Q2fP%E2FPE}SFz2}x`cffA%O+v|^}@javmIA05}UZ_Qu396!fPo(9hEZE z1>2N@(k?i$?x;)=vgo(o%ETR)uypFw+6E4Ghdw3N%+$w||88t}&(ZqF_0BnW<*C(- zD;DXl{1MEMl{xKBOqBEEBT2hvC&usAanliIPcpi<^FgVPx}l$DN5+cGM7@Py&0bx~ zV$WL$MpQEV4#UK!#WwQj^2+77EeU7T>6TeTw+)B zR^9B_cum?dmq}i@`ltN-_8+%r{L)(D+QdTgvu{=biP{qVw%SG7t1nk&s^3ze^XxitT^?)yv+^m1w(Y8=H3e%cXlad>FSmwWozBr?>L)epOX0T(5L{)s%3-X*tzj z+T^Xu%vajGJye{mxXJ(0&u0M)H3lC%t~ejcdbjJqD-j>3*Did=c3ms2k?o$gV4`KS zU$er5S#4X>--@&@`!ikDU9Ry=ZSw8|vXcuV6sO!x=wqdRVt-a%kA>Wecz0T=H`7h0g4@%B9Bn%AXair+j_#f17-@ z|E%rrFDL%H-DvGDzi(Hd{C8Up-8W{>Uw7~M_Uzs? zJ3A^ED(ELC0!d&o0F(fdKm;|%2yTvoen@3Os)Bw%VtT5As)8Zd z+Fe^ex3^` zYBXG|j0_Ad4J-|f3`|VTj7+06k*qUEvJT>2=fsl4ocwgKL7-SEhQfiZ+8W?+J$&cMJBnEKGw837|6P0ZL5 z=z26UQv(ZxSV>W0W=?7mxW)?3tV#ts859pe`S~RZAdf>5i)UV1z5*!N!SPsJl2}v% R_MxGvnE{uos;j>n7XbR6sZ9U?