mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			298 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			298 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import UIKit
 | |
| import SessionUIKit
 | |
| import SessionMessagingKit
 | |
| import SessionUtilitiesKit
 | |
| 
 | |
| final class QuoteView: UIView {
 | |
|     static let thumbnailSize: CGFloat = 48
 | |
|     static let iconSize: CGFloat = 24
 | |
|     static let labelStackViewSpacing: CGFloat = 2
 | |
|     static let labelStackViewVMargin: CGFloat = 4
 | |
|     static let cancelButtonSize: CGFloat = 33
 | |
|     
 | |
|     enum Mode {
 | |
|         case regular
 | |
|         case draft
 | |
|     }
 | |
|     enum Direction { case incoming, outgoing }
 | |
|     
 | |
|     // MARK: - Variables
 | |
|     
 | |
|     private let onCancel: (() -> ())?
 | |
| 
 | |
|     // MARK: - Lifecycle
 | |
|     
 | |
|     init(
 | |
|         for mode: Mode,
 | |
|         authorId: String,
 | |
|         quotedText: String?,
 | |
|         threadVariant: SessionThread.Variant,
 | |
|         currentUserPublicKey: String?,
 | |
|         currentUserBlinded15PublicKey: String?,
 | |
|         currentUserBlinded25PublicKey: String?,
 | |
|         direction: Direction,
 | |
|         attachment: Attachment?,
 | |
|         hInset: CGFloat,
 | |
|         maxWidth: CGFloat,
 | |
|         onCancel: (() -> ())? = nil
 | |
|     ) {
 | |
|         self.onCancel = onCancel
 | |
|         
 | |
|         super.init(frame: CGRect.zero)
 | |
|         
 | |
|         setUpViewHierarchy(
 | |
|             mode: mode,
 | |
|             authorId: authorId,
 | |
|             quotedText: quotedText,
 | |
|             threadVariant: threadVariant,
 | |
|             currentUserPublicKey: currentUserPublicKey,
 | |
|             currentUserBlinded15PublicKey: currentUserBlinded15PublicKey,
 | |
|             currentUserBlinded25PublicKey: currentUserBlinded25PublicKey,
 | |
|             direction: direction,
 | |
|             attachment: attachment,
 | |
|             hInset: hInset,
 | |
|             maxWidth: maxWidth
 | |
|         )
 | |
|     }
 | |
| 
 | |
|     override init(frame: CGRect) {
 | |
|         preconditionFailure("Use init(for:maxMessageWidth:) instead.")
 | |
|     }
 | |
| 
 | |
|     required init?(coder: NSCoder) {
 | |
|         preconditionFailure("Use init(for:maxMessageWidth:) instead.")
 | |
|     }
 | |
| 
 | |
|     private func setUpViewHierarchy(
 | |
|         mode: Mode,
 | |
|         authorId: String,
 | |
|         quotedText: String?,
 | |
|         threadVariant: SessionThread.Variant,
 | |
|         currentUserPublicKey: String?,
 | |
|         currentUserBlinded15PublicKey: String?,
 | |
|         currentUserBlinded25PublicKey: String?,
 | |
|         direction: Direction,
 | |
|         attachment: Attachment?,
 | |
|         hInset: CGFloat,
 | |
|         maxWidth: CGFloat
 | |
|     ) {
 | |
|         // There's quite a bit of calculation going on here. It's a bit complex so don't make changes
 | |
|         // if you don't need to. If you do then test:
 | |
|         // • Quoted text in both private chats and group chats
 | |
|         // • Quoted images and videos in both private chats and group chats
 | |
|         // • Quoted voice messages and documents in both private chats and group chats
 | |
|         // • All of the above in both dark mode and light mode
 | |
|         let thumbnailSize = QuoteView.thumbnailSize
 | |
|         let iconSize = QuoteView.iconSize
 | |
|         let labelStackViewSpacing = QuoteView.labelStackViewSpacing
 | |
|         let labelStackViewVMargin = QuoteView.labelStackViewVMargin
 | |
|         let smallSpacing = Values.smallSpacing
 | |
|         let cancelButtonSize = QuoteView.cancelButtonSize
 | |
|         var availableWidth: CGFloat
 | |
|         
 | |
|         // Subtract smallSpacing twice; once for the spacing in between the stack view elements and
 | |
|         // once for the trailing margin.
 | |
|         if attachment == nil {
 | |
|             availableWidth = maxWidth - 2 * hInset - Values.accentLineThickness - 2 * smallSpacing
 | |
|         }
 | |
|         else {
 | |
|             availableWidth = maxWidth - 2 * hInset - thumbnailSize - 2 * smallSpacing
 | |
|         }
 | |
|         
 | |
|         if case .draft = mode {
 | |
|             availableWidth -= cancelButtonSize
 | |
|         }
 | |
|         
 | |
|         var body: String? = quotedText
 | |
|         
 | |
|         // Main stack view
 | |
|         let mainStackView = UIStackView(arrangedSubviews: [])
 | |
|         mainStackView.axis = .horizontal
 | |
|         mainStackView.spacing = smallSpacing
 | |
|         mainStackView.isLayoutMarginsRelativeArrangement = true
 | |
|         mainStackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: smallSpacing)
 | |
|         mainStackView.alignment = .center
 | |
|         
 | |
|         // Content view
 | |
|         let contentView = UIView()
 | |
|         addSubview(contentView)
 | |
|         contentView.pin(to: self)
 | |
|         
 | |
|         if let attachment: Attachment = attachment {
 | |
|             let isAudio: Bool = MIMETypeUtil.isAudio(attachment.contentType)
 | |
|             let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black")
 | |
|             let imageView: UIImageView = UIImageView(
 | |
|                 image: UIImage(named: fallbackImageName)?
 | |
|                     .resizedImage(to: CGSize(width: iconSize, height: iconSize))?
 | |
|                     .withRenderingMode(.alwaysTemplate)
 | |
|             )
 | |
|             
 | |
|             imageView.themeTintColor = {
 | |
|                 switch mode {
 | |
|                     case .regular: return (direction == .outgoing ?
 | |
|                         .messageBubble_outgoingText :
 | |
|                         .messageBubble_incomingText
 | |
|                     )
 | |
|                     case .draft: return .textPrimary
 | |
|                 }
 | |
|             }()
 | |
|             imageView.contentMode = .center
 | |
|             imageView.themeBackgroundColor = .messageBubble_overlay
 | |
|             imageView.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
 | |
|             imageView.layer.masksToBounds = true
 | |
|             imageView.set(.width, to: thumbnailSize)
 | |
|             imageView.set(.height, to: thumbnailSize)
 | |
|             mainStackView.addArrangedSubview(imageView)
 | |
|             
 | |
|             if (body ?? "").isEmpty {
 | |
|                 body = attachment.shortDescription
 | |
|             }
 | |
|             
 | |
|             // Generate the thumbnail if needed
 | |
|             if attachment.isVisualMedia {
 | |
|                 attachment.thumbnail(
 | |
|                     size: .small,
 | |
|                     success: { [imageView] image, _ in
 | |
|                         guard Thread.isMainThread else {
 | |
|                             DispatchQueue.main.async {
 | |
|                                 imageView.image = image
 | |
|                                 imageView.contentMode = .scaleAspectFill
 | |
|                             }
 | |
|                             return
 | |
|                         }
 | |
|                         
 | |
|                         imageView.image = image
 | |
|                         imageView.contentMode = .scaleAspectFill
 | |
|                     },
 | |
|                     failure: {}
 | |
|                 )
 | |
|             }
 | |
|         }
 | |
|         else {
 | |
|             // Line view
 | |
|             let lineColor: ThemeValue = {
 | |
|                 switch mode {
 | |
|                     case .regular: return (direction == .outgoing ? .messageBubble_outgoingText : .primary)
 | |
|                     case .draft: return .primary
 | |
|                 }
 | |
|             }()
 | |
|             let lineView = UIView()
 | |
|             lineView.themeBackgroundColor = lineColor
 | |
|             mainStackView.addArrangedSubview(lineView)
 | |
|             
 | |
|             lineView.pin(.top, to: .top, of: mainStackView)
 | |
|             lineView.pin(.bottom, to: .bottom, of: mainStackView)
 | |
|             lineView.set(.width, to: Values.accentLineThickness)
 | |
|         }
 | |
|         
 | |
|         // Body label
 | |
|         let bodyLabel = TappableLabel()
 | |
|         bodyLabel.lineBreakMode = .byTruncatingTail
 | |
|         bodyLabel.numberOfLines = 2
 | |
|         
 | |
|         let targetThemeColor: ThemeValue = {
 | |
|             switch mode {
 | |
|                 case .regular: return (direction == .outgoing ?
 | |
|                     .messageBubble_outgoingText :
 | |
|                     .messageBubble_incomingText
 | |
|                 )
 | |
|                 case .draft: return .textPrimary
 | |
|             }
 | |
|         }()
 | |
|         bodyLabel.font = .systemFont(ofSize: Values.smallFontSize)
 | |
|         
 | |
|         ThemeManager.onThemeChange(observer: bodyLabel) { [weak bodyLabel] theme, primaryColor in
 | |
|             guard let textColor: UIColor = theme.color(for: targetThemeColor) else { return }
 | |
|             
 | |
|             bodyLabel?.attributedText = body
 | |
|                 .map {
 | |
|                     MentionUtilities.highlightMentions(
 | |
|                         in: $0,
 | |
|                         threadVariant: threadVariant,
 | |
|                         currentUserPublicKey: currentUserPublicKey,
 | |
|                         currentUserBlinded15PublicKey: currentUserBlinded15PublicKey,
 | |
|                         currentUserBlinded25PublicKey: currentUserBlinded25PublicKey,
 | |
|                         isOutgoingMessage: (direction == .outgoing),
 | |
|                         textColor: textColor,
 | |
|                         theme: theme,
 | |
|                         primaryColor: primaryColor,
 | |
|                         attributes: [
 | |
|                             .foregroundColor: textColor
 | |
|                         ]
 | |
|                     )
 | |
|                 }
 | |
|                 .defaulting(
 | |
|                     to: attachment.map {
 | |
|                         NSAttributedString(string: $0.shortDescription, attributes: [ .foregroundColor: textColor ])
 | |
|                     }
 | |
|                 )
 | |
|                 .defaulting(to: NSAttributedString(string: "QUOTED_MESSAGE_NOT_FOUND".localized(), attributes: [ .foregroundColor: textColor ]))
 | |
|         }
 | |
|         
 | |
|         // Label stack view
 | |
|         let isCurrentUser: Bool = [
 | |
|             currentUserPublicKey,
 | |
|             currentUserBlinded15PublicKey,
 | |
|             currentUserBlinded25PublicKey
 | |
|         ]
 | |
|         .compactMap { $0 }
 | |
|         .asSet()
 | |
|         .contains(authorId)
 | |
|         
 | |
|         let authorLabel = UILabel()
 | |
|         authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize)
 | |
