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.
		
		
		
		
		
			
		
			
				
	
	
		
			714 lines
		
	
	
		
			31 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			714 lines
		
	
	
		
			31 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import UIKit
 | |
| import SessionUIKit
 | |
| import SignalUtilitiesKit
 | |
| import SessionMessagingKit
 | |
| import SessionUtilitiesKit
 | |
| 
 | |
| public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticCell {
 | |
|     public static let mutePrefix: String = "\u{e067}  "
 | |
|     public static let unreadCountViewSize: CGFloat = 20
 | |
|     private static let statusIndicatorSize: CGFloat = 14
 | |
|     
 | |
|     // MARK: - UI
 | |
|     
 | |
|     private let accentLineView: UIView = UIView()
 | |
| 
 | |
|     private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .list)
 | |
| 
 | |
|     private lazy var displayNameLabel: UILabel = {
 | |
|         let result: UILabel = UILabel()
 | |
|         result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
 | |
|         result.themeTextColor = .textPrimary
 | |
|         result.lineBreakMode = .byTruncatingTail
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var unreadCountView: UIView = {
 | |
|         let result: UIView = UIView()
 | |
|         result.clipsToBounds = true
 | |
|         result.themeBackgroundColor = .conversationButton_unreadBubbleBackground
 | |
|         result.layer.cornerRadius = (FullConversationCell.unreadCountViewSize / 2)
 | |
|         result.set(.width, greaterThanOrEqualTo: FullConversationCell.unreadCountViewSize)
 | |
|         result.set(.height, to: FullConversationCell.unreadCountViewSize)
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var unreadCountLabel: UILabel = {
 | |
|         let result: UILabel = UILabel()
 | |
|         result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
 | |
|         result.themeTextColor = .conversationButton_unreadBubbleText
 | |
|         result.textAlignment = .center
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     private lazy var unreadImageView: UIView = {
 | |
|         let iconHeight: CGFloat = 12
 | |
|         let indicatorSize: CGFloat = 6
 | |
|         
 | |
|         let result: UIView = UIView()
 | |
|         
 | |
|         let imageView: UIImageView = UIImageView(image: UIImage(systemName: "envelope"))
 | |
|         imageView.contentMode = .scaleAspectFit
 | |
|         imageView.themeTintColor = .textPrimary
 | |
|         result.addSubview(imageView)
 | |
|         
 | |
|         // Note: We add a 2 inset to align the bottom of the image with the bottom of the text (looks
 | |
|         // off otherwise)
 | |
|         imageView.pin(.top, to: .top, of: result, withInset: 2)
 | |
|         imageView.pin(.leading, to: .leading, of: result)
 | |
|         imageView.pin(.trailing, to: .trailing, of: result)
 | |
|         imageView.pin(.bottom, to: .bottom, of: result)
 | |
|         
 | |
|         // Note: For some weird reason if we dont '+ 4' here the height ends up getting set to '8'
 | |
|         imageView.set(.height, to: (iconHeight + 4))
 | |
|         imageView.set(.width, to: ((imageView.image?.size.width ?? 1) / (imageView.image?.size.height ?? 1) * iconHeight))
 | |
|         
 | |
|         let indicatorBackgroundView: UIView = UIView()
 | |
|         indicatorBackgroundView.themeBackgroundColor = .conversationButton_unreadBackground
 | |
|         indicatorBackgroundView.layer.cornerRadius = (indicatorSize / 2)
 | |
|         result.addSubview(indicatorBackgroundView)
 | |
| 
 | |
|         indicatorBackgroundView.set(.width, to: indicatorSize)
 | |
|         indicatorBackgroundView.set(.height, to: indicatorSize)
 | |
|         indicatorBackgroundView.pin(.top, to: .top, of: result, withInset: 1)
 | |
|         indicatorBackgroundView.pin(.trailing, to: .trailing, of: result, withInset: 1)
 | |
|         
 | |
|         let indicatorView: UIView = UIView()
 | |
|         indicatorView.themeBackgroundColor = .conversationButton_unreadBubbleBackground
 | |
|         indicatorView.layer.cornerRadius = ((indicatorSize - 2) / 2)
 | |
|         result.addSubview(indicatorView)
 | |
| 
 | |
|         indicatorView.set(.width, to: (indicatorSize - 2))
 | |
|         indicatorView.set(.height, to: (indicatorSize - 2))
 | |
|         indicatorView.center(in: indicatorBackgroundView)
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var hasMentionView: UIView = {
 | |
|         let result: UIView = UIView()
 | |
|         result.clipsToBounds = true
 | |
|         result.themeBackgroundColor = .conversationButton_unreadBubbleBackground
 | |
|         result.layer.cornerRadius = (FullConversationCell.unreadCountViewSize / 2)
 | |
|         result.set(.width, to: FullConversationCell.unreadCountViewSize)
 | |
|         result.set(.height, to: FullConversationCell.unreadCountViewSize)
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var hasMentionLabel: UILabel = {
 | |
|         let result: UILabel = UILabel()
 | |
|         result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
 | |
|         result.themeTextColor = .conversationButton_unreadBubbleText
 | |
|         result.text = "@"
 | |
|         result.textAlignment = .center
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var isPinnedIcon: UIImageView = {
 | |
|         let result: UIImageView = UIImageView(
 | |
|             image: UIImage(named: "Pin")?
 | |
|                 .withRenderingMode(.alwaysTemplate)
 | |
|         )
 | |
|         result.clipsToBounds = true
 | |
|         result.themeTintColor = .textSecondary
 | |
|         result.contentMode = .scaleAspectFit
 | |
|         result.set(.width, to: FullConversationCell.unreadCountViewSize)
 | |
|         result.set(.height, to: FullConversationCell.unreadCountViewSize)
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var timestampLabel: UILabel = {
 | |
|         let result: UILabel = UILabel()
 | |
|         result.font = .systemFont(ofSize: Values.smallFontSize)
 | |
|         result.themeTextColor = .textSecondary
 | |
|         result.lineBreakMode = .byTruncatingTail
 | |
|         result.alpha = Values.lowOpacity
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var snippetLabel: UILabel = {
 | |
|         let result: UILabel = UILabel()
 | |
|         result.font = .systemFont(ofSize: Values.smallFontSize)
 | |
|         result.themeTextColor = .textPrimary
 | |
|         result.lineBreakMode = .byTruncatingTail
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var typingIndicatorView = TypingIndicatorView()
 | |
| 
 | |
|     private lazy var statusIndicatorView: UIImageView = {
 | |
|         let result: UIImageView = UIImageView()
 | |
|         result.clipsToBounds = true
 | |
|         result.contentMode = .scaleAspectFit
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var topLabelStackView: UIStackView = {
 | |
|         let result: UIStackView = UIStackView()
 | |
|         result.axis = .horizontal
 | |
|         result.alignment = .center
 | |
|         result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var bottomLabelStackView: UIStackView = {
 | |
|         let result: UIStackView = UIStackView()
 | |
|         result.axis = .horizontal
 | |
|         result.alignment = .center
 | |
|         result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer
 | |
|         
 | |
|         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() {
 | |
|         let cellHeight: CGFloat = 68
 | |
|         
 | |
|         // Background color
 | |
|         themeBackgroundColor = .conversationButton_background
 | |
|         
 | |
|         // Highlight color
 | |
|         let selectedBackgroundView = UIView()
 | |
|         selectedBackgroundView.themeBackgroundColor = .highlighted(.conversationButton_background)
 | |
|         self.selectedBackgroundView = selectedBackgroundView
 | |
|         
 | |
|         // Accent line view
 | |
|         accentLineView.set(.width, to: Values.accentLineThickness)
 | |
|         accentLineView.set(.height, to: cellHeight)
 | |
|         
 | |
|         // Unread count view
 | |
|         unreadCountView.addSubview(unreadCountLabel)
 | |
|         unreadCountLabel.setCompressionResistanceHigh()
 | |
|         unreadCountLabel.pin([ VerticalEdge.top, VerticalEdge.bottom ], to: unreadCountView)
 | |
|         unreadCountView.pin(.leading, to: .leading, of: unreadCountLabel, withInset: -4)
 | |
|         unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4)
 | |
|         
 | |
|         // Has mention view
 | |
|         hasMentionView.addSubview(hasMentionLabel)
 | |
|         hasMentionLabel.setCompressionResistanceHigh()
 | |
|         hasMentionLabel.pin(to: hasMentionView)
 | |
|         
 | |
|         // Label stack view
 | |
|         let topLabelSpacer = UIView.hStretchingSpacer()
 | |
|         [ displayNameLabel, isPinnedIcon, unreadCountView, unreadImageView, hasMentionView, topLabelSpacer, timestampLabel ].forEach{ view in
 | |
|             topLabelStackView.addArrangedSubview(view)
 | |
|         }
 | |
|         
 | |
|         let snippetLabelContainer = UIView()
 | |
|         snippetLabelContainer.addSubview(snippetLabel)
 | |
|         snippetLabelContainer.addSubview(typingIndicatorView)
 | |
|         
 | |
|         let bottomLabelSpacer = UIView.hStretchingSpacer()
 | |
|         [ snippetLabelContainer, bottomLabelSpacer, statusIndicatorView ].forEach{ view in
 | |
|             bottomLabelStackView.addArrangedSubview(view)
 | |
|         }
 | |
|         
 | |
|         let labelContainerView = UIStackView(arrangedSubviews: [ topLabelStackView, bottomLabelStackView ])
 | |
|         labelContainerView.axis = .vertical
 | |
|         labelContainerView.alignment = .fill
 | |
|         labelContainerView.spacing = 6
 | |
|         labelContainerView.isUserInteractionEnabled = false
 | |
|         
 | |
|         // Main stack view
 | |
|         let stackView = UIStackView(arrangedSubviews: [ accentLineView, profilePictureView, labelContainerView ])
 | |
|         stackView.axis = .horizontal
 | |
|         stackView.alignment = .center
 | |
|         stackView.spacing = Values.mediumSpacing
 | |
|         contentView.addSubview(stackView)
 | |
|         
 | |
|         // Constraints
 | |
|         accentLineView.pin(.top, to: .top, of: contentView)
 | |
|         accentLineView.pin(.bottom, to: .bottom, of: contentView)
 | |
|         timestampLabel.setContentCompressionResistancePriority(.required, for: NSLayoutConstraint.Axis.horizontal)
 | |
|         
 | |
|         // HACK: The 4 lines below are part of a workaround for a weird layout bug
 | |
|         topLabelStackView.set(.height, to: 20)
 | |
|         topLabelSpacer.set(.height, to: 20)
 | |
|         
 | |
|         bottomLabelStackView.set(.height, to: 18)
 | |
|         bottomLabelSpacer.set(.height, to: 18)
 | |
|         
 | |
|         statusIndicatorView.set(.width, to: FullConversationCell.statusIndicatorSize)
 | |
|         statusIndicatorView.set(.height, to: FullConversationCell.statusIndicatorSize)
 | |
|         
 | |
|         snippetLabel.pin(to: snippetLabelContainer)
 | |
|         
 | |
|         typingIndicatorView.pin(.leading, to: .leading, of: snippetLabelContainer)
 | |
|         typingIndicatorView.centerYAnchor.constraint(equalTo: snippetLabel.centerYAnchor).isActive = true
 | |
|         
 | |
|         stackView.pin([ UIView.VerticalEdge.bottom, UIView.VerticalEdge.top, UIView.HorizontalEdge.leading ], to: contentView)
 | |
|         stackView.pin(.trailing, to: .trailing, of: contentView, withInset: -Values.mediumSpacing)
 | |
|     }
 | |
|     
 | |
|     // MARK: - Content
 | |
|     
 | |
|     // MARK: --Search Results
 | |
|     
 | |
|     public func updateForMessageSearchResult(with cellViewModel: SessionThreadViewModel, searchText: String) {
 | |
|         profilePictureView.update(
 | |
|             publicKey: cellViewModel.threadId,
 | |
|             threadVariant: cellViewModel.threadVariant,
 | |
|             customImageData: cellViewModel.openGroupProfilePictureData,
 | |
|             profile: cellViewModel.profile,
 | |
|             additionalProfile: cellViewModel.additionalProfile
 | |
|         )
 | |
|         
 | |
|         isPinnedIcon.isHidden = true
 | |
|         unreadCountView.isHidden = true
 | |
|         unreadImageView.isHidden = true
 | |
|         hasMentionView.isHidden = true
 | |
|         timestampLabel.isHidden = false
 | |
|         timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay
 | |
|         bottomLabelStackView.isHidden = false
 | |
|         
 | |
|         ThemeManager.onThemeChange(observer: displayNameLabel) { [weak displayNameLabel] theme, _ in
 | |
|             guard let textColor: UIColor = theme.color(for: .textPrimary) else { return }
 | |
|                 
 | |
|             displayNameLabel?.attributedText = NSMutableAttributedString(
 | |
|                 string: cellViewModel.displayName,
 | |
|                 attributes: [ .foregroundColor: textColor ]
 | |
|             )
 | |
|         }
 | |
|         
 | |
|         ThemeManager.onThemeChange(observer: displayNameLabel) { [weak self, weak snippetLabel] theme, _ in
 | |
|             guard let textColor: UIColor = theme.color(for: .textPrimary) else { return }
 | |
|             
 | |
|             snippetLabel?.attributedText = self?.getHighlightedSnippet(
 | |
|                 content: Interaction.previewText(
 | |
|                     variant: (cellViewModel.interactionVariant ?? .standardIncoming),
 | |
|                     body: cellViewModel.interactionBody,
 | |
|                     authorDisplayName: cellViewModel.authorName(for: .contact),
 | |
|                     attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo,
 | |
|                     attachmentCount: cellViewModel.interactionAttachmentCount,
 | |
|                     isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true)
 | |
|                 ),
 | |
|                 authorName: (cellViewModel.authorId != cellViewModel.currentUserPublicKey ?
 | |
|                     cellViewModel.authorName(for: .contact) :
 | |
|                     nil
 | |
|                 ),
 | |
|                 currentUserPublicKey: cellViewModel.currentUserPublicKey,
 | |
|                 currentUserBlinded15PublicKey: cellViewModel.currentUserBlinded15PublicKey,
 | |
|                 currentUserBlinded25PublicKey: cellViewModel.currentUserBlinded25PublicKey,
 | |
|                 searchText: searchText.lowercased(),
 | |
|                 fontSize: Values.smallFontSize,
 | |
|                 textColor: textColor
 | |
|             )
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     public func updateForContactAndGroupSearchResult(with cellViewModel: SessionThreadViewModel, searchText: String) {
 | |
|         profilePictureView.update(
 | |
|             publicKey: cellViewModel.threadId,
 | |
|             threadVariant: cellViewModel.threadVariant,
 | |
|             customImageData: cellViewModel.openGroupProfilePictureData,
 | |
|             profile: cellViewModel.profile,
 | |
|             additionalProfile: cellViewModel.additionalProfile
 | |
|         )
 | |
|         
 | |
|         isPinnedIcon.isHidden = true
 | |
|         unreadCountView.isHidden = true
 | |
|         unreadImageView.isHidden = true
 | |
|         hasMentionView.isHidden = true
 | |
|         timestampLabel.isHidden = true
 | |
|         
 | |
|         ThemeManager.onThemeChange(observer: displayNameLabel) { [weak self, weak displayNameLabel] theme, _ in
 | |
|             guard let textColor: UIColor = theme.color(for: .textPrimary) else { return }
 | |
|             
 | |
|             displayNameLabel?.attributedText = self?.getHighlightedSnippet(
 | |
|                 content: cellViewModel.displayName,
 | |
|                 currentUserPublicKey: cellViewModel.currentUserPublicKey,
 | |
|                 currentUserBlinded15PublicKey: cellViewModel.currentUserBlinded15PublicKey,
 | |
|                 currentUserBlinded25PublicKey: cellViewModel.currentUserBlinded25PublicKey,
 | |
|                 searchText: searchText.lowercased(),
 | |
|                 fontSize: Values.mediumFontSize,
 | |
|                 textColor: textColor
 | |
|             )
 | |
|         }
 | |
|         
 | |
|         switch cellViewModel.threadVariant {
 | |
|             case .contact, .community: bottomLabelStackView.isHidden = true
 | |
|                 
 | |
|             case .legacyGroup, .group:
 | |
|                 bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty
 | |
|         
 | |
|                 ThemeManager.onThemeChange(observer: displayNameLabel) { [weak self, weak snippetLabel] theme, _ in
 | |
|                     guard let textColor: UIColor = theme.color(for: .textPrimary) else { return }
 | |
|                     if cellViewModel.threadVariant == .legacyGroup || cellViewModel.threadVariant == .group {
 | |
|                         snippetLabel?.attributedText = self?.getHighlightedSnippet(
 | |
|                             content: (cellViewModel.threadMemberNames ?? ""),
 | |
|                             currentUserPublicKey: cellViewModel.currentUserPublicKey,
 | |
|                             currentUserBlinded15PublicKey: cellViewModel.currentUserBlinded15PublicKey,
 | |
|                             currentUserBlinded25PublicKey: cellViewModel.currentUserBlinded25PublicKey,
 | |
|                             searchText: searchText.lowercased(),
 | |
|                             fontSize: Values.smallFontSize,
 | |
|                             textColor: textColor
 | |
|                         )
 | |
|                     }
 | |
|                 }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: --Standard
 | |
|     
 | |
|     public func update(with cellViewModel: SessionThreadViewModel) {
 | |
|         let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0)
 | |
|         let threadIsUnread: Bool = (
 | |
|             unreadCount > 0 ||
 | |
|             cellViewModel.threadWasMarkedUnread == true
 | |
|         )
 | |
|         let themeBackgroundColor: ThemeValue = (threadIsUnread ?
 | |
|             .conversationButton_unreadBackground :
 | |
|             .conversationButton_background
 | |
|         )
 | |
|         self.themeBackgroundColor = themeBackgroundColor
 | |
|         self.selectedBackgroundView?.themeBackgroundColor = .highlighted(themeBackgroundColor)
 | |
|         
 | |
|         if cellViewModel.threadIsBlocked == true {
 | |
|             accentLineView.themeBackgroundColor = .danger
 | |
|             accentLineView.alpha = 1
 | |
|         }
 | |
|         else {
 | |
|             accentLineView.themeBackgroundColor = .conversationButton_unreadStripBackground
 | |
|             accentLineView.alpha = (unreadCount > 0 ? 1 : 0)
 | |
|         }
 | |
|         
 | |
|         isPinnedIcon.isHidden = (cellViewModel.threadPinnedPriority == 0)
 | |
|         unreadCountView.isHidden = (unreadCount <= 0)
 | |
|         unreadImageView.isHidden = (!unreadCountView.isHidden || !threadIsUnread)
 | |
|         unreadCountLabel.text = (unreadCount <= 0 ?
 | |
|             "" :
 | |
|             (unreadCount < 10000 ? "\(unreadCount)" : "9999+")
 | |
|         )
 | |
|         unreadCountLabel.font = .boldSystemFont(
 | |
|             ofSize: (unreadCount < 10000 ? Values.verySmallFontSize : 8)
 | |
|         )
 | |
|         hasMentionView.isHidden = !(
 | |
|             ((cellViewModel.threadUnreadMentionCount ?? 0) > 0) && (
 | |
|                 cellViewModel.threadVariant == .legacyGroup ||
 | |
|                 cellViewModel.threadVariant == .group ||
 | |
|                 cellViewModel.threadVariant == .community
 | |
|             )
 | |
|         )
 | |
|         profilePictureView.update(
 | |
|             publicKey: cellViewModel.threadId,
 | |
|             threadVariant: cellViewModel.threadVariant,
 | |
|             customImageData: cellViewModel.openGroupProfilePictureData,
 | |
|             profile: cellViewModel.profile,
 | |
|             additionalProfile: cellViewModel.additionalProfile
 | |
|         )
 | |
|         displayNameLabel.text = cellViewModel.displayName
 | |
|         timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay
 | |
|         
 | |
|         if cellViewModel.threadContactIsTyping == true {
 | |
|             snippetLabel.text = ""
 | |
|             typingIndicatorView.isHidden = false
 | |
|             typingIndicatorView.startAnimation()
 | |
|         }
 | |
|         else {
 | |
|             displayNameLabel.themeTextColor = {
 | |
|                 guard cellViewModel.interactionVariant != .infoClosedGroupCurrentUserLeaving else {
 | |
|                     return .textSecondary
 | |
|                 }
 | |
|                 
 | |
|                 return .textPrimary
 | |
|             }()
 | |
|             typingIndicatorView.isHidden = true
 | |
|             typingIndicatorView.stopAnimation()
 | |
|             
 | |
|             ThemeManager.onThemeChange(observer: snippetLabel) { [weak self, weak snippetLabel] theme, _ in
 | |
|                 if cellViewModel.interactionVariant == .infoClosedGroupCurrentUserLeaving {
 | |
|                     guard let textColor: UIColor = theme.color(for: .textSecondary) else { return }
 | |
|                     
 | |
|                     snippetLabel?.attributedText = self?.getSnippet(
 | |
|                         cellViewModel: cellViewModel,
 | |
|                         textColor: textColor
 | |
|                     )
 | |
|                 } else if cellViewModel.interactionVariant == .infoClosedGroupCurrentUserErrorLeaving {
 | |
|                     guard let textColor: UIColor = theme.color(for: .danger) else { return }
 | |
|                     
 | |
|                     snippetLabel?.attributedText = self?.getSnippet(
 | |
|                         cellViewModel: cellViewModel,
 | |
|                         textColor: textColor
 | |
|                     )
 | |
|                 } else {
 | |
|                     guard let textColor: UIColor = theme.color(for: .textPrimary) else { return }
 | |
|                     
 | |
|                     snippetLabel?.attributedText = self?.getSnippet(
 | |
|                         cellViewModel: cellViewModel,
 | |
|                         textColor: textColor
 | |
|                     )
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         let stateInfo = cellViewModel.interactionState?.statusIconInfo(
 | |
|             variant: (cellViewModel.interactionVariant ?? .standardOutgoing),
 | |
|             hasAtLeastOneReadReceipt: (cellViewModel.interactionHasAtLeastOneReadReceipt ?? false)
 | |
|         )
 | |
|         statusIndicatorView.image = stateInfo?.image
 | |
|         statusIndicatorView.themeTintColor = stateInfo?.themeTintColor
 | |
|         statusIndicatorView.isHidden = (
 | |
|             cellViewModel.interactionVariant != .standardOutgoing &&
 | |
|             cellViewModel.interactionState != .skipped
 | |
|         )
 | |
|     }
 | |
|     
 | |
|     // MARK: - SwipeActionOptimisticCell
 | |
|     
 | |
|     public func optimisticUpdate(
 | |
|         isMuted: Bool?,
 | |
|         isBlocked: Bool?,
 | |
|         isPinned: Bool?,
 | |
|         hasUnread: Bool?
 | |
|     ) {
 | |
|         // Note: This will result in the snippet being out of sync while the swipe action animation completes,
 | |
|         // this means if the day/night mode changes while the animation is happening then the below optimistic
 | |
|         // update might get reset (this should be rare and is a relatively minor bug so can be left in)
 | |
|         if let isMuted: Bool = isMuted {
 | |
|             let attrString: NSAttributedString = (self.snippetLabel.attributedText ?? NSAttributedString())
 | |
|             let hasMutePrefix: Bool = attrString.string.starts(with: FullConversationCell.mutePrefix)
 | |
|             
 | |
|             switch (isMuted, hasMutePrefix) {
 | |
|                 case (true, false):
 | |
|                     self.snippetLabel.attributedText = NSAttributedString(
 | |
|                         string: FullConversationCell.mutePrefix,
 | |
|                         attributes: [ .font: UIFont(name: "ElegantIcons", size: 10) as Any ]
 | |
|                     )
 | |
|                     .appending(attrString)
 | |
|                     
 | |
|                 case (false, true):
 | |
|                     self.snippetLabel.attributedText = attrString
 | |
|                         .attributedSubstring(from: NSRange(location: FullConversationCell.mutePrefix.count, length: (attrString.length - FullConversationCell.mutePrefix.count)))
 | |
|                     
 | |
|                 default: break
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         if let isBlocked: Bool = isBlocked {
 | |
|             if isBlocked {
 | |
|                 accentLineView.themeBackgroundColor = .danger
 | |
|                 accentLineView.alpha = 1
 | |
|             }
 | |
|             else {
 | |
|                 accentLineView.themeBackgroundColor = .conversationButton_unreadStripBackground
 | |
|                 accentLineView.alpha = (!unreadCountView.isHidden || !unreadImageView.isHidden ? 1 : 0)
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         if let isPinned: Bool = isPinned {
 | |
|             isPinnedIcon.isHidden = !isPinned
 | |
|         }
 | |
|         
 | |
|         if let hasUnread: Bool = hasUnread {
 | |
|             if hasUnread {
 | |
|                 unreadCountView.isHidden = false
 | |
|                 unreadCountLabel.text = "1"
 | |
|                 unreadCountLabel.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
 | |
|                 accentLineView.alpha = 1
 | |
|             } else {
 | |
|                 unreadCountView.isHidden = true
 | |
|                 accentLineView.alpha = 0
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // MARK: - Snippet generation
 | |
| 
 | |
|     private func getSnippet(
 | |
|         cellViewModel: SessionThreadViewModel,
 | |
|         textColor: UIColor
 | |
|     ) -> NSMutableAttributedString {
 | |
|         // If we don't have an interaction then do nothing
 | |
|         guard cellViewModel.interactionId != nil else { return NSMutableAttributedString() }
 | |
|         
 | |
|         let result = NSMutableAttributedString()
 | |
|         
 | |
|         if Date().timeIntervalSince1970 < (cellViewModel.threadMutedUntilTimestamp ?? 0) {
 | |
|             result.append(NSAttributedString(
 | |
|                 string: FullConversationCell.mutePrefix,
 | |
|                 attributes: [
 | |
|                     .font: UIFont(name: "ElegantIcons", size: 10) as Any,
 | |
|                     .foregroundColor: textColor
 | |
|                 ]
 | |
|             ))
 | |
|         }
 | |
|         else if cellViewModel.threadOnlyNotifyForMentions == true {
 | |
|             let imageAttachment = NSTextAttachment()
 | |
|             imageAttachment.image = UIImage(named: "NotifyMentions.png")?.withTint(textColor)
 | |
|             imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize)
 | |
|             
 | |
|             let imageString = NSAttributedString(attachment: imageAttachment)
 | |
|             result.append(imageString)
 | |
|             result.append(NSAttributedString(
 | |
|                 string: "  ",
 | |
|                 attributes: [
 | |
|                     .font: UIFont(name: "ElegantIcons", size: 10) as Any,
 | |
|                     .foregroundColor: textColor
 | |
|                 ]
 | |
|             ))
 | |
|         }
 | |
|         
 | |
|         if
 | |
|             (cellViewModel.threadVariant == .legacyGroup || cellViewModel.threadVariant == .group || cellViewModel.threadVariant == .community) &&
 | |
|             (cellViewModel.interactionVariant?.isGroupControlMessage == false)
 | |
|         {
 | |
|             let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant)
 | |
|             
 | |
|             result.append(NSAttributedString(
 | |
|                 string: "\(authorName): ",
 | |
|                 attributes: [ .foregroundColor: textColor ]
 | |
|             ))
 | |
|         }
 | |
|         
 | |
|         let previewText: String = {
 | |
|             if cellViewModel.interactionVariant == .infoClosedGroupCurrentUserErrorLeaving { return "group_leave_error".localized() }
 | |
|             return Interaction.previewText(
 | |
|                 variant: (cellViewModel.interactionVariant ?? .standardIncoming),
 | |
|                 body: cellViewModel.interactionBody,
 | |
|                 threadContactDisplayName: cellViewModel.threadContactName(),
 | |
|                 authorDisplayName: cellViewModel.authorName(for: cellViewModel.threadVariant),
 | |
|                 attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo,
 | |
|                 attachmentCount: cellViewModel.interactionAttachmentCount,
 | |
|                 isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true)
 | |
|             )
 | |
|         }()
 | |
|         
 | |
|         result.append(NSAttributedString(
 | |
|             string: MentionUtilities.highlightMentionsNoAttributes(
 | |
|                 in: previewText,
 | |
|                 threadVariant: cellViewModel.threadVariant,
 | |
|                 currentUserPublicKey: cellViewModel.currentUserPublicKey,
 | |
|                 currentUserBlinded15PublicKey: cellViewModel.currentUserBlinded15PublicKey,
 | |
|                 currentUserBlinded25PublicKey: cellViewModel.currentUserBlinded25PublicKey
 | |
|             ),
 | |
|             attributes: [ .foregroundColor: textColor ]
 | |
|         ))
 | |
|             
 | |
|         return result
 | |
|     }
 | |
|     
 | |
|     private func getHighlightedSnippet(
 | |
|         content: String,
 | |
|         authorName: String? = nil,
 | |
|         currentUserPublicKey: String,
 | |
|         currentUserBlinded15PublicKey: String?,
 | |
|         currentUserBlinded25PublicKey: String?,
 | |
|         searchText: String,
 | |
|         fontSize: CGFloat,
 | |
|         textColor: UIColor
 | |
|     ) -> NSAttributedString {
 | |
|         guard !content.isEmpty, content != "NOTE_TO_SELF".localized() else {
 | |
|             return NSMutableAttributedString(
 | |
|                 string: (authorName != nil && authorName?.isEmpty != true ?
 | |
|                     "\(authorName ?? ""): \(content)" :
 | |
|                     content
 | |
|                 ),
 | |
|                 attributes: [ .foregroundColor: textColor ]
 | |
|             )
 | |
|         }
 | |
|         
 | |
|         // Replace mentions in the content
 | |
|         //
 | |
|         // Note: The 'threadVariant' is used for profile context but in the search results
 | |
|         // we don't want to include the truncated id as part of the name so we exclude it
 | |
|         let mentionReplacedContent: String = MentionUtilities.highlightMentionsNoAttributes(
 | |
|             in: content,
 | |
|             threadVariant: .contact,
 | |
|             currentUserPublicKey: currentUserPublicKey,
 | |
|             currentUserBlinded15PublicKey: currentUserBlinded15PublicKey,
 | |
|             currentUserBlinded25PublicKey: currentUserBlinded25PublicKey
 | |
|         )
 | |
|         let result: NSMutableAttributedString = NSMutableAttributedString(
 | |
|             string: mentionReplacedContent,
 | |
|             attributes: [
 | |
|                 .foregroundColor: textColor
 | |
|                     .withAlphaComponent(Values.lowOpacity)
 | |
|             ]
 | |
|         )
 | |
|         
 | |
|         // Bold each part of the searh term which matched
 | |
|         let normalizedSnippet: String = mentionReplacedContent.lowercased()
 | |
|         var firstMatchRange: Range<String.Index>?
 | |
|         
 | |
|         SessionThreadViewModel.searchTermParts(searchText)
 | |
|             .map { part -> String in
 | |
|                 guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part }
 | |
|                 
 | |
|                 return part.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
 | |
|             }
 | |
|             .forEach { part in
 | |
|                 // Highlight all ranges of the text (Note: The search logic only finds results that start
 | |
|                 // with the term so we use the regex below to ensure we only highlight those cases)
 | |
|                 normalizedSnippet
 | |
|                     .ranges(
 | |
|                         of: (CurrentAppContext().isRTL ?
 | |
|                              "(\(part.lowercased()))(^|[^a-zA-Z0-9])" :
 | |
|                              "(^|[^a-zA-Z0-9])(\(part.lowercased()))"
 | |
|                         ),
 | |
|                         options: [.regularExpression]
 | |
|                     )
 | |
|                     .forEach { range in
 | |
|                         let targetRange: Range<String.Index> = {
 | |
|                             let term: String = String(normalizedSnippet[range])
 | |
|                             
 | |
|                             // If the matched term doesn't actually match the "part" value then it means
 | |
|                             // we've matched a term after a non-alphanumeric character so need to shift
 | |
|                             // the range over by 1
 | |
|                             guard term.starts(with: part.lowercased()) else {
 | |
|                                 return (normalizedSnippet.index(after: range.lowerBound)..<range.upperBound)
 | |
|                             }
 | |
|                             
 | |
|                             return range
 | |
|                         }()
 | |
|                         
 | |
|                         // Store the range of the first match so we can focus it in the content displayed
 | |
|                         if firstMatchRange == nil {
 | |
|                             firstMatchRange = targetRange
 | |
|                         }
 | |
|                         
 | |
|                         let legacyRange: NSRange = NSRange(targetRange, in: normalizedSnippet)
 | |
|                         result.addAttribute(.foregroundColor, value: textColor, range: legacyRange)
 | |
|                         result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: legacyRange)
 | |
|                     }
 | |
|             }
 | |
|         
 | |
|         // Now that we have generated the focused snippet add the author name as a prefix (if provided)
 | |
|         return authorName
 | |
|             .map { authorName -> NSAttributedString? in
 | |
|                 guard !authorName.isEmpty else { return nil }
 | |
|                 
 | |
|                 let authorPrefix: NSAttributedString = NSAttributedString(
 | |
|                     string: "\(authorName): ",
 | |
|                     attributes: [ .foregroundColor: textColor ]
 | |
|                 )
 | |
|                 
 | |
|                 return authorPrefix.appending(result)
 | |
|             }
 | |
|             .defaulting(to: result)
 | |
|     }
 | |
| }
 |