|         authorLabel.text = {
 | |
|             guard !isCurrentUser else { return "MEDIA_GALLERY_SENDER_NAME_YOU".localized() }
 | |
|             guard body != nil else {
 | |
|                 // When we can't find the quoted message we want to hide the author label
 | |
|                 return Profile.displayNameNoFallback(
 | |
|                     id: authorId,
 | |
|                     threadVariant: threadVariant
 | |
|                 )
 | |
|             }
 | |
|             
 | |
|             return Profile.displayName(
 | |
|                 id: authorId,
 | |
|                 threadVariant: threadVariant
 | |
|             )
 | |
|         }()
 | |
|         authorLabel.themeTextColor = targetThemeColor
 | |
|         authorLabel.lineBreakMode = .byTruncatingTail
 | |
|         authorLabel.isHidden = (authorLabel.text == nil)
 | |
|         authorLabel.numberOfLines = 1
 | |
|         
 | |
|         let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ])
 | |
|         labelStackView.axis = .vertical
 | |
|         labelStackView.spacing = labelStackViewSpacing
 | |
|         labelStackView.distribution = .equalCentering
 | |
|         labelStackView.isLayoutMarginsRelativeArrangement = true
 | |
|         labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0)
 | |
|         mainStackView.addArrangedSubview(labelStackView)
 | |
|         
 | |
|         // Constraints
 | |
|         contentView.addSubview(mainStackView)
 | |
|         mainStackView.pin(to: contentView)
 | |
|         
 | |
|         if mode == .draft {
 | |
|             // Cancel button
 | |
|             let cancelButton = UIButton(type: .custom)
 | |
|             cancelButton.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: .normal)
 | |
|             cancelButton.themeTintColor = .textPrimary
 | |
|             cancelButton.set(.width, to: cancelButtonSize)
 | |
|             cancelButton.set(.height, to: cancelButtonSize)
 | |
|             cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
 | |
|             
 | |
|             mainStackView.addArrangedSubview(cancelButton)
 | |
|             cancelButton.center(.vertical, in: self)
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: - Interaction
 | |
|     
 | |
|     @objc private func cancel() {
 | |
|         onCancel?()
 | |
|     }
 | |
| }
 |