Merge branch 'dev' into multi-device

This commit is contained in:
Niels Andriesse 2021-02-22 15:18:12 +11:00
commit d532badd09
314 changed files with 6420 additions and 17457 deletions

11
Podfile
View File

@ -74,17 +74,6 @@ target 'SessionMessagingKit' do
pod 'YapDatabase/SQLCipher', :git => 'https://github.com/loki-project/session-ios-yap-database.git', branch: 'signal-release', :inhibit_warnings => true
end
target 'SessionProtocolKit' do
pod 'CocoaLumberjack', :inhibit_warnings => true
pod 'CryptoSwift', :inhibit_warnings => true
pod 'Curve25519Kit', git: 'https://github.com/signalapp/Curve25519Kit.git', :inhibit_warnings => true
pod 'GRKOpenSSLFramework', :inhibit_warnings => true
pod 'HKDFKit', :inhibit_warnings => true
pod 'PromiseKit', :inhibit_warnings => true
pod 'SignalCoreKit', git: 'https://github.com/signalapp/SignalCoreKit.git', :inhibit_warnings => true
pod 'SwiftProtobuf', '~> 1.5.0', :inhibit_warnings => true
end
target 'SessionSnodeKit' do
pod 'CryptoSwift', :inhibit_warnings => true
pod 'Curve25519Kit', git: 'https://github.com/signalapp/Curve25519Kit.git', :inhibit_warnings => true

View File

@ -124,7 +124,6 @@ PODS:
DEPENDENCIES:
- AFNetworking
- CocoaLumberjack
- CryptoSwift
- Curve25519Kit (from `https://github.com/signalapp/Curve25519Kit.git`)
- FeedKit
@ -217,6 +216,6 @@ SPEC CHECKSUMS:
YYImage: 6db68da66f20d9f169ceb94dfb9947c3867b9665
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
PODFILE CHECKSUM: bb4f6cffd6e7c08814b945e1787d01d639036b1e
PODFILE CHECKSUM: 2fca3f32c171e1324c9e3809b96a32d4a929d05c
COCOAPODS: 1.10.0.rc.1

File diff suppressed because it is too large Load Diff

View File

@ -77,10 +77,10 @@ final class NewPrivateChatVC : BaseVC, UIPageViewControllerDataSource, UIPageVie
pageVCView.set(.width, to: screen.width)
let height: CGFloat
if #available(iOS 13, *) {
height = navigationController!.view.bounds.height - navigationBar.height() - Values.tabBarHeight
height = navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight
} else {
let statusBarHeight = UIApplication.shared.statusBarFrame.height
height = navigationController!.view.bounds.height - navigationBar.height() - Values.tabBarHeight - statusBarHeight
height = navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight - statusBarHeight
}
pageVCView.set(.height, to: height)
enterPublicKeyVC.constrainHeight(to: height)
@ -183,7 +183,7 @@ private final class EnterPublicKeyVC : UIViewController {
view.backgroundColor = .clear
// Explanation label
let explanationLabel = UILabel()
explanationLabel.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity)
explanationLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
explanationLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
explanationLabel.text = NSLocalizedString("vc_enter_public_key_explanation", comment: "")
explanationLabel.numberOfLines = 0

View File

@ -100,7 +100,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
let hasContactsToAdd = !Set(ContactUtilities.getAllContacts()).subtracting(self.members).isEmpty
if (!hasContactsToAdd) {
addMembersButton.isUserInteractionEnabled = false
let disabledColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity)
let disabledColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
addMembersButton.layer.borderColor = disabledColor.cgColor
addMembersButton.setTitleColor(disabledColor, for: UIControl.State.normal)
}
@ -238,7 +238,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
self.members = members.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
let hasContactsToAdd = !Set(ContactUtilities.getAllContacts()).subtracting(self.members).isEmpty
self.addMembersButton.isUserInteractionEnabled = hasContactsToAdd
let color = hasContactsToAdd ? Colors.accent : Colors.text.withAlphaComponent(Values.unimportantElementOpacity)
let color = hasContactsToAdd ? Colors.accent : Colors.text.withAlphaComponent(Values.mediumOpacity)
self.addMembersButton.layer.borderColor = color.cgColor
self.addMembersButton.setTitleColor(color, for: UIControl.State.normal)
}
@ -247,7 +247,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
private func commitChanges() {
let popToConversationVC: (EditClosedGroupVC) -> Void = { editVC in
if let conversationVC = editVC.navigationController!.viewControllers.first(where: { $0 is ConversationViewController }) {
if let conversationVC = editVC.navigationController!.viewControllers.first(where: { $0 is ConversationVC }) {
editVC.navigationController!.popToViewController(conversationVC, animated: true)
} else {
editVC.navigationController!.popViewController(animated: true)

View File

@ -38,7 +38,7 @@ final class GroupMembersVC : BaseVC, UITableViewDataSource {
setNavBarTitle("Group Members")
// Set up explanation label
let explanationLabel = UILabel()
explanationLabel.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity)
explanationLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
explanationLabel.font = .systemFont(ofSize: Values.smallFontSize)
explanationLabel.text = "The ability to add members to a closed group is coming soon."
explanationLabel.numberOfLines = 0

View File

@ -1,9 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "SelectRecipientViewController.h"
@interface AddToBlockListViewController : SelectRecipientViewController
@end

View File

@ -1,110 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "AddToBlockListViewController.h"
#import "BlockListUIUtils.h"
#import <SessionMessagingKit/SSKEnvironment.h>
#import <SessionMessagingKit/OWSBlockingManager.h>
#import <SignalUtilitiesKit/SignalAccount.h>
NS_ASSUME_NONNULL_BEGIN
@interface AddToBlockListViewController () <SelectRecipientViewControllerDelegate>
@end
#pragma mark -
@implementation AddToBlockListViewController
- (void)loadView
{
self.delegate = self;
[super loadView];
self.title = NSLocalizedString(@"SETTINGS_ADD_TO_BLOCK_LIST_TITLE", @"Title for the 'add to block list' view.");
}
- (NSString *)phoneNumberSectionTitle
{
return NSLocalizedString(@"SETTINGS_ADD_TO_BLOCK_LIST_BLOCK_PHONE_NUMBER_TITLE",
@"Title for the 'block phone number' section of the 'add to block list' view.");
}
- (NSString *)phoneNumberButtonText
{
return NSLocalizedString(@"BLOCK_LIST_VIEW_BLOCK_BUTTON", @"A label for the block button in the block list view");
}
- (NSString *)contactsSectionTitle
{
return NSLocalizedString(@"SETTINGS_ADD_TO_BLOCK_LIST_BLOCK_CONTACT_TITLE",
@"Title for the 'block contact' section of the 'add to block list' view.");
}
- (void)phoneNumberWasSelected:(NSString *)phoneNumber
{
OWSAssertDebug(phoneNumber.length > 0);
__weak AddToBlockListViewController *weakSelf = self;
[BlockListUIUtils showBlockPhoneNumberActionSheet:phoneNumber
fromViewController:self
blockingManager:SSKEnvironment.shared.blockingManager
completionBlock:^(BOOL isBlocked) {
if (isBlocked) {
[weakSelf.navigationController popViewControllerAnimated:YES];
}
}];
}
- (BOOL)canSignalAccountBeSelected:(SignalAccount *)signalAccount
{
OWSAssertDebug(signalAccount);
return ![SSKEnvironment.shared.blockingManager isRecipientIdBlocked:signalAccount.recipientId];
}
- (void)signalAccountWasSelected:(SignalAccount *)signalAccount
{
OWSAssertDebug(signalAccount);
__weak AddToBlockListViewController *weakSelf = self;
if ([SSKEnvironment.shared.blockingManager isRecipientIdBlocked:signalAccount.recipientId]) {
OWSFailDebug(@"Cannot add already blocked user to block list.");
return;
}
[BlockListUIUtils showBlockSignalAccountActionSheet:signalAccount
fromViewController:self
blockingManager:SSKEnvironment.shared.blockingManager
completionBlock:^(BOOL isBlocked) {
if (isBlocked) {
[weakSelf.navigationController popViewControllerAnimated:YES];
}
}];
}
- (BOOL)shouldHideLocalNumber
{
return YES;
}
- (BOOL)shouldHideContacts
{
return NO;
}
- (BOOL)shouldValidatePhoneNumbers
{
return NO;
}
- (nullable NSString *)accessoryMessageForSignalAccount:(SignalAccount *)signalAccount
{
return nil;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,82 @@
extension ContextMenuVC {
struct Action {
let icon: UIImage
let title: String
let work: () -> Void
static func reply(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate) -> Action {
let title = "Reply"
return Action(icon: UIImage(named: "ic_reply")!, title: title) { delegate.reply(viewItem) }
}
static func copy(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate) -> Action {
let title = "Copy"
return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate.copy(viewItem) }
}
static func copySessionID(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate) -> Action {
let title = "Copy Session ID"
return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate.copySessionID(viewItem) }
}
static func delete(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate) -> Action {
let title = "Delete"
return Action(icon: UIImage(named: "ic_trash")!, title: title) { delegate.delete(viewItem) }
}
static func save(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate) -> Action {
let title = "Save"
return Action(icon: UIImage(named: "ic_download")!, title: title) { delegate.save(viewItem) }
}
static func ban(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate) -> Action {
let title = "Ban User"
return Action(icon: UIImage(named: "ic_block")!, title: title) { delegate.ban(viewItem) }
}
}
static func actions(for viewItem: ConversationViewItem, delegate: ContextMenuActionDelegate) -> [Action] {
func isReplyingAllowed() -> Bool {
guard let message = viewItem.interaction as? TSOutgoingMessage else { return true }
switch message.messageState {
case .failed, .sending: return false
default: return true
}
}
switch viewItem.messageCellType {
case .textOnlyMessage:
var result: [Action] = []
if isReplyingAllowed() { result.append(Action.reply(viewItem, delegate)) }
result.append(Action.copy(viewItem, delegate))
let isGroup = viewItem.isGroupThread
if isGroup && viewItem.interaction is TSIncomingMessage { result.append(Action.copySessionID(viewItem, delegate)) }
if !isGroup || viewItem.userCanDeleteGroupMessage { result.append(Action.delete(viewItem, delegate)) }
if isGroup && viewItem.interaction is TSIncomingMessage && viewItem.userHasModerationPermission { result.append(Action.ban(viewItem, delegate)) }
return result
case .mediaMessage, .audio, .genericAttachment:
var result: [Action] = []
if isReplyingAllowed() { result.append(Action.reply(viewItem, delegate)) }
if viewItem.canCopyMedia() { result.append(Action.copy(viewItem, delegate)) }
if viewItem.canSaveMedia() { result.append(Action.save(viewItem, delegate)) }
let isGroup = viewItem.isGroupThread
if isGroup && viewItem.interaction is TSIncomingMessage { result.append(Action.copySessionID(viewItem, delegate)) }
if !isGroup || viewItem.userCanDeleteGroupMessage { result.append(Action.delete(viewItem, delegate)) }
if isGroup && viewItem.interaction is TSIncomingMessage && viewItem.userHasModerationPermission { result.append(Action.ban(viewItem, delegate)) }
return result
default: return []
}
}
}
// MARK: Delegate
protocol ContextMenuActionDelegate {
func reply(_ viewItem: ConversationViewItem)
func copy(_ viewItem: ConversationViewItem)
func copySessionID(_ viewItem: ConversationViewItem)
func delete(_ viewItem: ConversationViewItem)
func save(_ viewItem: ConversationViewItem)
func ban(_ viewItem: ConversationViewItem)
}

View File

@ -0,0 +1,62 @@
extension ContextMenuVC {
final class ActionView : UIView {
private let action: Action
private let dismiss: () -> Void
// MARK: Settings
private static let iconSize: CGFloat = 16
private static let iconImageViewSize: CGFloat = 24
// MARK: Lifecycle
init(for action: Action, dismiss: @escaping () -> Void) {
self.action = action
self.dismiss = dismiss
super.init(frame: CGRect.zero)
setUpViewHierarchy()
}
override init(frame: CGRect) {
preconditionFailure("Use init(for:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(for:) instead.")
}
private func setUpViewHierarchy() {
// Icon
let iconSize = ActionView.iconSize
let iconImageView = UIImageView(image: action.icon.resizedImage(to: CGSize(width: iconSize, height: iconSize))!.withTint(Colors.text))
let iconImageViewSize = ActionView.iconImageViewSize
iconImageView.set(.width, to: iconImageViewSize)
iconImageView.set(.height, to: iconImageViewSize)
iconImageView.contentMode = .center
// Title
let titleLabel = UILabel()
titleLabel.text = action.title
titleLabel.textColor = Colors.text
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
// Stack view
let stackView = UIStackView(arrangedSubviews: [ iconImageView, titleLabel ])
stackView.axis = .horizontal
stackView.spacing = Values.smallSpacing
stackView.alignment = .center
stackView.isLayoutMarginsRelativeArrangement = true
let smallSpacing = Values.smallSpacing
stackView.layoutMargins = UIEdgeInsets(top: smallSpacing, leading: smallSpacing, bottom: smallSpacing, trailing: Values.mediumSpacing)
addSubview(stackView)
stackView.pin(to: self)
// Tap gesture recognizer
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
addGestureRecognizer(tapGestureRecognizer)
}
// MARK: Interaction
@objc private func handleTap() {
action.work()
dismiss()
}
}
}

View File

@ -0,0 +1,136 @@
final class ContextMenuVC : UIViewController {
private let snapshot: UIView
private let viewItem: ConversationViewItem
private let frame: CGRect
private let delegate: ContextMenuActionDelegate
private let dismiss: () -> Void
// MARK: UI Components
private lazy var blurView = UIVisualEffectView(effect: nil)
private lazy var menuView: UIView = {
let result = UIView()
result.layer.shadowColor = UIColor.black.cgColor
result.layer.shadowOffset = CGSize.zero
result.layer.shadowOpacity = 0.4
result.layer.shadowRadius = 4
return result
}()
private lazy var timestampLabel: UILabel = {
let result = UILabel()
result.text = DateUtil.formatTimestamp(asTime: viewItem.interaction.timestampForUI())
result.font = .systemFont(ofSize: Values.verySmallFontSize)
result.textColor = isLightMode ? .black : .white
return result
}()
// MARK: Settings
private static let actionViewHeight: CGFloat = 40
private static let menuCornerRadius: CGFloat = 8
// MARK: Lifecycle
init(snapshot: UIView, viewItem: ConversationViewItem, frame: CGRect, delegate: ContextMenuActionDelegate, dismiss: @escaping () -> Void) {
self.snapshot = snapshot
self.viewItem = viewItem
self.frame = frame
self.delegate = delegate
self.dismiss = dismiss
super.init(nibName: nil, bundle: nil)
}
override init(nibName: String?, bundle: Bundle?) {
preconditionFailure("Use init(snapshot:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(coder:) instead.")
}
override func viewDidLoad() {
super.viewDidLoad()
// Background color
view.backgroundColor = .clear
// Blur
view.addSubview(blurView)
blurView.pin(to: view)
// Snapshot
snapshot.layer.shadowColor = UIColor.black.cgColor
snapshot.layer.shadowOffset = CGSize.zero
snapshot.layer.shadowOpacity = 0.4
snapshot.layer.shadowRadius = 4
view.addSubview(snapshot)
snapshot.pin(.left, to: .left, of: view, withInset: frame.origin.x)
snapshot.pin(.top, to: .top, of: view, withInset: frame.origin.y)
snapshot.set(.width, to: frame.width)
snapshot.set(.height, to: frame.height)
// Timestamp
view.addSubview(timestampLabel)
timestampLabel.center(.vertical, in: snapshot)
let isOutgoing = (viewItem.interaction.interactionType() == .outgoingMessage)
if isOutgoing {
timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing)
} else {
timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing)
}
// Menu
let menuBackgroundView = UIView()
menuBackgroundView.backgroundColor = Colors.receivedMessageBackground
menuBackgroundView.layer.cornerRadius = ContextMenuVC.menuCornerRadius
menuBackgroundView.layer.masksToBounds = true
menuView.addSubview(menuBackgroundView)
menuBackgroundView.pin(to: menuView)
let actionViews = ContextMenuVC.actions(for: viewItem, delegate: delegate).map { ActionView(for: $0, dismiss: snDismiss) }
let menuStackView = UIStackView(arrangedSubviews: actionViews)
menuStackView.axis = .vertical
menuView.addSubview(menuStackView)
menuStackView.pin(to: menuView)
view.addSubview(menuView)
let menuHeight = CGFloat(actionViews.count) * ContextMenuVC.actionViewHeight
let spacing = Values.smallSpacing
let margin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing)
if frame.maxY + spacing + menuHeight > UIScreen.main.bounds.height - margin {
menuView.pin(.bottom, to: .top, of: snapshot, withInset: -spacing)
} else {
menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing)
}
switch viewItem.interaction.interactionType() {
case .outgoingMessage: menuView.pin(.right, to: .right, of: snapshot)
case .incomingMessage: menuView.pin(.left, to: .left, of: snapshot)
default: break // Should never occur
}
// Tap gesture
let mainTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
view.addGestureRecognizer(mainTapGestureRecognizer)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
UIView.animate(withDuration: 0.25) {
self.blurView.effect = UIBlurEffect(style: .regular)
self.menuView.alpha = 1
}
}
// MARK: Updating
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
menuView.layer.shadowPath = UIBezierPath(roundedRect: menuView.bounds, cornerRadius: ContextMenuVC.menuCornerRadius).cgPath
}
// MARK: Interaction
@objc private func handleTap() {
snDismiss()
}
func snDismiss() {
UIView.animate(withDuration: 0.25, animations: {
self.blurView.effect = nil
self.menuView.alpha = 0
self.timestampLabel.alpha = 0
}, completion: { _ in
self.dismiss()
})
}
}

View File

@ -0,0 +1,28 @@
final class ContextMenuWindow : UIWindow {
override var windowLevel: UIWindow.Level {
get { return UIWindow.Level(rawValue: CGFloat.greatestFiniteMagnitude - 1) }
set { /* Do nothing */ }
}
override init(frame: CGRect) {
super.init(frame: frame)
initialize()
}
@available(iOS 13.0, *)
override init(windowScene: UIWindowScene) {
super.init(windowScene: windowScene)
initialize()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
initialize()
}
private func initialize() {
backgroundColor = .clear
}
}

View File

@ -1,22 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@protocol ConversationCollectionViewDelegate <NSObject>
- (void)collectionViewWillChangeSizeFrom:(CGSize)oldSize to:(CGSize)newSize;
- (void)collectionViewDidChangeSizeFrom:(CGSize)oldSize to:(CGSize)newSize;
@end
#pragma mark -
@interface ConversationCollectionView : UICollectionView
@property (weak, nonatomic) id<ConversationCollectionViewDelegate> layoutDelegate;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,90 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "ConversationCollectionView.h"
NS_ASSUME_NONNULL_BEGIN
#pragma mark -
@implementation ConversationCollectionView
- (void)setFrame:(CGRect)frame
{
if (frame.size.width == 0 || frame.size.height == 0) {
// Ignore iOS Auto Layout's tendency to temporarily zero out the
// frame of this view during the layout process.
//
// The conversation view has an invariant that the collection view
// should always have a "reasonable" (correct width, non-zero height)
// size. This lets us manipulate scroll state at all times, especially
// before the view has been presented for the first time. This
// invariant also saves us from needing all sorts of ugly and incomplete
// hacks in the conversation view's code.
return;
}
CGSize oldSize = self.frame.size;
CGSize newSize = frame.size;
BOOL isChanging = !CGSizeEqualToSize(oldSize, newSize);
if (isChanging) {
[self.layoutDelegate collectionViewWillChangeSizeFrom:oldSize to:newSize];
}
[super setFrame:frame];
if (isChanging) {
[self.layoutDelegate collectionViewDidChangeSizeFrom:oldSize to:newSize];
}
}
- (void)setBounds:(CGRect)bounds
{
if (bounds.size.width == 0 || bounds.size.height == 0) {
// Ignore iOS Auto Layout's tendency to temporarily zero out the
// frame of this view during the layout process.
//
// The conversation view has an invariant that the collection view
// should always have a "reasonable" (correct width, non-zero height)
// size. This lets us manipulate scroll state at all times, especially
// before the view has been presented for the first time. This
// invariant also saves us from needing all sorts of ugly and incomplete
// hacks in the conversation view's code.
return;
}
CGSize oldSize = self.bounds.size;
CGSize newSize = bounds.size;
BOOL isChanging = !CGSizeEqualToSize(oldSize, newSize);
if (isChanging) {
[self.layoutDelegate collectionViewWillChangeSizeFrom:oldSize to:newSize];
}
[super setBounds:bounds];
if (isChanging) {
[self.layoutDelegate collectionViewDidChangeSizeFrom:oldSize to:newSize];
}
}
- (void)setContentOffset:(CGPoint)contentOffset
{
if (self.contentSize.height < 1 && CGPointEqualToPoint(CGPointZero, contentOffset)) {
// [UIScrollView _adjustContentOffsetIfNecessary] resets the content
// offset to zero under a number of undocumented conditions. We don't
// want this behavior; we want fine-grained control over the default
// scroll state of the message view.
//
// [UIScrollView _adjustContentOffsetIfNecessary] is called in
// response to many different events; trying to prevent them all is
// whack-a-mole.
//
// It's not safe to override [UIScrollView _adjustContentOffsetIfNecessary],
// since its a private API.
//
// We can avoid the issue by simply ignoring attempt to reset the content
// offset to zero before the collection view has determined its content size.
return;
}
[super setContentOffset:contentOffset];
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,43 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import <SignalUtilitiesKit/OWSTextView.h>
NS_ASSUME_NONNULL_BEGIN
@class SignalAttachment;
@protocol ConversationInputTextViewDelegate <NSObject>
- (void)didPasteAttachment:(SignalAttachment *_Nullable)attachment;
- (void)inputTextViewSendMessagePressed;
- (void)textViewDidChange:(UITextView *)textView;
@end
#pragma mark -
@protocol ConversationTextViewToolbarDelegate <NSObject>
- (void)textViewDidChange:(UITextView *)textView;
- (void)textViewDidChangeSelection:(UITextView *)textView;
@end
#pragma mark -
@interface ConversationInputTextView : OWSTextView
@property (weak, nonatomic) id<ConversationInputTextViewDelegate> inputTextViewDelegate;
@property (weak, nonatomic) id<ConversationTextViewToolbarDelegate> textViewToolbarDelegate;
- (NSString *)trimmedText;
- (void)setPlaceholderText:(NSString *)placeholderText;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,248 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "ConversationInputTextView.h"
#import "Session-Swift.h"
#import <SessionUtilitiesKit/NSString+SSK.h>
#import <SignalCoreKit/NSString+OWS.h>
NS_ASSUME_NONNULL_BEGIN
@interface ConversationInputTextView () <UITextViewDelegate>
@property (nonatomic) UILabel *placeholderView;
@property (nonatomic) NSArray<NSLayoutConstraint *> *placeholderConstraints;
@end
#pragma mark -
@implementation ConversationInputTextView
- (instancetype)init
{
self = [super init];
if (self) {
[self setTranslatesAutoresizingMaskIntoConstraints:NO];
self.delegate = self;
self.backgroundColor = nil;
self.showsHorizontalScrollIndicator = NO;
self.showsVerticalScrollIndicator = NO;
self.scrollEnabled = YES;
self.scrollsToTop = NO;
self.userInteractionEnabled = YES;
self.font = [UIFont systemFontOfSize:LKValues.mediumFontSize];
self.textColor = LKColors.text;
self.textAlignment = NSTextAlignmentNatural;
self.tintColor = LKColors.accent;
self.contentMode = UIViewContentModeRedraw;
self.dataDetectorTypes = UIDataDetectorTypeNone;
self.text = nil;
self.placeholderView = [UILabel new];
self.placeholderView.text = NSLocalizedString(@"Message", @"");
self.placeholderView.textColor = [LKColors.text colorWithAlphaComponent:LKValues.composeViewTextFieldPlaceholderOpacity];
self.placeholderView.userInteractionEnabled = NO;
[self addSubview:self.placeholderView];
// We need to do these steps _after_ placeholderView is configured.
self.font = [UIFont systemFontOfSize:LKValues.mediumFontSize];
CGFloat hMarginLeading = 16.f;
CGFloat hMarginTrailing = 16.f;
self.textContainerInset = UIEdgeInsetsMake(11.f,
CurrentAppContext().isRTL ? hMarginTrailing : hMarginLeading,
11.f,
CurrentAppContext().isRTL ? hMarginLeading : hMarginTrailing);
self.textContainer.lineFragmentPadding = 0;
self.contentInset = UIEdgeInsetsZero;
[self ensurePlaceholderConstraints];
[self updatePlaceholderVisibility];
}
return self;
}
#pragma mark -
- (void)setFont:(UIFont *_Nullable)font
{
[super setFont:font];
self.placeholderView.font = font;
}
- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)isAnimated
{
// When creating new lines, contentOffset is animated, but because because
// we are simultaneously resizing the text view, this can cause the
// text in the textview to be "too high" in the text view.
// Solution is to disable animation for setting content offset.
[super setContentOffset:contentOffset animated:NO];
}
- (void)setContentInset:(UIEdgeInsets)contentInset
{
[super setContentInset:contentInset];
[self ensurePlaceholderConstraints];
}
- (void)setTextContainerInset:(UIEdgeInsets)textContainerInset
{
[super setTextContainerInset:textContainerInset];
[self ensurePlaceholderConstraints];
}
- (void)ensurePlaceholderConstraints
{
OWSAssertDebug(self.placeholderView);
if (self.placeholderConstraints) {
[NSLayoutConstraint deactivateConstraints:self.placeholderConstraints];
}
// We align the location of our placeholder with the text content of
// this view. The only safe way to do that is by measuring the
// beginning position.
UITextRange *beginningTextRange =
[self textRangeFromPosition:self.beginningOfDocument toPosition:self.beginningOfDocument];
CGRect beginningTextRect = [self firstRectForRange:beginningTextRange];
CGFloat topInset = beginningTextRect.origin.y;
CGFloat leftInset = beginningTextRect.origin.x;
// we use Left instead of Leading, since it's based on the prior CGRect offset
self.placeholderConstraints = @[
[self.placeholderView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:leftInset],
[self.placeholderView autoPinEdgeToSuperviewEdge:ALEdgeRight],
[self.placeholderView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:topInset],
];
}
- (void)updatePlaceholderVisibility
{
self.placeholderView.hidden = self.text.length > 0;
}
- (void)setText:(NSString *_Nullable)text
{
[super setText:text];
[self updatePlaceholderVisibility];
}
- (BOOL)canBecomeFirstResponder
{
return YES;
}
- (BOOL)pasteboardHasPossibleAttachment
{
// We don't want to load/convert images more than once so we
// only do a cursory validation pass at this time.
return ([SignalAttachment pasteboardHasPossibleAttachment] && ![SignalAttachment pasteboardHasText]);
}
- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender
{
if (action == @selector(paste:)) {
if ([self pasteboardHasPossibleAttachment]) {
return YES;
}
}
return [super canPerformAction:action withSender:sender];
}
- (void)paste:(nullable id)sender
{
if ([self pasteboardHasPossibleAttachment]) {
SignalAttachment *attachment = [SignalAttachment attachmentFromPasteboard];
// Note: attachment might be nil or have an error at this point; that's fine.
[self.inputTextViewDelegate didPasteAttachment:attachment];
return;
}
[super paste:sender];
}
- (NSString *)trimmedText
{
return [self.text ows_stripped];
}
- (void)setPlaceholderText:(NSString *)placeholderText
{
[self.placeholderView setText:placeholderText];
}
#pragma mark - UITextViewDelegate
- (void)textViewDidChange:(UITextView *)textView
{
OWSAssertDebug(self.inputTextViewDelegate);
OWSAssertDebug(self.textViewToolbarDelegate);
[self updatePlaceholderVisibility];
[self.inputTextViewDelegate textViewDidChange:self];
[self.textViewToolbarDelegate textViewDidChange:self];
}
- (void)textViewDidChangeSelection:(UITextView *)textView
{
[self.textViewToolbarDelegate textViewDidChangeSelection:self];
}
#pragma mark - Key Commands
- (nullable NSArray<UIKeyCommand *> *)keyCommands
{
// We're permissive about what modifier key we accept for the "send message" hotkey.
// We accept command-return, option-return.
//
// We don't support control-return because it doesn't work.
//
// We don't support shift-return because it is often used for "newline" in other
// messaging apps.
return @[
[self keyCommandWithInput:@"\r"
modifierFlags:UIKeyModifierCommand
action:@selector(modifiedReturnPressed:)
discoverabilityTitle:@"Send Message"],
// "Alternate" is option.
[self keyCommandWithInput:@"\r"
modifierFlags:UIKeyModifierAlternate
action:@selector(modifiedReturnPressed:)
discoverabilityTitle:@"Send Message"],
];
}
- (UIKeyCommand *)keyCommandWithInput:(NSString *)input
modifierFlags:(UIKeyModifierFlags)modifierFlags
action:(SEL)action
discoverabilityTitle:(NSString *)discoverabilityTitle
{
return [UIKeyCommand keyCommandWithInput:input
modifierFlags:modifierFlags
action:action
discoverabilityTitle:discoverabilityTitle];
}
- (void)modifiedReturnPressed:(UIKeyCommand *)sender
{
OWSLogInfo(@"modifiedReturnPressed: %@", sender.input);
[self.inputTextViewDelegate inputTextViewSendMessagePressed];
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,97 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@class ConversationStyle;
@class LKMention;
@class LKMentionCandidateSelectionView;
@class OWSLinkPreviewDraft;
@class OWSQuotedReplyModel;
@class SignalAttachment;
@class TSThread;
@protocol ConversationInputToolbarDelegate <NSObject>
- (void)sendButtonPressed;
- (void)attachmentButtonPressed;
#pragma mark - Voice Memo
- (void)voiceMemoGestureDidStart;
- (void)voiceMemoGestureDidLock;
- (void)voiceMemoGestureDidComplete;
- (void)voiceMemoGestureDidCancel;
- (void)voiceMemoGestureDidUpdateCancelWithRatioComplete:(CGFloat)cancelAlpha;
- (void)handleMentionCandidateSelected:(LKMention *)mentionCandidate from:(LKMentionCandidateSelectionView *)mentionCandidateSelectionView;
@end
#pragma mark -
@class ConversationInputTextView;
@protocol ConversationInputTextViewDelegate;
@interface ConversationInputToolbar : UIView
- (instancetype)initWithConversationStyle:(ConversationStyle *)conversationStyle NS_DESIGNATED_INITIALIZER;
- (instancetype)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE;
- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE;
@property (nonatomic, weak) id<ConversationInputToolbarDelegate> inputToolbarDelegate;
- (void)beginEditingTextMessage;
- (void)endEditingTextMessage;
- (BOOL)isInputTextViewFirstResponder;
- (void)setInputTextViewDelegate:(id<ConversationInputTextViewDelegate>)value;
- (NSString *)messageText;
- (void)setMessageText:(NSString *_Nullable)value animated:(BOOL)isAnimated;
- (void)setPlaceholderText:(NSString *)placeholderText;
- (void)clearTextMessageAnimated:(BOOL)isAnimated;
- (void)toggleDefaultKeyboard;
- (void)setAttachmentButtonHidden:(BOOL)isHidden;
- (void)updateFontSizes;
- (void)updateLayoutWithSafeAreaInsets:(UIEdgeInsets)safeAreaInsets;
- (void)ensureTextViewHeight;
#pragma mark - Voice Memo
- (void)lockVoiceMemoUI;
- (void)showVoiceMemoUI;
- (void)hideVoiceMemoUI:(BOOL)animated;
- (void)setVoiceMemoUICancelAlpha:(CGFloat)cancelAlpha;
- (void)cancelVoiceMemoIfNecessary;
#pragma mark -
@property (nonatomic, nullable) OWSQuotedReplyModel *quotedReply;
@property (nonatomic, nullable, readonly) OWSLinkPreviewDraft *linkPreviewDraft;
- (void)hideInputMethod;
#pragma mark - Mention Candidate Selection View
- (void)showMentionCandidateSelectionViewFor:(NSArray<LKMention *> *)mentionCandidates in:(TSThread *)thread;
- (void)hideMentionCandidateSelectionView;
@end
NS_ASSUME_NONNULL_END

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +0,0 @@
//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@interface ConversationScrollButton : UIButton
@property (nonatomic) BOOL hasUnreadMessages;
+ (CGFloat)buttonSize;
- (nullable instancetype)initWithIconText:(NSString *)iconText;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,99 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "ConversationScrollButton.h"
#import "UIColor+OWS.h"
#import "UIFont+OWS.h"
#import "UIView+OWS.h"
#import <SignalUtilitiesKit/Theme.h>
#import "Session-Swift.h"
NS_ASSUME_NONNULL_BEGIN
@interface ConversationScrollButton ()
@property (nonatomic) NSString *iconText;
@property (nonatomic) UILabel *iconLabel;
@property (nonatomic) UIView *circleView;
@end
#pragma mark -
@implementation ConversationScrollButton
- (nullable instancetype)initWithIconText:(NSString *)iconText
{
self = [super initWithFrame:CGRectZero];
if (!self) {
return self;
}
self.iconText = iconText;
[self createContents];
return self;
}
+ (CGFloat)circleSize
{
return ScaleFromIPhone5To7Plus(35.f, 40.f);
}
+ (CGFloat)buttonSize
{
return self.circleSize + 2 * 15.f;
}
- (void)createContents
{
UILabel *iconLabel = [UILabel new];
self.iconLabel = iconLabel;
iconLabel.userInteractionEnabled = NO;
const CGFloat circleSize = self.class.circleSize;
UIView *circleView = [UIView new];
self.circleView = circleView;
circleView.userInteractionEnabled = NO;
circleView.layer.cornerRadius = circleSize * 0.5f;
circleView.layer.borderColor = [LKColors.text colorWithAlphaComponent:LKValues.composeViewTextFieldBorderOpacity].CGColor;
circleView.layer.borderWidth = LKValues.composeViewTextFieldBorderThickness;
[circleView autoSetDimension:ALDimensionWidth toSize:circleSize];
[circleView autoSetDimension:ALDimensionHeight toSize:circleSize];
[self addSubview:circleView];
[self addSubview:iconLabel];
[circleView autoCenterInSuperview];
[iconLabel autoCenterInSuperview];
[self updateColors];
}
- (void)setHasUnreadMessages:(BOOL)hasUnreadMessages
{
_hasUnreadMessages = hasUnreadMessages;
[self updateColors];
}
- (void)updateColors
{
UIColor *foregroundColor = LKColors.text;
UIColor *backgroundColor = LKColors.composeViewBackground;
const CGFloat circleSize = self.class.circleSize;
self.circleView.backgroundColor = backgroundColor;
self.iconLabel.attributedText =
[[NSAttributedString alloc] initWithString:self.iconText
attributes:@{
NSFontAttributeName : [UIFont ows_fontAwesomeFont:circleSize * 0.75f],
NSForegroundColorAttributeName : foregroundColor,
NSBaselineOffsetAttributeName : @(-0.5f),
}];
}
@end
NS_ASSUME_NONNULL_END

View File

@ -17,7 +17,7 @@ public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate
}
@objc
public class ConversationSearchController: NSObject {
public class ConversationSearchController : NSObject {
@objc
public static let kMinimumSearchTextLength: UInt = 2
@ -31,7 +31,7 @@ public class ConversationSearchController: NSObject {
let thread: TSThread
@objc
public let resultsBar: SearchResultsBar = SearchResultsBar(frame: .zero)
public let resultsBar: SearchResultsBar = SearchResultsBar()
// MARK: Initializer
@ -45,14 +45,12 @@ public class ConversationSearchController: NSObject {
uiSearchController.searchResultsUpdater = self
uiSearchController.hidesNavigationBarDuringPresentation = false
uiSearchController.dimsBackgroundDuringPresentation = false
if #available(iOS 13, *) {
// Do nothing
} else {
uiSearchController.dimsBackgroundDuringPresentation = false
}
uiSearchController.searchBar.inputAccessoryView = resultsBar
applyTheme()
}
func applyTheme() {
OWSSearchBar.applyTheme(to: uiSearchController.searchBar)
}
// MARK: Dependencies
@ -62,7 +60,8 @@ public class ConversationSearchController: NSObject {
}
}
extension ConversationSearchController: UISearchControllerDelegate {
extension ConversationSearchController : UISearchControllerDelegate {
public func didPresentSearchController(_ searchController: UISearchController) {
Logger.verbose("")
delegate?.didPresentSearchController?(searchController)
@ -74,7 +73,8 @@ extension ConversationSearchController: UISearchControllerDelegate {
}
}
extension ConversationSearchController: UISearchResultsUpdating {
extension ConversationSearchController : UISearchResultsUpdating {
var dbSearcher: FullTextSearcher {
return FullTextSearcher.shared
}
@ -88,7 +88,6 @@ extension ConversationSearchController: UISearchResultsUpdating {
return
}
let searchText = FullTextSearchFinder.normalize(text: rawSearchText)
BenchManager.startEvent(title: "Conversation Search", eventId: searchText)
guard searchText.count >= ConversationSearchController.kMinimumSearchTextLength else {
self.resultsBar.updateResults(resultSet: nil)
@ -112,7 +111,8 @@ extension ConversationSearchController: UISearchResultsUpdating {
}
}
extension ConversationSearchController: SearchResultsBarDelegate {
extension ConversationSearchController : SearchResultsBarDelegate {
func searchResultsBar(_ searchResultsBar: SearchResultsBar,
setCurrentIndex currentIndex: Int,
resultSet: ConversationScreenSearchResultSet) {
@ -126,68 +126,95 @@ extension ConversationSearchController: SearchResultsBarDelegate {
}
}
protocol SearchResultsBarDelegate: AnyObject {
protocol SearchResultsBarDelegate : AnyObject {
func searchResultsBar(_ searchResultsBar: SearchResultsBar,
setCurrentIndex currentIndex: Int,
resultSet: ConversationScreenSearchResultSet)
}
public class SearchResultsBar: UIToolbar {
public final class SearchResultsBar : UIView {
private var resultSet: ConversationScreenSearchResultSet?
var currentIndex: Int?
weak var resultsBarDelegate: SearchResultsBarDelegate?
var showLessRecentButton: UIBarButtonItem!
var showMoreRecentButton: UIBarButtonItem!
let labelItem: UIBarButtonItem
var resultSet: ConversationScreenSearchResultSet?
public override var intrinsicContentSize: CGSize { CGSize.zero }
private lazy var label: UILabel = {
let result = UILabel()
result.text = "Test"
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
result.textColor = Colors.text
return result
}()
private lazy var upButton: UIButton = {
let icon = #imageLiteral(resourceName: "ic_chevron_up").withRenderingMode(.alwaysTemplate)
let result = UIButton()
result.setImage(icon, for: UIControl.State.normal)
result.tintColor = Colors.accent
result.addTarget(self, action: #selector(handleUpButtonTapped), for: UIControl.Event.touchUpInside)
return result
}()
private lazy var downButton: UIButton = {
let icon = #imageLiteral(resourceName: "ic_chevron_down").withRenderingMode(.alwaysTemplate)
let result = UIButton()
result.setImage(icon, for: UIControl.State.normal)
result.tintColor = Colors.accent
result.addTarget(self, action: #selector(handleDownButtonTapped), for: UIControl.Event.touchUpInside)
return result
}()
override init(frame: CGRect) {
labelItem = UIBarButtonItem(title: nil, style: .plain, target: nil, action: nil)
labelItem.setTitleTextAttributes([ .font : UIFont.systemFont(ofSize: Values.mediumFontSize) ], for: UIControl.State.normal)
super.init(frame: frame)
let leftExteriorChevronMargin: CGFloat
let leftInteriorChevronMargin: CGFloat
if CurrentAppContext().isRTL {
leftExteriorChevronMargin = 8
leftInteriorChevronMargin = 0
} else {
leftExteriorChevronMargin = 0
leftInteriorChevronMargin = 8
}
let upChevron = #imageLiteral(resourceName: "ic_chevron_up").withRenderingMode(.alwaysTemplate)
showLessRecentButton = UIBarButtonItem(image: upChevron, style: .plain, target: self, action: #selector(didTapShowLessRecent))
showLessRecentButton.imageInsets = UIEdgeInsets(top: 2, left: leftExteriorChevronMargin, bottom: 2, right: leftInteriorChevronMargin)
showLessRecentButton.tintColor = Colors.accent
let downChevron = #imageLiteral(resourceName: "ic_chevron_down").withRenderingMode(.alwaysTemplate)
showMoreRecentButton = UIBarButtonItem(image: downChevron, style: .plain, target: self, action: #selector(didTapShowMoreRecent))
showMoreRecentButton.imageInsets = UIEdgeInsets(top: 2, left: leftInteriorChevronMargin, bottom: 2, right: leftExteriorChevronMargin)
showMoreRecentButton.tintColor = Colors.accent
let spacer1 = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
let spacer2 = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
self.items = [showLessRecentButton, showMoreRecentButton, spacer1, labelItem, spacer2]
self.isTranslucent = false
self.isOpaque = true
self.barTintColor = Colors.navigationBarBackground
self.autoresizingMask = .flexibleHeight
self.translatesAutoresizingMaskIntoConstraints = false
setUpViewHierarchy()
}
required init?(coder aDecoder: NSCoder) {
notImplemented()
required init?(coder: NSCoder) {
super.init(coder: coder)
setUpViewHierarchy()
}
private func setUpViewHierarchy() {
autoresizingMask = .flexibleHeight
// Background & blur
let backgroundView = UIView()
backgroundView.backgroundColor = isLightMode ? .white : .black
backgroundView.alpha = Values.lowOpacity
addSubview(backgroundView)
backgroundView.pin(to: self)
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
addSubview(blurView)
blurView.pin(to: self)
// Separator
let separator = UIView()
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
separator.set(.height, to: 1 / UIScreen.main.scale)
addSubview(separator)
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
// Spacers
let spacer1 = UIView.hStretchingSpacer()
let spacer2 = UIView.hStretchingSpacer()
// Button containers
let upButtonContainer = UIView(wrapping: upButton, withInsets: UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0))
let downButtonContainer = UIView(wrapping: downButton, withInsets: UIEdgeInsets(top: 0, left: 0, bottom: 2, right: 0))
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ upButtonContainer, downButtonContainer, spacer1, label, spacer2 ])
mainStackView.axis = .horizontal
mainStackView.spacing = Values.mediumSpacing
mainStackView.isLayoutMarginsRelativeArrangement = true
mainStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.largeSpacing, bottom: Values.smallSpacing, trailing: Values.largeSpacing)
addSubview(mainStackView)
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)
// Remaining constraints
label.center(.horizontal, in: self)
}
@objc
public func didTapShowLessRecent() {
public func handleUpButtonTapped() {
Logger.debug("")
guard let resultSet = resultSet else {
owsFailDebug("resultSet was unexpectedly nil")
@ -211,7 +238,7 @@ public class SearchResultsBar: UIToolbar {
}
@objc
public func didTapShowMoreRecent() {
public func handleDownButtonTapped() {
Logger.debug("")
guard let resultSet = resultSet else {
owsFailDebug("resultSet was unexpectedly nil")
@ -234,10 +261,6 @@ public class SearchResultsBar: UIToolbar {
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet)
}
var currentIndex: Int?
// MARK:
func updateResults(resultSet: ConversationScreenSearchResultSet?) {
if let resultSet = resultSet {
if resultSet.messages.count > 0 {
@ -259,17 +282,17 @@ public class SearchResultsBar: UIToolbar {
func updateBarItems() {
guard let resultSet = resultSet else {
labelItem.title = nil
showMoreRecentButton.isEnabled = false
showLessRecentButton.isEnabled = false
label.text = ""
downButton.isEnabled = false
upButton.isEnabled = false
return
}
switch resultSet.messages.count {
case 0:
labelItem.title = NSLocalizedString("CONVERSATION_SEARCH_NO_RESULTS", comment: "keyboard toolbar label when no messages match the search string")
label.text = NSLocalizedString("CONVERSATION_SEARCH_NO_RESULTS", comment: "keyboard toolbar label when no messages match the search string")
case 1:
labelItem.title = NSLocalizedString("CONVERSATION_SEARCH_ONE_RESULT", comment: "keyboard toolbar label when exactly 1 message matches the search string")
label.text = NSLocalizedString("CONVERSATION_SEARCH_ONE_RESULT", comment: "keyboard toolbar label when exactly 1 message matches the search string")
default:
let format = NSLocalizedString("CONVERSATION_SEARCH_RESULTS_FORMAT",
comment: "keyboard toolbar label when more than 1 message matches the search string. Embeds {{number/position of the 'currently viewed' result}} and the {{total number of results}}")
@ -278,15 +301,15 @@ public class SearchResultsBar: UIToolbar {
owsFailDebug("currentIndex was unexpectedly nil")
return
}
labelItem.title = String(format: format, currentIndex + 1, resultSet.messages.count)
label.text = String(format: format, currentIndex + 1, resultSet.messages.count)
}
if let currentIndex = currentIndex {
showMoreRecentButton.isEnabled = currentIndex > 0
showLessRecentButton.isEnabled = currentIndex + 1 < resultSet.messages.count
downButton.isEnabled = currentIndex > 0
upButton.isEnabled = currentIndex + 1 < resultSet.messages.count
} else {
showMoreRecentButton.isEnabled = false
showLessRecentButton.isEnabled = false
downButton.isEnabled = false
upButton.isEnabled = false
}
}
}

View File

@ -1,210 +0,0 @@
@objc(LKConversationTitleView)
final class ConversationTitleView : UIView {
private let thread: TSThread
private var currentStatus: Status? { didSet { updateSubtitleForCurrentStatus() } }
private var handledMessageTimestamps: Set<NSNumber> = []
// MARK: Types
private enum Status : Int {
case calculatingPoW = 1
case routing = 2
case messageSending = 3
case messageSent = 4
case messageFailed = 5
}
// MARK: Components
private lazy var profilePictureView: ProfilePictureView = {
let result = ProfilePictureView()
let size: CGFloat = 40
result.set(.width, to: size)
result.set(.height, to: size)
result.size = size
return result
}()
private lazy var titleLabel: UILabel = {
let result = UILabel()
result.textColor = Colors.text
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
result.lineBreakMode = .byTruncatingTail
return result
}()
private lazy var subtitleLabel: UILabel = {
let result = UILabel()
result.textColor = Colors.text
result.font = .systemFont(ofSize: 13)
result.lineBreakMode = .byTruncatingTail
return result
}()
// MARK: Lifecycle
@objc init(thread: TSThread) {
self.thread = thread
super.init(frame: CGRect.zero)
setUpViewHierarchy()
updateTitle()
updateProfilePicture()
updateSubtitleForCurrentStatus()
let notificationCenter = NotificationCenter.default
notificationCenter.addObserver(self, selector: #selector(handleProfileChangedNotification(_:)), name: NSNotification.Name(rawValue: kNSNotificationName_OtherUsersProfileDidChange), object: nil)
notificationCenter.addObserver(self, selector: #selector(handleCalculatingMessagePoWNotification(_:)), name: .calculatingMessagePoW, object: nil)
notificationCenter.addObserver(self, selector: #selector(handleEncryptingMessageNotification(_:)), name: .encryptingMessage, object: nil)
notificationCenter.addObserver(self, selector: #selector(handleMessageSendingNotification(_:)), name: .messageSending, object: nil)
notificationCenter.addObserver(self, selector: #selector(handleMessageSentNotification(_:)), name: .messageSent, object: nil)
notificationCenter.addObserver(self, selector: #selector(handleMessageSendingFailedNotification(_:)), name: .messageSendingFailed, object: nil)
}
override init(frame: CGRect) {
preconditionFailure("Use init(thread:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(thread:) instead.")
}
private func setUpViewHierarchy() {
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ])
labelStackView.axis = .vertical
labelStackView.alignment = .leading
let stackView = UIStackView(arrangedSubviews: [ profilePictureView, labelStackView ])
stackView.axis = .horizontal
stackView.alignment = .center
stackView.spacing = 12
addSubview(stackView)
stackView.pin(to: self)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: Updating
private func updateTitle() {
let title: String
if thread.isGroupThread() {
if thread.name().isEmpty {
title = GroupDisplayNameUtilities.getDefaultDisplayName(for: thread as! TSGroupThread)
} else {
title = thread.name()
}
} else {
if thread.isNoteToSelf() {
title = NSLocalizedString("Note to Self", comment: "")
} else {
let hexEncodedPublicKey = thread.contactIdentifier()!
title = UserDisplayNameUtilities.getPrivateChatDisplayName(for: hexEncodedPublicKey) ?? hexEncodedPublicKey
}
}
titleLabel.text = title
}
private func updateProfilePicture() {
profilePictureView.update(for: thread)
}
@objc private func handleProfileChangedNotification(_ notification: Notification) {
guard let hexEncodedPublicKey = notification.userInfo?[kNSNotificationKey_ProfileRecipientId] as? String, let thread = self.thread as? TSContactThread,
hexEncodedPublicKey == thread.contactIdentifier() else { return }
updateTitle()
updateProfilePicture()
}
@objc private func handleCalculatingMessagePoWNotification(_ notification: Notification) {
guard let timestamp = notification.object as? NSNumber else { return }
setStatusIfNeeded(to: .calculatingPoW, forMessageWithTimestamp: timestamp)
}
@objc private func handleEncryptingMessageNotification(_ notification: Notification) {
guard let timestamp = notification.object as? NSNumber else { return }
setStatusIfNeeded(to: .routing, forMessageWithTimestamp: timestamp)
}
@objc private func handleMessageSendingNotification(_ notification: Notification) {
guard let timestamp = notification.object as? NSNumber else { return }
setStatusIfNeeded(to: .messageSending, forMessageWithTimestamp: timestamp)
}
@objc private func handleMessageSentNotification(_ notification: Notification) {
guard let timestamp = notification.object as? NSNumber else { return }
setStatusIfNeeded(to: .messageSent, forMessageWithTimestamp: timestamp)
handledMessageTimestamps.insert(timestamp)
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.clearStatusIfNeededForMessageWithTimestamp(timestamp)
}
}
@objc private func handleMessageSendingFailedNotification(_ notification: Notification) {
guard let timestamp = notification.object as? NSNumber else { return }
clearStatusIfNeededForMessageWithTimestamp(timestamp)
}
private func setStatusIfNeeded(to status: Status, forMessageWithTimestamp timestamp: NSNumber) {
guard !handledMessageTimestamps.contains(timestamp) else { return }
var uncheckedTargetInteraction: TSInteraction? = nil
thread.enumerateInteractions { interaction in
guard interaction.timestamp == timestamp.uint64Value else { return }
uncheckedTargetInteraction = interaction
}
guard let targetInteraction = uncheckedTargetInteraction, targetInteraction.interactionType() == .outgoingMessage,
status.rawValue > (currentStatus?.rawValue ?? 0) else { return }
currentStatus = status
}
private func clearStatusIfNeededForMessageWithTimestamp(_ timestamp: NSNumber) {
var uncheckedTargetInteraction: TSInteraction? = nil
OWSPrimaryStorage.shared().dbReadConnection.read { transaction in
guard let interactionsByThread = transaction.ext(TSMessageDatabaseViewExtensionName) as? YapDatabaseViewTransaction else { return }
interactionsByThread.enumerateKeysAndObjects(inGroup: self.thread.uniqueId!) { _, _, object, _, _ in
guard let interaction = object as? TSInteraction, interaction.timestamp == timestamp.uint64Value else { return }
uncheckedTargetInteraction = interaction
}
}
guard let targetInteraction = uncheckedTargetInteraction, targetInteraction.interactionType() == .outgoingMessage else { return }
self.currentStatus = nil
}
@objc func updateSubtitleForCurrentStatus() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.subtitleLabel.isHidden = false
let subtitle = NSMutableAttributedString()
if let muteEndDate = self.thread.mutedUntilDate, self.thread.isMuted {
subtitle.append(NSAttributedString(string: "\u{e067} ", attributes: [ .font : UIFont.ows_elegantIconsFont(10), .foregroundColor : Colors.unimportant ]))
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale.current
dateFormatter.timeStyle = .medium
dateFormatter.dateStyle = .medium
subtitle.append(NSAttributedString(string: "Muted until " + dateFormatter.string(from: muteEndDate)))
} else if let thread = self.thread as? TSGroupThread {
var userCount: Int?
if thread.groupModel.groupType == .closedGroup {
userCount = GroupUtilities.getClosedGroupMemberCount(thread)
} else if thread.groupModel.groupType == .openGroup {
if let openGroup = Storage.shared.getOpenGroup(for: self.thread.uniqueId!) {
userCount = Storage.shared.getUserCount(forOpenGroupWithID: openGroup.id)
}
}
if let userCount = userCount {
subtitle.append(NSAttributedString(string: "\(userCount) members"))
} else if let hexEncodedPublicKey = (self.thread as? TSContactThread)?.contactIdentifier(), ECKeyPair.isValidHexEncodedPublicKey(candidate: hexEncodedPublicKey) {
subtitle.append(NSAttributedString(string: hexEncodedPublicKey))
} else {
self.subtitleLabel.isHidden = true
}
}
else {
self.subtitleLabel.isHidden = true
}
self.subtitleLabel.attributedText = subtitle
self.titleLabel.font = .boldSystemFont(ofSize: self.subtitleLabel.isHidden ? Values.veryLargeFontSize : Values.mediumFontSize)
}
}
// MARK: Layout
public override var intrinsicContentSize: CGSize {
return UIView.layoutFittingExpandedSize
}
}

View File

@ -0,0 +1,693 @@
import CoreServices
import Photos
extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuActionDelegate, ScrollToBottomButtonDelegate,
SendMediaNavDelegate, UIDocumentPickerDelegate, AttachmentApprovalViewControllerDelegate, GifPickerViewControllerDelegate {
@objc func openSettings() {
let settingsVC = OWSConversationSettingsViewController()
settingsVC.configure(with: thread, uiDatabaseConnection: OWSPrimaryStorage.shared().uiDatabaseConnection)
settingsVC.conversationSettingsViewDelegate = self
navigationController!.pushViewController(settingsVC, animated: true, completion: nil)
}
func handleScrollToBottomButtonTapped() {
scrollToBottom(isAnimated: true)
}
// MARK: Blocking
@objc func unblock() {
guard let thread = thread as? TSContactThread else { return }
let publicKey = thread.contactIdentifier()
UIView.animate(withDuration: 0.25, animations: {
self.blockedBanner.alpha = 0
}, completion: { _ in
OWSBlockingManager.shared().removeBlockedPhoneNumber(publicKey)
})
}
func showBlockedModalIfNeeded() -> Bool {
guard let thread = thread as? TSContactThread else { return false }
let publicKey = thread.contactIdentifier()
guard OWSBlockingManager.shared().isRecipientIdBlocked(publicKey) else { return false }
let blockedModal = BlockedModal(publicKey: publicKey)
blockedModal.modalPresentationStyle = .overFullScreen
blockedModal.modalTransitionStyle = .crossDissolve
present(blockedModal, animated: true, completion: nil)
return true
}
// MARK: Attachments
func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController) {
dismiss(animated: true, completion: nil)
}
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) {
sendAttachments(attachments, with: messageText ?? "")
scrollToBottom(isAnimated: false)
resetMentions()
self.snInputView.text = ""
dismiss(animated: true) { }
}
func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? {
return snInputView.text
}
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) {
snInputView.text = newMessageText ?? ""
}
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) {
sendAttachments(attachments, with: messageText ?? "")
scrollToBottom(isAnimated: false)
resetMentions()
self.snInputView.text = ""
dismiss(animated: true) { }
}
func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) {
dismiss(animated: true, completion: nil)
}
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) {
snInputView.text = newMessageText ?? ""
}
func handleCameraButtonTapped() {
guard requestCameraPermissionIfNeeded() else { return }
requestMicrophonePermissionIfNeeded { }
if AVAudioSession.sharedInstance().recordPermission != .granted {
SNLog("Proceeding without microphone access. Any recorded video will be silent.")
}
let sendMediaNavController = SendMediaNavigationController.showingCameraFirst()
sendMediaNavController.sendMediaNavDelegate = self
sendMediaNavController.modalPresentationStyle = .fullScreen
present(sendMediaNavController, animated: true, completion: nil)
}
func handleLibraryButtonTapped() {
let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst()
sendMediaNavController.sendMediaNavDelegate = self
sendMediaNavController.modalPresentationStyle = .fullScreen
present(sendMediaNavController, animated: true, completion: nil)
}
func handleGIFButtonTapped() {
let gifVC = GifPickerViewController(thread: thread)
gifVC.delegate = self
let navController = OWSNavigationController(rootViewController: gifVC)
navController.modalPresentationStyle = .fullScreen
present(navController, animated: true) { }
}
func gifPickerDidSelect(attachment: SignalAttachment) {
showAttachmentApprovalDialog(for: [ attachment ])
}
func handleDocumentButtonTapped() {
// UIDocumentPickerModeImport copies to a temp file within our container.
// It uses more memory than "open" but lets us avoid working with security scoped URLs.
let documentPickerVC = UIDocumentPickerViewController(documentTypes: [ kUTTypeItem as String ], in: UIDocumentPickerMode.import)
documentPickerVC.delegate = self
documentPickerVC.modalPresentationStyle = .fullScreen
SNAppearance.switchToDocumentPickerAppearance()
present(documentPickerVC, animated: true, completion: nil)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
SNAppearance.switchToSessionAppearance()
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
SNAppearance.switchToSessionAppearance()
guard let url = urls.first else { return } // TODO: Handle multiple?
let urlResourceValues: URLResourceValues
do {
urlResourceValues = try url.resourceValues(forKeys: [ .typeIdentifierKey, .isDirectoryKey, .nameKey ])
} catch {
let alert = UIAlertController(title: "Session", message: "An error occurred.", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
return present(alert, animated: true, completion: nil)
}
let type = urlResourceValues.typeIdentifier ?? (kUTTypeData as String)
guard urlResourceValues.isDirectory != true else {
DispatchQueue.main.async {
let title = NSLocalizedString("ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE", comment: "")
let message = NSLocalizedString("ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY", comment: "")
OWSAlerts.showAlert(title: title, message: message)
}
return
}
let fileName = urlResourceValues.name ?? NSLocalizedString("ATTACHMENT_DEFAULT_FILENAME", comment: "")
guard let dataSource = DataSourcePath.dataSource(with: url, shouldDeleteOnDeallocation: false) else {
DispatchQueue.main.async {
let title = NSLocalizedString("ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE", comment: "")
OWSAlerts.showAlert(title: title)
}
return
}
dataSource.sourceFilename = fileName
// Although we want to be able to send higher quality attachments through the document picker
// it's more imporant that we ensure the sent format is one all clients can accept (e.g. *not* quicktime .mov)
guard !SignalAttachment.isInvalidVideo(dataSource: dataSource, dataUTI: type) else {
return showAttachmentApprovalDialogAfterProcessingVideo(at: url, with: fileName)
}
// "Document picker" attachments _SHOULD NOT_ be resized
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: type, imageQuality: .original)
showAttachmentApprovalDialog(for: [ attachment ])
}
func showAttachmentApprovalDialog(for attachments: [SignalAttachment]) {
let navController = AttachmentApprovalViewController.wrappedInNavController(attachments: attachments, approvalDelegate: self)
present(navController, animated: true, completion: nil)
}
func showAttachmentApprovalDialogAfterProcessingVideo(at url: URL, with fileName: String) {
ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: true, message: nil) { [weak self] modalActivityIndicator in
let dataSource = DataSourcePath.dataSource(with: url, shouldDeleteOnDeallocation: false)!
dataSource.sourceFilename = fileName
let compressionResult: SignalAttachment.VideoCompressionResult = SignalAttachment.compressVideoAsMp4(dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String)
compressionResult.attachmentPromise.done { attachment in
guard !modalActivityIndicator.wasCancelled, let attachment = attachment as? SignalAttachment else { return }
modalActivityIndicator.dismiss {
if !attachment.hasError {
self?.showAttachmentApprovalDialog(for: [ attachment ])
} else {
self?.showErrorAlert(for: attachment)
}
}
}.retainUntilComplete()
}
}
// MARK: Message Sending
func handleSendButtonTapped() {
sendMessage()
}
func sendMessage() {
guard !showBlockedModalIfNeeded() else { return }
let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines))
let thread = self.thread
guard !text.isEmpty else { return }
let message = VisibleMessage()
message.sentTimestamp = NSDate.millisecondTimestamp()
message.text = text
message.quote = VisibleMessage.Quote.from(snInputView.quoteDraftInfo?.model)
let linkPreviewDraft = snInputView.linkPreviewInfo?.draft
let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread)
viewModel.appendUnsavedOutgoingTextMessage(tsMessage)
Storage.write(with: { transaction in
message.linkPreview = VisibleMessage.LinkPreview.from(linkPreviewDraft, using: transaction)
}, completion: { [weak self] in
tsMessage.linkPreview = OWSLinkPreview.from(message.linkPreview)
Storage.shared.write { transaction in
tsMessage.save(with: transaction as! YapDatabaseReadWriteTransaction)
}
Storage.shared.write { transaction in
MessageSender.send(message, with: [], in: thread, using: transaction as! YapDatabaseReadWriteTransaction)
}
self?.handleMessageSent()
})
}
func sendAttachments(_ attachments: [SignalAttachment], with text: String) {
guard !showBlockedModalIfNeeded() else { return }
for attachment in attachments {
if attachment.hasError {
return showErrorAlert(for: attachment)
}
}
let thread = self.thread
let message = VisibleMessage()
message.sentTimestamp = NSDate.millisecondTimestamp()
message.text = replaceMentions(in: text)
let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread)
Storage.write(with: { transaction in
tsMessage.save(with: transaction)
}, completion: { [weak self] in
Storage.write { transaction in
MessageSender.send(message, with: attachments, in: thread, using: transaction)
}
self?.handleMessageSent()
})
}
func handleMessageSent() {
resetMentions()
self.snInputView.text = ""
self.snInputView.quoteDraftInfo = nil
self.markAllAsRead()
if Environment.shared.preferences.soundInForeground() {
let soundID = OWSSounds.systemSoundID(for: .messageSent, quiet: true)
AudioServicesPlaySystemSound(soundID)
}
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)
}
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
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
}
func resetMentions() {
oldText = ""
currentMentionStartIndex = nil
mentions = []
}
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 }),
let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell,
let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false), contextMenuWindow == nil,
!ContextMenuVC.actions(for: viewItem, delegate: self).isEmpty else { return }
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
let frame = cell.convert(cell.bubbleView.frame, to: UIApplication.shared.keyWindow!)
let window = ContextMenuWindow()
let contextMenuVC = ContextMenuVC(snapshot: snapshot, viewItem: viewItem, frame: frame, delegate: self) { [weak self] in
window.isHidden = true
guard let self = self else { return }
self.contextMenuVC = nil
self.contextMenuWindow = nil
self.scrollButton.alpha = 0
UIView.animate(withDuration: 0.25) {
self.scrollButton.alpha = self.getScrollButtonOpacity()
}
}
self.contextMenuVC = contextMenuVC
contextMenuWindow = window
window.rootViewController = contextMenuVC
window.makeKeyAndVisible()
window.backgroundColor = .clear
}
func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer) {
if let message = viewItem.interaction as? TSOutgoingMessage, message.messageState == .failed {
showFailedMessageSheet(for: message)
} else {
switch viewItem.messageCellType {
case .audio: playOrPauseAudio(for: viewItem)
case .mediaMessage:
guard let index = viewItems.firstIndex(where: { $0 === viewItem }),
let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, let albumView = cell.albumView else { return }
let locationInCell = gestureRecognizer.location(in: cell)
if let overlayView = cell.mediaTextOverlayView {
let locationInOverlayView = cell.convert(locationInCell, to: overlayView)
if let readMoreButton = overlayView.readMoreButton, readMoreButton.frame.contains(locationInOverlayView) {
return showFullText(viewItem) // FIXME: Bit of a hack to do it this way
}
}
let locationInAlbumView = cell.convert(locationInCell, to: albumView)
guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return }
if albumView.isMoreItemsView(mediaView: mediaView) && viewItem.mediaAlbumHasFailedAttachment() {
// TODO: Tapped a failed incoming attachment
}
let attachment = mediaView.attachment
if let pointer = attachment as? TSAttachmentPointer {
if pointer.state == .failed {
// TODO: Tapped a failed incoming attachment
}
}
guard let stream = attachment as? TSAttachmentStream else { return }
let gallery = MediaGallery(thread: thread, options: [ .sliderEnabled, .showAllMediaButton ])
gallery.presentDetailView(fromViewController: self, mediaAttachment: stream, replacingView: mediaView)
case .genericAttachment:
guard let url = viewItem.attachmentStream?.originalMediaURL else { return }
let shareVC = UIActivityViewController(activityItems: [ url ], applicationActivities: nil)
navigationController!.present(shareVC, animated: true, completion: nil)
case .textOnlyMessage:
if let preview = viewItem.linkPreview, let urlAsString = preview.urlString, let url = URL(string: urlAsString) {
openURL(url)
} else if let reply = viewItem.quotedReply {
guard let indexPath = viewModel.ensureLoadWindowContainsQuotedReply(reply) else { return }
messagesTableView.scrollToRow(at: indexPath, at: UITableView.ScrollPosition.middle, animated: true)
}
default: break
}
}
}
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)
default: break
}
}
func showFullText(_ viewItem: ConversationViewItem) {
let longMessageVC = LongTextViewController(viewItem: viewItem)
navigationController!.pushViewController(longMessageVC, animated: true)
}
func reply(_ viewItem: ConversationViewItem) {
var quoteDraftOrNil: OWSQuotedReplyModel?
Storage.read { transaction in
quoteDraftOrNil = OWSQuotedReplyModel.quotedReplyForSending(with: viewItem, threadId: viewItem.interaction.uniqueThreadId, transaction: transaction)
}
guard let quoteDraft = quoteDraftOrNil else { return }
let isOutgoing = (viewItem.interaction.interactionType() == .outgoingMessage)
snInputView.quoteDraftInfo = (model: quoteDraft, isOutgoing: isOutgoing)
snInputView.becomeFirstResponder()
}
func copy(_ viewItem: ConversationViewItem) {
if viewItem.canCopyMedia() {
viewItem.copyMediaAction()
} else {
viewItem.copyTextAction()
}
}
func copySessionID(_ viewItem: ConversationViewItem) {
guard let message = viewItem.interaction as? TSIncomingMessage else { return }
UIPasteboard.general.string = message.authorId
}
func delete(_ viewItem: ConversationViewItem) {
viewItem.deleteAction()
}
func save(_ viewItem: ConversationViewItem) {
guard viewItem.canSaveMedia() else { return }
viewItem.saveMediaAction()
}
func ban(_ viewItem: ConversationViewItem) {
guard let message = viewItem.interaction as? TSIncomingMessage, message.isOpenGroupMessage else { return }
let alert = UIAlertController(title: "Ban This User?", message: nil, preferredStyle: .alert)
let threadID = thread.uniqueId!
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in
guard let openGroup = Storage.shared.getOpenGroup(for: threadID) else { return }
let publicKey = message.authorId
OpenGroupAPI.ban(publicKey, from: openGroup.server).retainUntilComplete()
}))
alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil))
present(alert, animated: true, completion: nil)
}
func handleQuoteViewCancelButtonTapped() {
snInputView.quoteDraftInfo = nil
}
func openURL(_ url: URL) {
let urlModal = URLModal(url: url)
urlModal.modalPresentationStyle = .overFullScreen
urlModal.modalTransitionStyle = .crossDissolve
present(urlModal, animated: true, completion: nil)
}
func handleReplyButtonTapped(for viewItem: ConversationViewItem) {
reply(viewItem)
}
// 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
guard let path = attachment.originalFilePath, fileManager.fileExists(atPath: path),
let url = attachment.originalMediaURL else { return }
if let audioPlayer = audioPlayer {
if let owner = audioPlayer.owner as? ConversationViewItem, owner === viewItem {
audioPlayer.playbackRate = 1
audioPlayer.togglePlayState()
return
} else {
audioPlayer.stop()
self.audioPlayer = nil
}
}
let audioPlayer = OWSAudioPlayer(mediaUrl: url, audioBehavior: .audioMessagePlayback, delegate: viewItem)
self.audioPlayer = audioPlayer
audioPlayer.owner = viewItem
audioPlayer.play()
audioPlayer.setCurrentTime(Double(viewItem.audioProgressSeconds))
}
func speedUpAudio(for viewItem: ConversationViewItem) {
guard let audioPlayer = audioPlayer, let owner = audioPlayer.owner as? ConversationViewItem, owner === viewItem, audioPlayer.isPlaying else { return }
audioPlayer.playbackRate = 1.5
viewItem.lastAudioMessageView?.showSpeedUpLabel()
}
// MARK: Voice Message Recording
func startVoiceMessageRecording() {
// Request permission if needed
requestMicrophonePermissionIfNeeded() { [weak self] in
self?.cancelVoiceMessageRecording()
}
guard AVAudioSession.sharedInstance().recordPermission == .granted else { return }
// Cancel any current audio playback
audioPlayer?.stop()
audioPlayer = nil
// Create URL
let directory = OWSTemporaryDirectory()
let fileName = "\(NSDate.millisecondTimestamp()).m4a"
let path = (directory as NSString).appendingPathComponent(fileName)
let url = URL(fileURLWithPath: path)
// Set up audio session
let isConfigured = audioSession.startAudioActivity(recordVoiceMessageActivity)
guard isConfigured else {
return cancelVoiceMessageRecording()
}
// Set up audio recorder
let settings: [String:NSNumber] = [
AVFormatIDKey : NSNumber(value: kAudioFormatMPEG4AAC),
AVSampleRateKey : NSNumber(value: 44100),
AVNumberOfChannelsKey : NSNumber(value: 2),
AVEncoderBitRateKey : NSNumber(value: 128 * 1024)
]
let audioRecorder: AVAudioRecorder
do {
audioRecorder = try AVAudioRecorder(url: url, settings: settings)
audioRecorder.isMeteringEnabled = true
self.audioRecorder = audioRecorder
} catch {
SNLog("Couldn't start audio recording due to error: \(error).")
return cancelVoiceMessageRecording()
}
// Limit voice messages to a minute
audioTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: false, block: { [weak self] _ in
self?.snInputView.hideVoiceMessageUI()
self?.endVoiceMessageRecording()
})
// Prepare audio recorder
guard audioRecorder.prepareToRecord() else {
SNLog("Couldn't prepare audio recorder.")
return cancelVoiceMessageRecording()
}
// Start recording
guard audioRecorder.record() else {
SNLog("Couldn't record audio.")
return cancelVoiceMessageRecording()
}
}
func endVoiceMessageRecording() {
// Hide the UI
snInputView.hideVoiceMessageUI()
// Cancel the timer
audioTimer?.invalidate()
// Check preconditions
guard let audioRecorder = audioRecorder else { return }
// Get duration
let duration = audioRecorder.currentTime
// Stop the recording
stopVoiceMessageRecording()
// Check for user misunderstanding
guard duration > 1 else {
self.audioRecorder = nil
let title = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE", comment: "")
let message = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE", comment: "")
return OWSAlerts.showAlert(title: title, message: message)
}
// Get data
let dataSourceOrNil = DataSourcePath.dataSource(with: audioRecorder.url, shouldDeleteOnDeallocation: true)
self.audioRecorder = nil
guard let dataSource = dataSourceOrNil else { return SNLog("Couldn't load recorded data.") }
// Create attachment
let fileName = (NSLocalizedString("VOICE_MESSAGE_FILE_NAME", comment: "") as NSString).appendingPathExtension("m4a")
dataSource.sourceFilename = fileName
let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4Audio as String)
guard !attachment.hasError else {
return showErrorAlert(for: attachment)
}
// Send attachment
sendAttachments([ attachment ], with: "")
}
func cancelVoiceMessageRecording() {
snInputView.hideVoiceMessageUI()
audioTimer?.invalidate()
stopVoiceMessageRecording()
audioRecorder = nil
}
func stopVoiceMessageRecording() {
audioRecorder?.stop()
audioSession.endAudioActivity(recordVoiceMessageActivity)
}
// MARK: Requesting Permission
func requestCameraPermissionIfNeeded() -> Bool {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized: return true
case .denied, .restricted:
let modal = PermissionMissingModal(permission: "camera") { }
modal.modalPresentationStyle = .overFullScreen
modal.modalTransitionStyle = .crossDissolve
present(modal, animated: true, completion: nil)
return false
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video, completionHandler: { _ in })
return false
default: return false
}
}
func requestMicrophonePermissionIfNeeded(onNotGranted: @escaping () -> Void) {
switch AVAudioSession.sharedInstance().recordPermission {
case .granted: break
case .denied:
onNotGranted()
let modal = PermissionMissingModal(permission: "microphone") {
onNotGranted()
}
modal.modalPresentationStyle = .overFullScreen
modal.modalTransitionStyle = .crossDissolve
present(modal, animated: true, completion: nil)
case .undetermined:
onNotGranted()
AVAudioSession.sharedInstance().requestRecordPermission { _ in }
default: break
}
}
func requestLibraryPermissionIfNeeded() -> Bool {
switch PHPhotoLibrary.authorizationStatus() {
case .authorized, .limited: return true
case .denied, .restricted:
let modal = PermissionMissingModal(permission: "library") { }
modal.modalPresentationStyle = .overFullScreen
modal.modalTransitionStyle = .crossDissolve
present(modal, animated: true, completion: nil)
return false
case .notDetermined:
PHPhotoLibrary.requestAuthorization { _ in }
return false
default: return false
}
}
// MARK: Convenience
func showErrorAlert(for attachment: SignalAttachment) {
let title = NSLocalizedString("ATTACHMENT_ERROR_ALERT_TITLE", comment: "")
let message = attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage
OWSAlerts.showAlert(title: title, message: message)
}
}

View File

@ -0,0 +1,494 @@
// TODO
// Slight paging glitch
// Image detail VC transition glitch
// Photo rounding
// Scroll button behind mentions view
// Remaining search glitchiness
final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversationSettingsViewDelegate, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate {
let thread: TSThread
let focusedMessageID: String?
var didConstrainScrollButton = false
// Search
var isShowingSearchUI = false
var lastSearchedText: String?
// Audio playback & recording
var audioPlayer: OWSAudioPlayer?
var audioRecorder: AVAudioRecorder?
var audioTimer: Timer?
// Context menu
var contextMenuWindow: ContextMenuWindow?
var contextMenuVC: ContextMenuVC?
// Mentions
var oldText = ""
var currentMentionStartIndex: String.Index?
var mentions: [Mention] = []
// Scrolling & paging
var isUserScrolling = false
var didFinishInitialLayout = false
var isLoadingMore = false
var scrollDistanceToBottomBeforeUpdate: CGFloat?
var audioSession: OWSAudioSession { Environment.shared.audioSession }
var dbConnection: YapDatabaseConnection { OWSPrimaryStorage.shared().uiDatabaseConnection }
var viewItems: [ConversationViewItem] { viewModel.viewState.viewItems }
override var inputAccessoryView: UIView? { isShowingSearchUI ? searchController.resultsBar : snInputView }
override var canBecomeFirstResponder: Bool { true }
var tableViewUnobscuredHeight: CGFloat {
let bottomInset = messagesTableView.adjustedContentInset.bottom
return messagesTableView.bounds.height - bottomInset
}
var lastPageTop: CGFloat {
return messagesTableView.contentSize.height - tableViewUnobscuredHeight
}
lazy var viewModel = ConversationViewModel(thread: thread, focusMessageIdOnOpen: focusedMessageID, delegate: self)
lazy var mediaCache: NSCache<NSString, AnyObject> = {
let result = NSCache<NSString, AnyObject>()
result.countLimit = 40
return result
}()
lazy var recordVoiceMessageActivity = AudioActivity(audioDescription: "Voice message", behavior: .playAndRecord)
lazy var searchController: ConversationSearchController = {
let result = ConversationSearchController(thread: thread)
result.delegate = self
if #available(iOS 13, *) {
result.uiSearchController.obscuresBackgroundDuringPresentation = false
} else {
result.uiSearchController.dimsBackgroundDuringPresentation = false
}
return result
}()
// MARK: UI Components
lazy var titleView = ConversationTitleView(thread: thread)
lazy var messagesTableView: MessagesTableView = {
let result = MessagesTableView()
result.dataSource = self
result.delegate = self
return result
}()
lazy var snInputView = InputView(delegate: self)
lazy var scrollButton = ScrollToBottomButton(delegate: self)
lazy var blockedBanner: InfoBanner = {
let name: String
if let thread = thread as? TSContactThread {
let publicKey = thread.contactIdentifier()
name = OWSProfileManager.shared().profileNameForRecipient(withID: publicKey, avoidingWriteTransaction: true) ?? publicKey
} else {
name = "Thread"
}
let message = "\(name) is blocked. Unblock them?"
let result = InfoBanner(message: message, backgroundColor: Colors.destructive)
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(unblock))
result.addGestureRecognizer(tapGestureRecognizer)
return result
}()
// MARK: Settings
static let bottomInset = Values.mediumSpacing
static let loadMoreThreshold: CGFloat = 120
/// The button will be fully visible once the user has scrolled this amount from the bottom of the table view.
static let scrollButtonFullVisibilityThreshold: CGFloat = 80
/// The button will be invisible until the user has scrolled at least this amount from the bottom of the table view.
static let scrollButtonNoVisibilityThreshold: CGFloat = 20
// MARK: Lifecycle
init(thread: TSThread, focusedMessageID: String? = nil) {
self.thread = thread
self.focusedMessageID = focusedMessageID
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(thread:) instead.")
}
override func viewDidLoad() {
super.viewDidLoad()
// Gradient
setUpGradientBackground()
// Nav bar
setUpNavBarStyle()
navigationItem.titleView = titleView
updateNavBarButtons()
// Constraints
view.addSubview(messagesTableView)
messagesTableView.pin(to: view)
view.addSubview(scrollButton)
scrollButton.pin(.right, to: .right, of: view, withInset: -22)
// Blocked banner
addOrRemoveBlockedBanner()
// Notifications
let notificationCenter = NotificationCenter.default
notificationCenter.addObserver(self, selector: #selector(handleKeyboardWillChangeFrameNotification(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
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() {
super.viewDidLayoutSubviews()
if !didFinishInitialLayout {
DispatchQueue.main.async {
self.scrollToBottom(isAnimated: false)
}
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
didFinishInitialLayout = true
markAllAsRead()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
mediaCache.removeAllObjects()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: Table View Data Source
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewItems.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let viewItem = viewItems[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: MessageCell.getCellType(for: viewItem).identifier) as! MessageCell
cell.delegate = self
cell.viewItem = viewItem
return cell
}
// MARK: Updating
func updateNavBarButtons() {
navigationItem.hidesBackButton = isShowingSearchUI
if isShowingSearchUI {
navigationItem.rightBarButtonItems = []
} else {
let rightBarButtonItem: UIBarButtonItem
if thread is TSContactThread {
let size = Values.verySmallProfilePictureSize
let profilePictureView = ProfilePictureView()
profilePictureView.accessibilityLabel = "Settings button"
profilePictureView.size = size
profilePictureView.update(for: thread)
profilePictureView.set(.width, to: size)
profilePictureView.set(.height, to: size)
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings))
profilePictureView.addGestureRecognizer(tapGestureRecognizer)
rightBarButtonItem = UIBarButtonItem(customView: profilePictureView)
} else {
rightBarButtonItem = UIBarButtonItem(image: UIImage(named: "Gear"), style: .plain, target: self, action: #selector(openSettings))
}
rightBarButtonItem.accessibilityLabel = "Settings button"
rightBarButtonItem.isAccessibilityElement = true
navigationItem.rightBarButtonItem = rightBarButtonItem
}
}
@objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) {
guard let newHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.size.height else { return }
if !didConstrainScrollButton {
// Bit of a hack to do this here, but it works out.
scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -(newHeight + 22))
didConstrainScrollButton = true
}
UIView.animate(withDuration: 0.25) {
self.messagesTableView.keyboardHeight = newHeight
self.scrollButton.alpha = 0
}
}
@objc func handleKeyboardWillHideNotification(_ notification: Notification) {
UIView.animate(withDuration: 0.25) {
self.messagesTableView.keyboardHeight = 0
self.scrollButton.alpha = self.getScrollButtonOpacity()
}
}
func conversationViewModelWillUpdate() {
}
func conversationViewModelDidUpdate(_ conversationUpdate: ConversationUpdate) {
guard self.isViewLoaded else { return }
// TODO: Reload the thread if it's a group thread?
let updateType = conversationUpdate.conversationUpdateType
guard updateType != .minor else { return } // No view items were affected
if updateType == .reload {
return messagesTableView.reloadData()
}
var shouldScrollToBottom = false
let shouldAnimate = conversationUpdate.shouldAnimateUpdates
let batchUpdates: () -> Void = {
for update in conversationUpdate.updateItems! {
switch update.updateItemType {
case .delete:
self.messagesTableView.deleteRows(at: [ IndexPath(row: Int(update.oldIndex), section: 0) ], with: .fade)
case .insert:
// Perform inserts before updates
self.messagesTableView.insertRows(at: [ IndexPath(row: Int(update.newIndex), section: 0) ], with: .fade)
let viewItem = update.viewItem
if viewItem?.interaction is TSOutgoingMessage {
shouldScrollToBottom = true
}
case .update:
self.messagesTableView.reloadRows(at: [ IndexPath(row: Int(update.oldIndex), section: 0) ], with: .fade)
default: preconditionFailure()
}
}
}
let batchUpdatesCompletion: (Bool) -> Void = { isFinished in
// TODO: Update last visible sort ID?
if shouldScrollToBottom {
self.scrollToBottom(isAnimated: true)
}
// TODO: Update last known distance from bottom
}
if shouldAnimate {
messagesTableView.performBatchUpdates(batchUpdates, completion: batchUpdatesCompletion)
} else {
// HACK: We use `UIView.animateWithDuration:0` rather than `UIView.performWithAnimation` to work around a
// UIKit Crash like:
//
// *** Assertion failure in -[ConversationViewLayout prepareForCollectionViewUpdates:],
// /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit_Sim/UIKit-3600.7.47/UICollectionViewLayout.m:760
// *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'While
// preparing update a visible view at <NSIndexPath: 0xc000000011c00016> {length = 2, path = 0 - 142}
// wasn't found in the current data model and was not in an update animation. This is an internal
// error.'
//
// I'm unclear if this is a bug in UIKit, or if we're doing something crazy in
// ConversationViewLayout#prepareLayout. To reproduce, rapidily insert and delete items into the
// conversation.
UIView.animate(withDuration: 0) {
self.messagesTableView.performBatchUpdates(batchUpdates, completion: batchUpdatesCompletion)
if shouldScrollToBottom {
self.scrollToBottom(isAnimated: false)
}
}
}
// TODO: Set last reload date?
}
func conversationViewModelWillLoadMoreItems() {
view.layoutIfNeeded()
scrollDistanceToBottomBeforeUpdate = messagesTableView.contentSize.height - messagesTableView.contentOffset.y
}
func conversationViewModelDidLoadMoreItems() {
guard let scrollDistanceToBottomBeforeUpdate = scrollDistanceToBottomBeforeUpdate else { return }
view.layoutIfNeeded()
messagesTableView.contentOffset.y = messagesTableView.contentSize.height - scrollDistanceToBottomBeforeUpdate
isLoadingMore = false
}
func conversationViewModelDidLoadPrevPage() {
}
func conversationViewModelRangeDidChange() {
}
func conversationViewModelDidReset() {
}
// MARK: General
@objc func addOrRemoveBlockedBanner() {
func detach() {
blockedBanner.removeFromSuperview()
}
guard let thread = thread as? TSContactThread else { return detach() }
if OWSBlockingManager.shared().isRecipientIdBlocked(thread.contactIdentifier()) {
view.addSubview(blockedBanner)
blockedBanner.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view)
} else {
detach()
}
}
func markAllAsRead() {
guard let lastSortID = viewItems.last?.interaction.sortId else { return }
OWSReadReceiptManager.shared().markAsReadLocally(beforeSortId: lastSortID, thread: thread)
}
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableView.automaticDimension
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableView.automaticDimension
}
func getMediaCache() -> NSCache<NSString, AnyObject> {
return mediaCache
}
func scrollToBottom(isAnimated: Bool) {
guard !isUserScrolling else { return }
// Ensure the view is fully up to date before we try to scroll to the bottom, since
// we use the table view's bounds to determine where the bottom is.
view.layoutIfNeeded()
let firstContentPageTop: CGFloat = 0
let contentOffsetY = max(firstContentPageTop, lastPageTop)
messagesTableView.setContentOffset(CGPoint(x: 0, y: contentOffsetY), animated: isAnimated)
// TODO: Did scroll to bottom
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
isUserScrolling = true
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
isUserScrolling = false
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollButton.alpha = getScrollButtonOpacity()
autoLoadMoreIfNeeded()
}
func autoLoadMoreIfNeeded() {
let isMainAppAndActive = CurrentAppContext().isMainAppAndActive
guard isMainAppAndActive && viewModel.canLoadMoreItems() && !isLoadingMore
&& messagesTableView.contentOffset.y < ConversationVC.loadMoreThreshold else { return }
isLoadingMore = true
viewModel.loadAnotherPageOfMessages()
}
func getScrollButtonOpacity() -> CGFloat {
let contentOffsetY = messagesTableView.contentOffset.y
let x = (lastPageTop - ConversationVC.bottomInset - contentOffsetY).clamp(0, .greatestFiniteMagnitude)
let a = 1 / (ConversationVC.scrollButtonFullVisibilityThreshold - ConversationVC.scrollButtonNoVisibilityThreshold)
return a * x
}
func groupWasUpdated(_ groupModel: TSGroupModel) {
// Do nothing
}
// MARK: Search
func conversationSettingsDidRequestConversationSearch(_ conversationSettingsViewController: OWSConversationSettingsViewController) {
showSearchUI()
popAllConversationSettingsViews {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.searchController.uiSearchController.searchBar.becomeFirstResponder()
}
}
}
func popAllConversationSettingsViews(completion completionBlock: (() -> Void)? = nil) {
if presentedViewController != nil {
dismiss(animated: true) {
self.navigationController!.popToViewController(self, animated: true, completion: completionBlock)
}
} else {
navigationController!.popToViewController(self, animated: true, completion: completionBlock)
}
}
func showSearchUI() {
isShowingSearchUI = true
// Search bar
let searchBar = searchController.uiSearchController.searchBar
searchBar.searchBarStyle = .minimal
searchBar.barStyle = .black
searchBar.tintColor = Colors.accent
let searchIcon = UIImage(named: "searchbar_search")!.asTintedImage(color: Colors.searchBarPlaceholder)
searchBar.setImage(searchIcon, for: .search, state: UIControl.State.normal)
let clearIcon = UIImage(named: "searchbar_clear")!.asTintedImage(color: Colors.searchBarPlaceholder)
searchBar.setImage(clearIcon, for: .clear, state: UIControl.State.normal)
let searchTextField: UITextField
if #available(iOS 13, *) {
searchTextField = searchBar.searchTextField
} else {
searchTextField = searchBar.value(forKey: "_searchField") as! UITextField
}
searchTextField.backgroundColor = Colors.searchBarBackground
searchTextField.textColor = Colors.text
searchTextField.attributedPlaceholder = NSAttributedString(string: "Search", attributes: [ .foregroundColor : Colors.searchBarPlaceholder ])
searchTextField.keyboardAppearance = isLightMode ? .default : .dark
searchBar.setPositionAdjustment(UIOffset(horizontal: 4, vertical: 0), for: .search)
searchBar.searchTextPositionAdjustment = UIOffset(horizontal: 2, vertical: 0)
searchBar.setPositionAdjustment(UIOffset(horizontal: -4, vertical: 0), for: .clear)
navigationItem.titleView = searchBar
// Nav bar buttons
updateNavBarButtons()
// Hack so that the ResultsBar stays on the screen when dismissing the search field
// keyboard.
//
// Details:
//
// When the search UI is activated, both the SearchField and the ConversationVC
// have the resultsBar as their inputAccessoryView.
//
// So when the SearchField is first responder, the ResultsBar is shown on top of the keyboard.
// When the ConversationVC is first responder, the ResultsBar is shown at the bottom of the
// screen.
//
// When the user swipes to dismiss the keyboard, trying to see more of the content while
// searching, we want the ResultsBar to stay at the bottom of the screen - that is, we
// want the ConversationVC to becomeFirstResponder.
//
// If the SearchField were a subview of ConversationVC.view, this would all be automatic,
// as first responder status is percolated up the responder chain via `nextResponder`, which
// basically travereses each superView, until you're at a rootView, at which point the next
// responder is the ViewController which controls that View.
//
// However, because SearchField lives in the Navbar, it's "controlled" by the
// NavigationController, not the ConversationVC.
//
// So here we stub the next responder on the navBar so that when the searchBar resigns
// first responder, the ConversationVC will be in it's responder chain - keeeping the
// ResultsBar on the bottom of the screen after dismissing the keyboard.
let navBar = navigationController!.navigationBar as! OWSNavigationBar
navBar.stubbedNextResponder = self
}
func hideSearchUI() {
isShowingSearchUI = false
navigationItem.titleView = titleView
updateNavBarButtons()
let navBar = navigationController!.navigationBar as! OWSNavigationBar
navBar.stubbedNextResponder = nil
becomeFirstResponder()
reloadInputViews()
}
func didDismissSearchController(_ searchController: UISearchController) {
hideSearchUI()
}
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?) {
lastSearchedText = resultSet?.searchText
messagesTableView.reloadRows(at: messagesTableView.indexPathsForVisibleRows ?? [], with: UITableView.RowAnimation.none)
}
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectMessageId interactionID: String) {
scrollToInteraction(with: interactionID)
}
func scrollToInteraction(with interactionID: String) {
guard let indexPath = viewModel.ensureLoadWindowContainsInteractionId(interactionID) else { return }
messagesTableView.scrollToRow(at: indexPath, at: UITableView.ScrollPosition.middle, animated: true)
}
}

View File

@ -0,0 +1,8 @@
@import Foundation;
typedef NS_ENUM(NSUInteger, ConversationViewAction) {
ConversationViewActionNone,
ConversationViewActionCompose,
ConversationViewActionAudioCall,
ConversationViewActionVideoCall,
};

View File

@ -1,37 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import <SignalUtilitiesKit/OWSViewController.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSUInteger, ConversationViewAction) {
ConversationViewActionNone,
ConversationViewActionCompose,
ConversationViewActionAudioCall,
ConversationViewActionVideoCall,
};
@class TSThread;
@interface ConversationViewController : OWSViewController
@property (nonatomic, readonly) TSThread *thread;
- (void)configureForThread:(TSThread *)thread
action:(ConversationViewAction)action
focusMessageId:(nullable NSString *)focusMessageId;
- (void)popKeyBoard;
- (void)scrollToFirstUnreadMessage:(BOOL)isAnimated;
#pragma mark 3D Touch Methods
- (void)peekSetup;
- (void)popped;
@end
NS_ASSUME_NONNULL_END

File diff suppressed because it is too large Load Diff

View File

@ -2,11 +2,12 @@
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "ConversationViewLayout.h"
#import <SessionMessagingKit/OWSAudioPlayer.h>
NS_ASSUME_NONNULL_BEGIN
extern NSString *const SNAudioDidFinishPlayingNotification;
typedef NS_ENUM(NSInteger, OWSMessageCellType) {
OWSMessageCellType_Unknown,
OWSMessageCellType_TextOnlyMessage,
@ -23,7 +24,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType);
@class ContactShareViewModel;
@class ConversationViewCell;
@class DisplayableText;
@class LKVoiceMessageView;
@class SNVoiceMessageView;
@class OWSLinkPreview;
@class OWSQuotedReplyModel;
@class OWSUnreadIndicator;
@ -52,14 +53,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType);
#pragma mark -
// This is a ViewModel for cells in the conversation view.
//
// The lifetime of this class is the lifetime of that cell
// in the load window of the conversation view.
//
// Critically, this class implements ConversationViewLayoutItem
// and does caching of the cell's size.
@protocol ConversationViewItem <NSObject, ConversationViewLayoutItem, OWSAudioPlayerDelegate>
@protocol ConversationViewItem <NSObject, OWSAudioPlayerDelegate>
@property (nonatomic, readonly) TSInteraction *interaction;
@ -79,17 +73,16 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType);
@property (nonatomic, readonly) BOOL isExpiringMessage;
@property (nonatomic) BOOL shouldShowDate;
@property (nonatomic) BOOL shouldShowSenderAvatar;
@property (nonatomic) BOOL shouldShowSenderProfilePicture;
@property (nonatomic, nullable) NSAttributedString *senderName;
@property (nonatomic) BOOL shouldHideFooter;
@property (nonatomic) BOOL isFirstInCluster;
@property (nonatomic) BOOL isOnlyMessageInCluster;
@property (nonatomic) BOOL isLastInCluster;
@property (nonatomic) BOOL wasPreviousItemInfoMessage;
@property (nonatomic, nullable) OWSUnreadIndicator *unreadIndicator;
- (ConversationViewCell *)dequeueCellForCollectionView:(UICollectionView *)collectionView
indexPath:(NSIndexPath *)indexPath;
- (void)replaceInteraction:(TSInteraction *)interaction transaction:(YapDatabaseReadTransaction *)transaction;
- (void)clearCachedLayoutState;
@ -98,7 +91,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType);
#pragma mark - Audio Playback
@property (nonatomic, weak) LKVoiceMessageView *lastAudioMessageView;
@property (nonatomic, weak) SNVoiceMessageView *lastAudioMessageView;
@property (nonatomic, readonly) CGFloat audioDurationSeconds;
@property (nonatomic, readonly) CGFloat audioProgressSeconds;
@ -157,13 +150,12 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType);
#pragma mark -
@interface ConversationInteractionViewItem
: NSObject <ConversationViewItem, ConversationViewLayoutItem, OWSAudioPlayerDelegate>
: NSObject <ConversationViewItem, OWSAudioPlayerDelegate>
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithInteraction:(TSInteraction *)interaction
isGroupThread:(BOOL)isGroupThread
transaction:(YapDatabaseReadTransaction *)transaction
conversationStyle:(ConversationStyle *)conversationStyle;
transaction:(YapDatabaseReadTransaction *)transaction;
@end

View File

@ -4,22 +4,19 @@
#import <CoreServices/CoreServices.h>
#import "ConversationViewItem.h"
#import "OWSMessageCell.h"
#import "OWSMessageHeaderView.h"
#import "OWSSystemMessageCell.h"
#import "Session-Swift.h"
#import "AnyPromise.h"
#import <SignalUtilitiesKit/OWSUnreadIndicator.h>
#import <SessionUtilitiesKit/NSData+Image.h>
#import <SessionUtilitiesKit/NSString+SSK.h>
#import <SessionMessagingKit/TSInteraction.h>
#import <SessionMessagingKit/SSKEnvironment.h>
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
NS_ASSUME_NONNULL_BEGIN
NSString *const SNAudioDidFinishPlayingNotification = @"SNAudioDidFinishPlayingNotification";
NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
{
switch (cellType) {
@ -102,7 +99,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
@property (nonatomic, nullable) NSString *systemMessageText;
@property (nonatomic, nullable) TSThread *incomingMessageAuthorThread;
@property (nonatomic, nullable) NSString *authorConversationColorName;
@property (nonatomic, nullable) ConversationStyle *conversationStyle;
@end
@ -111,13 +107,15 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
@implementation ConversationInteractionViewItem
@synthesize shouldShowDate = _shouldShowDate;
@synthesize shouldShowSenderAvatar = _shouldShowSenderAvatar;
@synthesize shouldShowSenderProfilePicture = _shouldShowSenderProfilePicture;
@synthesize unreadIndicator = _unreadIndicator;
@synthesize didCellMediaFailToLoad = _didCellMediaFailToLoad;
@synthesize interaction = _interaction;
@synthesize isFirstInCluster = _isFirstInCluster;
@synthesize isGroupThread = _isGroupThread;
@synthesize isOnlyMessageInCluster = _isOnlyMessageInCluster;
@synthesize isLastInCluster = _isLastInCluster;
@synthesize wasPreviousItemInfoMessage = _wasPreviousItemInfoMessage;
@synthesize lastAudioMessageView = _lastAudioMessageView;
@synthesize senderName = _senderName;
@synthesize shouldHideFooter = _shouldHideFooter;
@ -125,11 +123,9 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
- (instancetype)initWithInteraction:(TSInteraction *)interaction
isGroupThread:(BOOL)isGroupThread
transaction:(YapDatabaseReadTransaction *)transaction
conversationStyle:(ConversationStyle *)conversationStyle
{
OWSAssertDebug(interaction);
OWSAssertDebug(transaction);
OWSAssertDebug(conversationStyle);
self = [super init];
@ -139,7 +135,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
_interaction = interaction;
_isGroupThread = isGroupThread;
_conversationStyle = conversationStyle;
[self ensureViewState:transaction];
@ -228,13 +223,13 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
[self clearCachedLayoutState];
}
- (void)setShouldShowSenderAvatar:(BOOL)shouldShowSenderAvatar
- (void)setShouldShowSenderAvatar:(BOOL)shouldShowSenderProfilePicture
{
if (_shouldShowSenderAvatar == shouldShowSenderAvatar) {
if (_shouldShowSenderProfilePicture == shouldShowSenderProfilePicture) {
return;
}
_shouldShowSenderAvatar = shouldShowSenderAvatar;
_shouldShowSenderProfilePicture = shouldShowSenderProfilePicture;
[self clearCachedLayoutState];
}
@ -307,114 +302,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
return self.cachedCellSize != nil;
}
- (CGSize)cellSize
{
OWSAssertIsOnMainThread();
OWSAssertDebug(self.conversationStyle);
if (!self.cachedCellSize) {
ConversationViewCell *_Nullable measurementCell = [self measurementCell];
measurementCell.viewItem = self;
measurementCell.conversationStyle = self.conversationStyle;
CGSize cellSize = [measurementCell cellSize];
self.cachedCellSize = [NSValue valueWithCGSize:cellSize];
[measurementCell prepareForReuse];
}
return [self.cachedCellSize CGSizeValue];
}
- (nullable ConversationViewCell *)measurementCell
{
OWSAssertIsOnMainThread();
OWSAssertDebug(self.interaction);
// For performance reasons, we cache one instance of each kind of
// cell and uses these cells for measurement.
static NSMutableDictionary<NSNumber *, ConversationViewCell *> *measurementCellCache = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
measurementCellCache = [NSMutableDictionary new];
});
NSNumber *cellCacheKey = @(self.interaction.interactionType);
ConversationViewCell *_Nullable measurementCell = measurementCellCache[cellCacheKey];
if (!measurementCell) {
switch (self.interaction.interactionType) {
case OWSInteractionType_Unknown:
OWSFailDebug(@"Unknown interaction type.");
return nil;
case OWSInteractionType_IncomingMessage:
case OWSInteractionType_OutgoingMessage:
measurementCell = [OWSMessageCell new];
break;
case OWSInteractionType_Error:
case OWSInteractionType_Info:
case OWSInteractionType_Call:
measurementCell = [OWSSystemMessageCell new];
break;
case OWSInteractionType_TypingIndicator:
measurementCell = [OWSTypingIndicatorCell new];
break;
}
OWSAssertDebug(measurementCell);
measurementCellCache[cellCacheKey] = measurementCell;
}
return measurementCell;
}
- (CGFloat)vSpacingWithPreviousLayoutItem:(id<ConversationViewItem>)previousLayoutItem
{
OWSAssertDebug(previousLayoutItem);
if (self.hasCellHeader) {
return OWSMessageHeaderViewDateHeaderVMargin;
}
// "Bubble Collapse". Adjacent messages with the same author should be close together.
if (self.interaction.interactionType == OWSInteractionType_IncomingMessage
&& previousLayoutItem.interaction.interactionType == OWSInteractionType_IncomingMessage) {
TSIncomingMessage *incomingMessage = (TSIncomingMessage *)self.interaction;
TSIncomingMessage *previousIncomingMessage = (TSIncomingMessage *)previousLayoutItem.interaction;
if ([incomingMessage.authorId isEqualToString:previousIncomingMessage.authorId]) {
return 2.f;
}
} else if (self.interaction.interactionType == OWSInteractionType_OutgoingMessage
&& previousLayoutItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) {
return 2.f;
}
return 12.f;
}
- (ConversationViewCell *)dequeueCellForCollectionView:(UICollectionView *)collectionView
indexPath:(NSIndexPath *)indexPath
{
OWSAssertIsOnMainThread();
OWSAssertDebug(collectionView);
OWSAssertDebug(indexPath);
OWSAssertDebug(self.interaction);
switch (self.interaction.interactionType) {
case OWSInteractionType_Unknown:
OWSFailDebug(@"Unknown interaction type.");
return nil;
case OWSInteractionType_IncomingMessage:
case OWSInteractionType_OutgoingMessage:
return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSMessageCell cellReuseIdentifier]
forIndexPath:indexPath];
case OWSInteractionType_Error:
case OWSInteractionType_Info:
case OWSInteractionType_Call:
return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSSystemMessageCell cellReuseIdentifier]
forIndexPath:indexPath];
case OWSInteractionType_TypingIndicator:
return [collectionView dequeueReusableCellWithReuseIdentifier:[OWSTypingIndicatorCell cellReuseIdentifier]
forIndexPath:indexPath];
}
}
- (nullable TSAttachmentStream *)firstValidAlbumAttachment
{
OWSAssertDebug(self.mediaAlbumItems.count > 0);
@ -446,7 +333,7 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
self.audioProgressSeconds = progress;
[self.lastAudioMessageView setProgress:progress / duration];
[self.lastAudioMessageView setProgress:(int)(progress)];
}
- (void)showInvalidAudioFileAlert
@ -458,6 +345,12 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
@"Message for the alert indicating that an audio file is invalid.")];
}
- (void)audioPlayerDidFinishPlaying:(OWSAudioPlayer *)player successfully:(BOOL)flag
{
if (!flag) { return; }
[NSNotificationCenter.defaultCenter postNotificationName:SNAudioDidFinishPlayingNotification object:nil];
}
#pragma mark - Displayable Text
// TODO: Now that we're caching the displayable text on the view items,
@ -659,9 +552,6 @@ NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType)
if (self.hasBodyText) {
if (self.messageCellType == OWSMessageCellType_Unknown) {
// OWSAssertDebug(message.attachmentIds.count == 0
// || (message.attachmentIds.count == 1 &&
// [message oversizeTextAttachmentWithTransaction:transaction] != nil));
self.messageCellType = OWSMessageCellType_TextOnlyMessage;
}
OWSAssertDebug(self.displayableBodyText);

View File

@ -1,44 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@class ConversationStyle;
@protocol ConversationViewLayoutItem <NSObject>
- (CGSize)cellSize;
- (CGFloat)vSpacingWithPreviousLayoutItem:(id<ConversationViewLayoutItem>)previousLayoutItem;
@end
#pragma mark -
@protocol ConversationViewLayoutDelegate <NSObject>
- (NSArray<id<ConversationViewLayoutItem>> *)layoutItems;
- (CGFloat)layoutHeaderHeight;
@end
#pragma mark -
// A new lean and efficient layout for conversation view designed to
// handle our edge cases (e.g. full-width unread indicators, etc.).
@interface ConversationViewLayout : UICollectionViewLayout
@property (nonatomic, weak) id<ConversationViewLayoutDelegate> delegate;
@property (nonatomic, readonly) BOOL hasLayout;
@property (nonatomic, readonly) BOOL hasEverHadLayout;
@property (nonatomic, readonly) ConversationStyle *conversationStyle;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConversationStyle:(ConversationStyle *)conversationStyle;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,168 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "ConversationViewLayout.h"
#import "Session-Swift.h"
#import "UIView+OWS.h"
NS_ASSUME_NONNULL_BEGIN
@interface ConversationViewLayout ()
@property (nonatomic) CGFloat lastViewWidth;
@property (nonatomic) CGSize contentSize;
@property (nonatomic, readonly) NSMutableDictionary<NSNumber *, UICollectionViewLayoutAttributes *> *itemAttributesMap;
// This dirty flag may be redundant with logic in UICollectionViewLayout,
// but it can't hurt and it ensures that we can safely & cheaply call
// prepareLayout from view logic to ensure that we always have a¸valid
// layout without incurring any of the (great) expense of performing an
// unnecessary layout pass.
@property (nonatomic) BOOL hasLayout;
@property (nonatomic) BOOL hasEverHadLayout;
@end
#pragma mark -
@implementation ConversationViewLayout
- (instancetype)initWithConversationStyle:(ConversationStyle *)conversationStyle
{
if (self = [super init]) {
_itemAttributesMap = [NSMutableDictionary new];
_conversationStyle = conversationStyle;
}
return self;
}
- (void)setHasLayout:(BOOL)hasLayout
{
_hasLayout = hasLayout;
if (hasLayout) {
self.hasEverHadLayout = YES;
}
}
- (void)invalidateLayout
{
[super invalidateLayout];
[self clearState];
}
- (void)invalidateLayoutWithContext:(UICollectionViewLayoutInvalidationContext *)context
{
[super invalidateLayoutWithContext:context];
[self clearState];
}
- (void)clearState
{
self.contentSize = CGSizeZero;
[self.itemAttributesMap removeAllObjects];
self.hasLayout = NO;
self.lastViewWidth = 0.f;
}
- (void)prepareLayout
{
[super prepareLayout];
id<ConversationViewLayoutDelegate> delegate = self.delegate;
if (!delegate) {
OWSFailDebug(@"Missing delegate");
[self clearState];
return;
}
if (self.collectionView.bounds.size.width <= 0.f || self.collectionView.bounds.size.height <= 0.f) {
OWSFailDebug(@"Collection view has invalid size: %@", NSStringFromCGRect(self.collectionView.bounds));
[self clearState];
return;
}
if (self.hasLayout) {
return;
}
self.hasLayout = YES;
[self prepareLayoutOfItems];
}
- (void)prepareLayoutOfItems
{
const CGFloat viewWidth = self.conversationStyle.viewWidth;
NSArray<id<ConversationViewLayoutItem>> *layoutItems = self.delegate.layoutItems;
CGFloat y = self.conversationStyle.contentMarginTop + self.delegate.layoutHeaderHeight;
CGFloat contentBottom = y;
NSInteger row = 0;
id<ConversationViewLayoutItem> _Nullable previousLayoutItem = nil;
for (id<ConversationViewLayoutItem> layoutItem in layoutItems) {
if (previousLayoutItem) {
y += [layoutItem vSpacingWithPreviousLayoutItem:previousLayoutItem];
}
CGSize layoutSize = CGSizeCeil([layoutItem cellSize]);
// Ensure cell fits within view.
OWSAssertDebug(layoutSize.width <= viewWidth);
layoutSize.width = MIN(viewWidth, layoutSize.width);
// All cells are "full width" and are responsible for aligning their own content.
CGRect itemFrame = CGRectMake(0, y, viewWidth, layoutSize.height);
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:0];
UICollectionViewLayoutAttributes *itemAttributes =
[UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
itemAttributes.frame = itemFrame;
self.itemAttributesMap[@(row)] = itemAttributes;
contentBottom = itemFrame.origin.y + itemFrame.size.height;
y = contentBottom;
row++;
previousLayoutItem = layoutItem;
}
contentBottom += self.conversationStyle.contentMarginBottom;
self.contentSize = CGSizeMake(viewWidth, contentBottom);
self.lastViewWidth = viewWidth;
}
- (nullable NSArray<__kindof UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect
{
NSMutableArray<UICollectionViewLayoutAttributes *> *result = [NSMutableArray new];
for (UICollectionViewLayoutAttributes *itemAttributes in self.itemAttributesMap.allValues) {
if (CGRectIntersectsRect(rect, itemAttributes.frame)) {
[result addObject:itemAttributes];
}
}
return result;
}
- (nullable UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath
{
return self.itemAttributesMap[@(indexPath.row)];
}
- (CGSize)collectionViewContentSize
{
return self.contentSize;
}
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds
{
return self.lastViewWidth != newBounds.size.width;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -87,8 +87,6 @@ typedef NS_ENUM(NSUInteger, ConversationUpdateItemType) {
// to prod the view to reset its scroll state, etc.
- (void)conversationViewModelDidReset;
- (ConversationStyle *)conversationStyle;
@end
#pragma mark -

View File

@ -5,7 +5,6 @@
#import "ConversationViewModel.h"
#import "ConversationViewItem.h"
#import "DateUtil.h"
#import "OWSMessageBubbleView.h"
#import "OWSQuotedReplyModel.h"
#import "Session-Swift.h"
#import <SignalCoreKit/NSDate+OWS.h>
@ -166,12 +165,12 @@ NS_ASSUME_NONNULL_BEGIN
// Always load up to n messages when user arrives.
//
// The smaller this number is, the faster the conversation can display.
// To test, shrink you accessability font as much as possible, then count how many 1-line system info messages (our
// To test, shrink you accessibility font as much as possible, then count how many 1-line system info messages (our
// shortest cells) can fit on screen at a time on an iPhoneX
//
// PERF: we could do less messages on shorter (older, slower) devices
// PERF: we could cache the cell height, since some messages will be much taller.
static const int kYapDatabasePageSize = 18;
static const int kYapDatabasePageSize = 100;
// Never show more than n messages in conversation view when user arrives.
static const int kConversationInitialMaxRangeSize = 300;
@ -622,13 +621,6 @@ static const int kYapDatabaseRangeMaxLength = 25000;
NSMutableSet<NSString *> *diffRemovedItemIds = [diff.removedItemIds mutableCopy];
NSMutableSet<NSString *> *diffUpdatedItemIds = [diff.updatedItemIds mutableCopy];
for (TSOutgoingMessage *unsavedOutgoingMessage in self.unsavedOutgoingMessages) {
// unsavedOutgoingMessages should only exist for a short period (usually 30-50ms) before
// they are saved and moved into the `persistedViewItems`
// Loki: Original code
// ========
// OWSAssertDebug(unsavedOutgoingMessage.timestamp >= ([NSDate ows_millisecondTimeStamp] - 1 * kSecondInMs));
// ========
BOOL isFound = ([diff.addedItemIds containsObject:unsavedOutgoingMessage.uniqueId] ||
[diff.removedItemIds containsObject:unsavedOutgoingMessage.uniqueId] ||
[diff.updatedItemIds containsObject:unsavedOutgoingMessage.uniqueId]);
@ -1049,7 +1041,6 @@ static const int kYapDatabaseRangeMaxLength = 25000;
NSArray<NSString *> *loadedUniqueIds = [self.messageMapping loadedUniqueIds];
BOOL isGroupThread = self.thread.isGroupThread;
ConversationStyle *conversationStyle = self.delegate.conversationStyle;
[self ensureConversationProfileState];
@ -1062,8 +1053,7 @@ static const int kYapDatabaseRangeMaxLength = 25000;
if (!viewItem) {
viewItem = [[ConversationInteractionViewItem alloc] initWithInteraction:interaction
isGroupThread:isGroupThread
transaction:transaction
conversationStyle:conversationStyle];
transaction:transaction];
}
OWSAssertDebug(!viewItemCache[interaction.uniqueId]);
viewItemCache[interaction.uniqueId] = viewItem;
@ -1083,13 +1073,11 @@ static const int kYapDatabaseRangeMaxLength = 25000;
[TSInteraction fetchObjectWithUniqueID:uniqueId transaction:transaction];
if (!interaction) {
OWSFailDebug(@"missing interaction in message mapping: %@.", uniqueId);
// TODO: Add analytics.
hasError = YES;
continue;
}
if (!interaction.uniqueId) {
OWSFailDebug(@"invalid interaction in message mapping: %@.", interaction);
// TODO: Add analytics.
hasError = YES;
continue;
}
@ -1226,7 +1214,7 @@ static const int kYapDatabaseRangeMaxLength = 25000;
id<ConversationViewItem> viewItem = viewItems[i];
id<ConversationViewItem> _Nullable previousViewItem = (i > 0 ? viewItems[i - 1] : nil);
id<ConversationViewItem> _Nullable nextViewItem = (i + 1 < viewItems.count ? viewItems[i + 1] : nil);
BOOL shouldShowSenderAvatar = NO;
BOOL shouldShowSenderProfilePicture = NO;
BOOL shouldHideFooter = NO;
BOOL isFirstInCluster = YES;
BOOL isLastInCluster = YES;
@ -1322,9 +1310,8 @@ static const int kYapDatabaseRangeMaxLength = 25000;
}
if (viewItem.isGroupThread) {
// Show the sender name for incoming group messages unless
// the previous message has the same sender name and
// no "date break" separates us.
// Show the sender name for incoming group messages unless the
// previous message has the same sender and no "date break" separates us.
BOOL shouldShowSenderName = YES;
NSString *_Nullable previousIncomingSenderId = nil;
if (previousViewItem && previousViewItem.interaction.interactionType == interactionType) {
@ -1333,37 +1320,18 @@ static const int kYapDatabaseRangeMaxLength = 25000;
previousIncomingSenderId = previousIncomingMessage.authorId;
OWSAssertDebug(previousIncomingSenderId.length > 0);
shouldShowSenderName
= (![NSObject isNullableObject:previousIncomingSenderId equalTo:incomingSenderId]
|| viewItem.hasCellHeader);
shouldShowSenderName = (![NSObject isNullableObject:previousIncomingSenderId equalTo:incomingSenderId] || viewItem.hasCellHeader);
}
if (shouldShowSenderName) {
senderName = [[NSAttributedString alloc] initWithString:[SSKEnvironment.shared.profileManager profileNameForRecipientWithID:incomingSenderId avoidingWriteTransaction:YES]];
if ([self.thread isKindOfClass:[TSGroupThread class]]) {
TSGroupThread *groupThread = (TSGroupThread *)self.thread;
NSData *groupId = groupThread.groupModel.groupId;
NSString *stringGroupId = [[NSString alloc] initWithData:groupId encoding:NSUTF8StringEncoding];
if (stringGroupId != nil) {
NSString __block *displayName;
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
displayName = [transaction objectForKey:incomingSenderId inCollection:stringGroupId];
}];
if (displayName != nil) {
senderName = [[NSAttributedString alloc] initWithString:displayName attributes:[OWSMessageBubbleView senderNamePrimaryAttributes]];
}
}
}
}
// Show the sender avatar for incoming group messages unless
// the next message has the same sender avatar and
// no "date break" separates us.
shouldShowSenderAvatar = YES;
if (previousViewItem && previousViewItem.interaction.interactionType == interactionType) {
shouldShowSenderAvatar = (![NSObject isNullableObject:previousIncomingSenderId equalTo:incomingSenderId]);
// Show the sender profile picture for incoming group messages unless the
// next message has the same sender and no "date break" separates us.
shouldShowSenderProfilePicture = YES;
if (nextViewItem && nextViewItem.interaction.interactionType == interactionType) {
shouldShowSenderProfilePicture = (![NSObject isNullableObject:nextIncomingSenderId equalTo:incomingSenderId]);
}
}
}
@ -1374,9 +1342,10 @@ static const int kYapDatabaseRangeMaxLength = 25000;
viewItem.isFirstInCluster = isFirstInCluster;
viewItem.isLastInCluster = isLastInCluster;
viewItem.shouldShowSenderAvatar = shouldShowSenderAvatar;
viewItem.shouldShowSenderProfilePicture = shouldShowSenderProfilePicture;
viewItem.shouldHideFooter = shouldHideFooter;
viewItem.senderName = senderName;
viewItem.wasPreviousItemInfoMessage = (previousViewItem.interaction.interactionType == OWSInteractionType_Info);
}
self.viewState = [[ConversationViewState alloc] initWithViewItems:viewItems];

View File

@ -0,0 +1,121 @@
final class ExpandingAttachmentsButton : UIView, InputViewButtonDelegate {
private let delegate: ExpandingAttachmentsButtonDelegate
private var isExpanded = false { didSet { expandOrCollapse() } }
// MARK: Constraints
private lazy var gifButtonContainerBottomConstraint = gifButtonContainer.pin(.bottom, to: .bottom, of: self)
private lazy var documentButtonContainerBottomConstraint = documentButtonContainer.pin(.bottom, to: .bottom, of: self)
private lazy var libraryButtonContainerBottomConstraint = libraryButtonContainer.pin(.bottom, to: .bottom, of: self)
private lazy var cameraButtonContainerBottomConstraint = cameraButtonContainer.pin(.bottom, to: .bottom, of: self)
// MARK: UI Components
lazy var gifButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_gif_black"), delegate: self, hasOpaqueBackground: true)
lazy var gifButtonContainer = container(for: gifButton)
lazy var documentButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_document_black"), delegate: self, hasOpaqueBackground: true)
lazy var documentButtonContainer = container(for: documentButton)
lazy var libraryButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_roll_black"), delegate: self, hasOpaqueBackground: true)
lazy var libraryButtonContainer = container(for: libraryButton)
lazy var cameraButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_black"), delegate: self, hasOpaqueBackground: true)
lazy var cameraButtonContainer = container(for: cameraButton)
lazy var mainButton = InputViewButton(icon: #imageLiteral(resourceName: "ic_plus_24"), delegate: self)
lazy var mainButtonContainer = container(for: mainButton)
// MARK: Lifecycle
init(delegate: ExpandingAttachmentsButtonDelegate) {
self.delegate = delegate
super.init(frame: CGRect.zero)
setUpViewHierarchy()
}
override init(frame: CGRect) {
preconditionFailure("Use init(delegate:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(delegate:) instead.")
}
private func setUpViewHierarchy() {
backgroundColor = .clear
// GIF button
addSubview(gifButtonContainer)
gifButtonContainer.alpha = 0
// Document button
addSubview(documentButtonContainer)
documentButtonContainer.alpha = 0
// Library button
addSubview(libraryButtonContainer)
libraryButtonContainer.alpha = 0
// Camera button
addSubview(cameraButtonContainer)
cameraButtonContainer.alpha = 0
// Main button
addSubview(mainButtonContainer)
// Constraints
mainButtonContainer.pin(to: self)
gifButtonContainer.center(.horizontal, in: self)
documentButtonContainer.center(.horizontal, in: self)
libraryButtonContainer.center(.horizontal, in: self)
cameraButtonContainer.center(.horizontal, in: self)
[ gifButtonContainerBottomConstraint, documentButtonContainerBottomConstraint, libraryButtonContainerBottomConstraint, cameraButtonContainerBottomConstraint ].forEach {
$0.isActive = true
}
}
// MARK: Animation
private func expandOrCollapse() {
if isExpanded {
let expandedButtonSize = InputViewButton.expandedSize
let spacing: CGFloat = 4
cameraButtonContainerBottomConstraint.constant = -1 * (expandedButtonSize + spacing)
libraryButtonContainerBottomConstraint.constant = -2 * (expandedButtonSize + spacing)
documentButtonContainerBottomConstraint.constant = -3 * (expandedButtonSize + spacing)
gifButtonContainerBottomConstraint.constant = -4 * (expandedButtonSize + spacing)
UIView.animate(withDuration: 0.25) {
[ self.gifButtonContainer, self.documentButtonContainer, self.libraryButtonContainer, self.cameraButtonContainer ].forEach {
$0.alpha = 1
}
self.layoutIfNeeded()
}
} else {
[ gifButtonContainerBottomConstraint, documentButtonContainerBottomConstraint, libraryButtonContainerBottomConstraint, cameraButtonContainerBottomConstraint ].forEach {
$0.constant = 0
}
UIView.animate(withDuration: 0.25) {
[ self.gifButtonContainer, self.documentButtonContainer, self.libraryButtonContainer, self.cameraButtonContainer ].forEach {
$0.alpha = 0
}
self.layoutIfNeeded()
}
}
}
// MARK: Interaction
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) {
if inputViewButton == gifButton { delegate.handleGIFButtonTapped(); isExpanded = false }
if inputViewButton == documentButton { delegate.handleDocumentButtonTapped(); isExpanded = false }
if inputViewButton == libraryButton { delegate.handleLibraryButtonTapped(); isExpanded = false }
if inputViewButton == cameraButton { delegate.handleCameraButtonTapped(); isExpanded = false }
if inputViewButton == mainButton { isExpanded = !isExpanded }
}
// MARK: Convenience
private func container(for button: InputViewButton) -> UIView {
let result = UIView()
result.addSubview(button)
result.set(.width, to: InputViewButton.expandedSize)
result.set(.height, to: InputViewButton.expandedSize)
button.center(in: result)
return result
}
}
// MARK: Delegate
protocol ExpandingAttachmentsButtonDelegate {
func handleGIFButtonTapped()
func handleDocumentButtonTapped()
func handleLibraryButtonTapped()
func handleCameraButtonTapped()
}

View File

@ -0,0 +1,80 @@
public final class InputTextView : UITextView, UITextViewDelegate {
private let snDelegate: InputTextViewDelegate
private lazy var heightConstraint = self.set(.height, to: minHeight)
public override var text: String! { didSet { handleTextChanged() } }
// MARK: UI Components
private lazy var placeholderLabel: UILabel = {
let result = UILabel()
result.text = "Message"
result.font = .systemFont(ofSize: Values.mediumFontSize)
result.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
return result
}()
// MARK: Settings
private let minHeight: CGFloat = 22
private let maxHeight: CGFloat = 80
// MARK: Lifecycle
init(delegate: InputTextViewDelegate) {
snDelegate = delegate
super.init(frame: CGRect.zero, textContainer: nil)
setUpViewHierarchy()
self.delegate = self
}
public override init(frame: CGRect, textContainer: NSTextContainer?) {
preconditionFailure("Use init(delegate:) instead.")
}
public required init?(coder: NSCoder) {
preconditionFailure("Use init(delegate:) instead.")
}
private func setUpViewHierarchy() {
showsHorizontalScrollIndicator = false
showsVerticalScrollIndicator = false
backgroundColor = .clear
textColor = Colors.text
font = .systemFont(ofSize: Values.mediumFontSize)
tintColor = Colors.accent
keyboardAppearance = isLightMode ? .light : .dark
heightConstraint.isActive = true
let horizontalInset: CGFloat = 2
textContainerInset = UIEdgeInsets(top: 0, left: horizontalInset, bottom: 0, right: horizontalInset)
addSubview(placeholderLabel)
placeholderLabel.pin(.leading, to: .leading, of: self, withInset: horizontalInset + 3) // Slight visual adjustment
placeholderLabel.pin(.top, to: .top, of: self)
pin(.trailing, to: .trailing, of: placeholderLabel, withInset: horizontalInset)
pin(.bottom, to: .bottom, of: placeholderLabel)
}
// MARK: Updating
public func textViewDidChange(_ textView: UITextView) {
handleTextChanged()
}
private func handleTextChanged() {
defer { snDelegate.inputTextViewDidChangeContent(self) }
placeholderLabel.isHidden = !text.isEmpty
let width = frame.width
let height = frame.height
let size = sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
// `textView.contentSize` isn't accurate when restoring a multiline draft, so we set it here manually
self.contentSize = size
let newHeight = size.height.clamp(minHeight, maxHeight)
guard newHeight != height else { return }
heightConstraint.constant = newHeight
snDelegate.inputTextViewDidChangeSize(self)
}
}
// MARK: Delegate
protocol InputTextViewDelegate {
func inputTextViewDidChangeSize(_ inputTextView: InputTextView)
func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
}

View File

@ -0,0 +1,337 @@
final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, QuoteViewDelegate, LinkPreviewViewDelegate, 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: LinkPreviewView = {
let maxWidth = self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset
return LinkPreviewView(for: nil, maxWidth: maxWidth, delegate: self)
}()
var text: String {
get { inputTextView.text }
set { inputTextView.text = newValue }
}
override var intrinsicContentSize: CGSize { CGSize.zero }
var lastSearchedText: String? { nil }
// MARK: UI Components
private lazy var attachmentsButton = ExpandingAttachmentsButton(delegate: delegate)
private lazy var voiceMessageButton = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self)
private lazy var sendButton: InputViewButton = {
let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self)
result.isHidden = true
return result
}()
private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton)
private lazy var mentionsView: MentionSelectionView = {
let result = MentionSelectionView()
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)
result.alpha = 0
return result
}()
private lazy var inputTextView = InputTextView(delegate: self)
private lazy var additionalContentContainer: UIView = {
let result = UIView()
result.heightAnchor.constraint(greaterThanOrEqualToConstant: 4).isActive = true
return result
}()
// MARK: Settings
private static let linkPreviewViewInset: CGFloat = 6
// MARK: Lifecycle
init(delegate: InputViewDelegate) {
self.delegate = delegate
super.init(frame: CGRect.zero)
setUpViewHierarchy()
}
override init(frame: CGRect) {
preconditionFailure("Use init(delegate:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(delegate:) instead.")
}
private func setUpViewHierarchy() {
autoresizingMask = .flexibleHeight
// Background & blur
let backgroundView = UIView()
backgroundView.backgroundColor = isLightMode ? .white : .black
backgroundView.alpha = Values.lowOpacity
addSubview(backgroundView)
backgroundView.pin(to: self)
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
addSubview(blurView)
blurView.pin(to: self)
// Separator
let separator = UIView()
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
separator.set(.height, to: 1 / UIScreen.main.scale)
addSubview(separator)
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
// Bottom stack view
let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ])
bottomStackView.axis = .horizontal
bottomStackView.spacing = Values.smallSpacing
bottomStackView.alignment = .center
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ])
mainStackView.axis = .vertical
mainStackView.isLayoutMarginsRelativeArrangement = true
let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2
mainStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.largeSpacing, bottom: Values.smallSpacing, trailing: Values.largeSpacing - adjustment)
addSubview(mainStackView)
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
insertSubview(mentionsViewContainer, belowSubview: mainStackView)
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
// Voice message button
addSubview(voiceMessageButtonContainer)
voiceMessageButtonContainer.center(in: sendButton)
}
// MARK: Updating
func inputTextViewDidChangeSize(_ inputTextView: InputTextView) {
invalidateIntrinsicContentSize()
}
func inputTextViewDidChangeContent(_ inputTextView: InputTextView) {
let hasText = !text.isEmpty
sendButton.isHidden = !hasText
voiceMessageButtonContainer.isHidden = hasText
autoGenerateLinkPreviewIfPossible()
delegate.inputTextViewDidChangeContent(inputTextView)
}
private func handleQuoteDraftChanged() {
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
linkPreviewInfo = nil
guard let quoteDraftInfo = quoteDraftInfo else { return }
let direction: QuoteView.Direction = quoteDraftInfo.isOutgoing ? .outgoing : .incoming
let hInset: CGFloat = 6
let maxWidth = additionalContentContainer.bounds.width
let quoteView = QuoteView(for: quoteDraftInfo.model, direction: direction, hInset: hInset, maxWidth: maxWidth, delegate: self)
additionalContentContainer.addSubview(quoteView)
quoteView.pin(.left, to: .left, of: additionalContentContainer, withInset: hInset)
quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12)
quoteView.pin(.right, to: .right, of: additionalContentContainer, withInset: -hInset)
quoteView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -6)
}
private func autoGenerateLinkPreviewIfPossible() {
// Suggest that the user enable link previews if they haven't already and we haven't
// told them about link previews yet
let text = inputTextView.text!
let userDefaults = UserDefaults.standard
if !OWSLinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && !SSKPreferences.areLinkPreviewsEnabled
&& !userDefaults[.hasSeenLinkPreviewSuggestion] {
delegate.showLinkPreviewSuggestionModal()
userDefaults[.hasSeenLinkPreviewSuggestion] = true
return
}
// Check that link previews are enabled
guard SSKPreferences.areLinkPreviewsEnabled else { return }
// Proceed
autoGenerateLinkPreview()
}
func autoGenerateLinkPreview() {
// Check that a valid URL is present
guard let linkPreviewURL = OWSLinkPreview.previewUrl(forRawBodyText: text, selectedRange: inputTextView.selectedRange) else {
return
}
// Guard against obsolete updates
guard linkPreviewURL != self.linkPreviewInfo?.url else { return }
// Clear content container
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
quoteDraftInfo = nil
// Set the state to loading
linkPreviewInfo = (url: linkPreviewURL, draft: nil)
linkPreviewView.linkPreviewState = LinkPreviewLoading()
// Add the link preview view
additionalContentContainer.addSubview(linkPreviewView)
linkPreviewView.pin(.left, to: .left, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset)
linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10)
linkPreviewView.pin(.right, to: .right, of: additionalContentContainer)
linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4)
// Build the link preview
OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL).done { [weak self] draft in
guard let self = self else { return }
guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
self.linkPreviewView.linkPreviewState = LinkPreviewDraft(linkPreviewDraft: draft)
}.catch { _ in
guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self.linkPreviewInfo = nil
self.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
}.retainUntilComplete()
}
// MARK: Interaction
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton,
attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ]
let buttonContainer = buttonContainers.first { $0.superview!.convert($0.frame, to: self).contains(point) }
if let buttonContainer = buttonContainer {
return buttonContainer
} else {
return super.hitTest(point, with: event)
}
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let buttonContainers = [ attachmentsButton.gifButtonContainer, attachmentsButton.documentButtonContainer,
attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ]
let isPointInsideAttachmentsButton = buttonContainers.contains { $0.superview!.convert($0.frame, to: self).contains(point) }
if isPointInsideAttachmentsButton {
return true
} else if mentionsViewContainer.frame.contains(point) {
return true
} else {
return super.point(inside: point, with: event)
}
}
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) {
if inputViewButton == sendButton { delegate.handleSendButtonTapped() }
}
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) {
guard inputViewButton == voiceMessageButton else { return }
delegate.startVoiceMessageRecording()
showVoiceMessageUI()
}
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) {
guard let voiceMessageRecordingView = voiceMessageRecordingView, inputViewButton == voiceMessageButton else { return }
let location = touch.location(in: voiceMessageRecordingView)
voiceMessageRecordingView.handleLongPressMoved(to: location)
}
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) {
guard let voiceMessageRecordingView = voiceMessageRecordingView, inputViewButton == voiceMessageButton else { return }
let location = touch.location(in: voiceMessageRecordingView)
voiceMessageRecordingView.handleLongPressEnded(at: location)
}
func handleQuoteViewCancelButtonTapped() {
delegate.handleQuoteViewCancelButtonTapped()
}
override func resignFirstResponder() -> Bool {
inputTextView.resignFirstResponder()
}
func handleLongPress() {
// Not relevant in this case
}
func handleLinkPreviewCanceled() {
linkPreviewInfo = nil
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
}
@objc private func showVoiceMessageUI() {
voiceMessageRecordingView?.removeFromSuperview()
let voiceMessageButtonFrame = voiceMessageButton.superview!.convert(voiceMessageButton.frame, to: self)
let voiceMessageRecordingView = VoiceMessageRecordingView(voiceMessageButtonFrame: voiceMessageButtonFrame, delegate: delegate)
voiceMessageRecordingView.alpha = 0
addSubview(voiceMessageRecordingView)
voiceMessageRecordingView.pin(to: self)
self.voiceMessageRecordingView = voiceMessageRecordingView
voiceMessageRecordingView.animate()
let allOtherViews = [ attachmentsButton, sendButton, inputTextView, additionalContentContainer ]
UIView.animate(withDuration: 0.25) {
allOtherViews.forEach { $0.alpha = 0 }
}
}
func hideVoiceMessageUI() {
let allOtherViews = [ attachmentsButton, sendButton, inputTextView, additionalContentContainer ]
UIView.animate(withDuration: 0.25, animations: {
allOtherViews.forEach { $0.alpha = 1 }
self.voiceMessageRecordingView?.alpha = 0
}, completion: { _ in
self.voiceMessageRecordingView?.removeFromSuperview()
self.voiceMessageRecordingView = nil
})
}
func hideMentionsUI() {
UIView.animate(withDuration: 0.25, animations: {
self.mentionsViewContainer.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.openGroupServer = openGroup.server
mentionsView.openGroupChannel = openGroup.channel
}
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.mentionsViewContainer.alpha = 1
}
}
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) {
delegate.handleMentionSelected(mention, from: view)
}
// MARK: Convenience
private func container(for button: InputViewButton) -> UIView {
let result = UIView()
result.addSubview(button)
result.set(.width, to: InputViewButton.expandedSize)
result.set(.height, to: InputViewButton.expandedSize)
button.center(in: result)
return result
}
}
// MARK: Delegate
protocol InputViewDelegate : ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate {
func showLinkPreviewSuggestionModal()
func handleSendButtonTapped()
func handleQuoteViewCancelButtonTapped()
func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView)
}

View File

@ -0,0 +1,145 @@
final class InputViewButton : UIView {
private let icon: UIImage
private let isSendButton: Bool
private let delegate: InputViewButtonDelegate
private let hasOpaqueBackground: Bool
private lazy var widthConstraint = set(.width, to: InputViewButton.size)
private lazy var heightConstraint = set(.height, to: InputViewButton.size)
private var longPressTimer: Timer?
private var isLongPress = false
// MARK: UI Components
private lazy var backgroundView = UIView()
// MARK: Settings
static let size = CGFloat(40)
static let expandedSize = CGFloat(48)
static let iconSize: CGFloat = 20
// MARK: Lifecycle
init(icon: UIImage, isSendButton: Bool = false, delegate: InputViewButtonDelegate, hasOpaqueBackground: Bool = false) {
self.icon = icon
self.isSendButton = isSendButton
self.delegate = delegate
self.hasOpaqueBackground = hasOpaqueBackground
super.init(frame: CGRect.zero)
setUpViewHierarchy()
}
override init(frame: CGRect) {
preconditionFailure("Use init(icon:delegate:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(icon:delegate:) instead.")
}
private func setUpViewHierarchy() {
backgroundColor = .clear
if hasOpaqueBackground {
let backgroundView = UIView()
backgroundView.backgroundColor = isLightMode ? .white : .black
backgroundView.alpha = Values.lowOpacity
addSubview(backgroundView)
backgroundView.pin(to: self)
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
addSubview(blurView)
blurView.pin(to: self)
}
backgroundView.backgroundColor = isSendButton ? Colors.accent : Colors.text.withAlphaComponent(0.05)
addSubview(backgroundView)
backgroundView.pin(to: self)
layer.cornerRadius = InputViewButton.size / 2
layer.masksToBounds = true
isUserInteractionEnabled = true
widthConstraint.isActive = true
heightConstraint.isActive = true
let tint = isSendButton ? UIColor.black : Colors.text
let iconImageView = UIImageView(image: icon.withTint(tint))
iconImageView.contentMode = .scaleAspectFit
let iconSize = InputViewButton.iconSize
iconImageView.set(.width, to: iconSize)
iconImageView.set(.height, to: iconSize)
addSubview(iconImageView)
iconImageView.center(in: self)
}
// MARK: Animation
private func animate(to size: CGFloat, glowColor: UIColor, backgroundColor: UIColor) {
let frame = CGRect(center: center, size: CGSize(width: size, height: size))
widthConstraint.constant = size
heightConstraint.constant = size
UIView.animate(withDuration: 0.25) {
self.layoutIfNeeded()
self.frame = frame
self.layer.cornerRadius = size / 2
let glowConfiguration = UIView.CircularGlowConfiguration(size: size, color: glowColor, isAnimated: true, radius: isLightMode ? 4 : 6)
self.setCircularGlow(with: glowConfiguration)
self.backgroundView.backgroundColor = backgroundColor
}
}
private func expand() {
animate(to: InputViewButton.expandedSize, glowColor: Colors.expandedButtonGlowColor, backgroundColor: Colors.accent)
}
private func collapse() {
let backgroundColor = isSendButton ? Colors.accent : Colors.text.withAlphaComponent(0.05)
animate(to: InputViewButton.size, glowColor: .clear, backgroundColor: backgroundColor)
}
// MARK: Interaction
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
expand()
invalidateLongPressIfNeeded()
longPressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { [weak self] _ in
guard let self = self else { return }
self.isLongPress = true
self.delegate.handleInputViewButtonLongPressBegan(self)
})
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
if isLongPress {
delegate.handleInputViewButtonLongPressMoved(self, with: touches.first!)
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
collapse()
if !isLongPress {
delegate.handleInputViewButtonTapped(self)
} else {
delegate.handleInputViewButtonLongPressEnded(self, with: touches.first!)
}
invalidateLongPressIfNeeded()
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
collapse()
invalidateLongPressIfNeeded()
}
private func invalidateLongPressIfNeeded() {
longPressTimer?.invalidate()
isLongPress = false
}
}
// MARK: Delegate
protocol InputViewButtonDelegate {
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton)
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton)
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch)
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch)
}
extension InputViewButtonDelegate {
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) { }
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) { }
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) { }
}

View File

@ -1,19 +1,17 @@
@objc(LKMentionCandidateSelectionView)
final class MentionCandidateSelectionView : UIView, UITableViewDataSource, UITableViewDelegate {
@objc var mentionCandidates: [Mention] = [] { didSet { tableView.reloadData() } }
@objc var publicChatServer: String?
var publicChatChannel: UInt64?
@objc var delegate: MentionCandidateSelectionViewDelegate?
// MARK: Convenience
@objc(setPublicChatChannel:)
func setPublicChatChannel(to publicChatChannel: UInt64) {
self.publicChatChannel = publicChatChannel != 0 ? publicChatChannel : nil
final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDelegate {
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
@ -23,21 +21,23 @@ final class MentionCandidateSelectionView : UIView, UITableViewDataSource, UITab
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() {
// Table view
addSubview(tableView)
tableView.pin(to: self)
// Top separator
let topSeparator = UIView()
topSeparator.backgroundColor = Colors.separator
topSeparator.set(.height, to: Values.separatorThickness)
@ -45,6 +45,7 @@ final class MentionCandidateSelectionView : UIView, UITableViewDataSource, UITab
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)
@ -53,46 +54,43 @@ final class MentionCandidateSelectionView : UIView, UITableViewDataSource, UITab
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
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]
delegate?.handleMentionCandidateSelected(mentionCandidate, from: self)
let mentionCandidate = candidates[indexPath.row]
delegate?.handleMentionSelected(mentionCandidate, from: self)
}
}
// MARK: - Cell
private extension MentionCandidateSelectionView {
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()
private lazy var moderatorIconImageView: UIImageView = {
let result = UIImageView(image: #imageLiteral(resourceName: "Crown"))
return result
}()
private lazy var moderatorIconImageView = UIImageView(image: #imageLiteral(resourceName: "Crown"))
private lazy var displayNameLabel: UILabel = {
let result = UILabel()
result.textColor = Colors.text
@ -100,68 +98,68 @@ private extension MentionCandidateSelectionView {
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
// 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)
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 {
if let server = openGroupServer, let channel = openGroupChannel {
let isUserModerator = OpenGroupAPI.isUserModerator(mentionCandidate.publicKey, for: channel, on: server)
moderatorIconImageView.isHidden = !isUserModerator
} else {
@ -173,8 +171,7 @@ private extension MentionCandidateSelectionView {
// MARK: - Delegate
@objc(LKMentionCandidateSelectionViewDelegate)
protocol MentionCandidateSelectionViewDelegate {
protocol MentionSelectionViewDelegate {
func handleMentionCandidateSelected(_ mentionCandidate: Mention, from mentionCandidateSelectionView: MentionCandidateSelectionView)
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView)
}

View File

@ -0,0 +1,405 @@
final class VoiceMessageRecordingView : UIView {
private let voiceMessageButtonFrame: CGRect
private let delegate: VoiceMessageRecordingViewDelegate
private lazy var slideToCancelStackViewRightConstraint = slideToCancelStackView.pin(.right, to: .right, of: self)
private lazy var slideToCancelLabelCenterHorizontalConstraint = slideToCancelLabel.center(.horizontal, in: self)
private lazy var pulseViewWidthConstraint = pulseView.set(.width, to: VoiceMessageRecordingView.circleSize)
private lazy var pulseViewHeightConstraint = pulseView.set(.height, to: VoiceMessageRecordingView.circleSize)
private lazy var lockViewBottomConstraint = lockView.pin(.bottom, to: .top, of: self, withInset: Values.mediumSpacing)
private let recordingStartDate = Date()
private var recordingTimer: Timer?
// MARK: UI Components
private lazy var iconImageView: UIImageView = {
let result = UIImageView()
result.image = UIImage(named: "Microphone")!.withTint(.white)
result.contentMode = .scaleAspectFit
let size = VoiceMessageRecordingView.iconSize
result.set(.width, to: size)
result.set(.height, to: size)
return result
}()
private lazy var circleView: UIView = {
let result = UIView()
result.backgroundColor = Colors.destructive
let size = VoiceMessageRecordingView.circleSize
result.set(.width, to: size)
result.set(.height, to: size)
result.layer.cornerRadius = size / 2
result.layer.masksToBounds = true
return result
}()
private lazy var pulseView: UIView = {
let result = UIView()
result.backgroundColor = Colors.destructive
result.layer.cornerRadius = VoiceMessageRecordingView.circleSize / 2
result.layer.masksToBounds = true
result.alpha = 0.5
return result
}()
private lazy var slideToCancelStackView: UIStackView = {
let result = UIStackView()
result.axis = .horizontal
result.spacing = Values.smallSpacing
result.alignment = .center
return result
}()
private lazy var chevronImageView: UIImageView = {
let chevronSize = VoiceMessageRecordingView.chevronSize
let chevronColor = (isLightMode ? UIColor.black : UIColor.white).withAlphaComponent(Values.mediumOpacity)
let result = UIImageView(image: UIImage(named: "small_chevron_left")!.withTint(chevronColor))
result.contentMode = .scaleAspectFit
result.set(.width, to: chevronSize)
result.set(.height, to: chevronSize)
return result
}()
private lazy var slideToCancelLabel: UILabel = {
let result = UILabel()
result.text = "Slide to cancel"
result.font = .systemFont(ofSize: Values.smallFontSize)
result.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
return result
}()
private lazy var cancelButton: UIButton = {
let result = UIButton()
result.setTitle("Cancel", for: UIControl.State.normal)
result.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize)
result.setTitleColor(Colors.text, for: UIControl.State.normal)
result.addTarget(self, action: #selector(handleCancelButtonTapped), for: UIControl.Event.touchUpInside)
result.alpha = 0
return result
}()
private lazy var durationStackView: UIStackView = {
let result = UIStackView()
result.axis = .horizontal
result.spacing = Values.smallSpacing
result.alignment = .center
return result
}()
private lazy var dotView: UIView = {
let result = UIView()
result.backgroundColor = Colors.destructive
let dotSize = VoiceMessageRecordingView.dotSize
result.set(.width, to: dotSize)
result.set(.height, to: dotSize)
result.layer.cornerRadius = dotSize / 2
result.layer.masksToBounds = true
return result
}()
private lazy var durationLabel: UILabel = {
let result = UILabel()
result.textColor = Colors.text
result.font = .systemFont(ofSize: Values.smallFontSize)
result.text = "0:00"
return result
}()
private lazy var lockView = LockView()
// MARK: Settings
private static let circleSize: CGFloat = 96
private static let pulseSize: CGFloat = 24
private static let iconSize: CGFloat = 28
private static let chevronSize: CGFloat = 16
private static let dotSize: CGFloat = 16
private static let lockViewHitMargin: CGFloat = 40
// MARK: Lifecycle
init(voiceMessageButtonFrame: CGRect, delegate: VoiceMessageRecordingViewDelegate) {
self.voiceMessageButtonFrame = voiceMessageButtonFrame
self.delegate = delegate
super.init(frame: CGRect.zero)
setUpViewHierarchy()
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
self?.updateDurationLabel()
}
}
override init(frame: CGRect) {
preconditionFailure("Use init(voiceMessageButtonFrame:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(voiceMessageButtonFrame:) instead.")
}
deinit {
recordingTimer?.invalidate()
}
private func setUpViewHierarchy() {
// Icon
let iconSize = VoiceMessageRecordingView.iconSize
addSubview(iconImageView)
let voiceMessageButtonCenter = voiceMessageButtonFrame.center
iconImageView.pin(.left, to: .left, of: self, withInset: voiceMessageButtonCenter.x - iconSize / 2)
iconImageView.pin(.top, to: .top, of: self, withInset: voiceMessageButtonCenter.y - iconSize / 2)
// Circle
insertSubview(circleView, at: 0)
circleView.center(in: iconImageView)
// Pulse
insertSubview(pulseView, at: 0)
pulseView.center(in: circleView)
// Slide to cancel stack view
slideToCancelStackView.addArrangedSubview(chevronImageView)
slideToCancelStackView.addArrangedSubview(slideToCancelLabel)
addSubview(slideToCancelStackView)
slideToCancelStackViewRightConstraint.isActive = true
slideToCancelStackView.center(.vertical, in: iconImageView)
// Cancel button
addSubview(cancelButton)
cancelButton.center(.horizontal, in: self)
cancelButton.center(.vertical, in: iconImageView)
// Duration stack view
durationStackView.addArrangedSubview(dotView)
durationStackView.addArrangedSubview(durationLabel)
addSubview(durationStackView)
durationStackView.pin(.left, to: .left, of: self, withInset: Values.largeSpacing)
durationStackView.center(.vertical, in: iconImageView)
// Lock view
addSubview(lockView)
lockView.centerXAnchor.constraint(equalTo: iconImageView.centerXAnchor, constant: 2).isActive = true
lockViewBottomConstraint.isActive = true
}
// MARK: Updating
@objc private func updateDurationLabel() {
let interval = Date().timeIntervalSince(recordingStartDate)
durationLabel.text = OWSFormat.formatDurationSeconds(Int(interval))
}
// MARK: Animation
func animate() {
layoutIfNeeded()
slideToCancelStackViewRightConstraint.isActive = false
slideToCancelLabelCenterHorizontalConstraint.isActive = true
lockViewBottomConstraint.constant = -Values.mediumSpacing
UIView.animate(withDuration: 0.25, animations: { [weak self] in
guard let self = self else { return }
self.alpha = 1
self.layoutIfNeeded()
}, completion: { [weak self] _ in
guard let self = self else { return }
self.fadeOutDotView()
self.pulse()
})
}
private func fadeOutDotView() {
UIView.animate(withDuration: 0.5, animations: { [weak self] in
self?.dotView.alpha = 0
}, completion: { [weak self] _ in
self?.fadeInDotView()
})
}
private func fadeInDotView() {
UIView.animate(withDuration: 0.5, animations: { [weak self] in
self?.dotView.alpha = 1
}, completion: { [weak self] _ in
self?.fadeOutDotView()
})
}
private func pulse() {
let collapsedSize = VoiceMessageRecordingView.circleSize
let collapsedFrame = CGRect(center: pulseView.center, size: CGSize(width: collapsedSize, height: collapsedSize))
let expandedSize = VoiceMessageRecordingView.circleSize + VoiceMessageRecordingView.pulseSize
let expandedFrame = CGRect(center: pulseView.center, size: CGSize(width: expandedSize, height: expandedSize))
pulseViewWidthConstraint.constant = expandedSize
pulseViewHeightConstraint.constant = expandedSize
UIView.animate(withDuration: 1, animations: { [weak self] in
guard let self = self else { return }
self.layoutIfNeeded()
self.pulseView.frame = expandedFrame
self.pulseView.layer.cornerRadius = expandedSize / 2
self.pulseView.alpha = 0
}, completion: { [weak self] _ in
guard let self = self else { return }
self.pulseViewWidthConstraint.constant = collapsedSize
self.pulseViewHeightConstraint.constant = collapsedSize
self.pulseView.frame = collapsedFrame
self.pulseView.layer.cornerRadius = collapsedSize / 2
self.pulseView.alpha = 0.5
self.pulse()
})
}
// MARK: Interaction
func handleLongPressMoved(to location: CGPoint) {
if location.x < bounds.center.x {
let translationX = location.x - bounds.center.x
let sign: CGFloat = -1
let chevronDamping: CGFloat = 4
let labelDamping: CGFloat = 3
let chevronX = (chevronDamping * (sqrt(abs(translationX)) / sqrt(chevronDamping))) * sign
let labelX = (labelDamping * (sqrt(abs(translationX)) / sqrt(labelDamping))) * sign
chevronImageView.transform = CGAffineTransform(translationX: chevronX, y: 0)
slideToCancelLabel.transform = CGAffineTransform(translationX: labelX, y: 0)
} else {
chevronImageView.transform = .identity
slideToCancelLabel.transform = .identity
}
if isValidLockViewLocation(location) {
if !lockView.isExpanded {
UIView.animate(withDuration: 0.25) {
self.lockViewBottomConstraint.constant = -Values.mediumSpacing + LockView.expansionMargin
}
}
lockView.expandIfNeeded()
} else {
if lockView.isExpanded {
UIView.animate(withDuration: 0.25) {
self.lockViewBottomConstraint.constant = -Values.mediumSpacing
}
}
lockView.collapseIfNeeded()
}
}
func handleLongPressEnded(at location: CGPoint) {
if pulseView.frame.contains(location) {
delegate.endVoiceMessageRecording()
} else if isValidLockViewLocation(location) {
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleCircleViewTap))
circleView.addGestureRecognizer(tapGestureRecognizer)
UIView.animate(withDuration: 0.25, delay: 0, options: .transitionCrossDissolve, animations: {
self.lockView.alpha = 0
self.iconImageView.image = UIImage(named: "ArrowUp")!.withTint(.white)
self.slideToCancelStackView.alpha = 0
self.cancelButton.alpha = 1
}, completion: { _ in
// Do nothing
})
} else {
delegate.cancelVoiceMessageRecording()
}
}
@objc private func handleCircleViewTap() {
delegate.endVoiceMessageRecording()
}
@objc private func handleCancelButtonTapped() {
delegate.cancelVoiceMessageRecording()
}
// MARK: Convenience
private func isValidLockViewLocation(_ location: CGPoint) -> Bool {
let lockViewHitMargin = VoiceMessageRecordingView.lockViewHitMargin
return location.y < 0 && location.x > (lockView.frame.minX - lockViewHitMargin) && location.x < (lockView.frame.maxX + lockViewHitMargin)
}
}
// MARK: Lock View
extension VoiceMessageRecordingView {
fileprivate final class LockView : UIView {
private lazy var widthConstraint = set(.width, to: LockView.width)
private(set) var isExpanded = false
private lazy var stackView: UIStackView = {
let result = UIStackView()
result.axis = .vertical
result.spacing = Values.smallSpacing
result.alignment = .center
result.isLayoutMarginsRelativeArrangement = true
result.layoutMargins = UIEdgeInsets(top: 12, leading: 0, bottom: 8, trailing: 0)
return result
}()
private static let width: CGFloat = 44
static let expansionMargin: CGFloat = 3
private static let lockIconSize: CGFloat = 20
private static let chevronIconSize: CGFloat = 20
override init(frame: CGRect) {
super.init(frame: frame)
setUpViewHierarchy()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setUpViewHierarchy()
}
private func setUpViewHierarchy() {
let iconTint: UIColor = isLightMode ? .black : .white
// Background & blur
let backgroundView = UIView()
backgroundView.backgroundColor = isLightMode ? .white : .black
backgroundView.alpha = Values.lowOpacity
addSubview(backgroundView)
backgroundView.pin(to: self)
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
addSubview(blurView)
blurView.pin(to: self)
// Size & shape
widthConstraint.isActive = true
layer.cornerRadius = LockView.width / 2
layer.masksToBounds = true
// Border
layer.borderWidth = 1
let borderColor = (isLightMode ? UIColor.black : UIColor.white).withAlphaComponent(Values.veryLowOpacity)
layer.borderColor = borderColor.cgColor
// Lock icon
let lockIconImageView = UIImageView(image: UIImage(named: "ic_lock_outline")!.withTint(iconTint))
let lockIconSize = LockView.lockIconSize
lockIconImageView.set(.width, to: lockIconSize)
lockIconImageView.set(.height, to: lockIconSize)
stackView.addArrangedSubview(lockIconImageView)
// Chevron icon
let chevronIconImageView = UIImageView(image: UIImage(named: "ic_chevron_up")!.withTint(iconTint))
let chevronIconSize = LockView.chevronIconSize
chevronIconImageView.set(.width, to: chevronIconSize)
chevronIconImageView.set(.height, to: chevronIconSize)
stackView.addArrangedSubview(chevronIconImageView)
// Stack view
addSubview(stackView)
stackView.pin(to: self)
}
func expandIfNeeded() {
guard !isExpanded else { return }
isExpanded = true
let expansionMargin = LockView.expansionMargin
let newWidth = LockView.width + 2 * expansionMargin
widthConstraint.constant = newWidth
UIView.animate(withDuration: 0.25) {
self.layer.cornerRadius = newWidth / 2
self.stackView.layoutMargins = UIEdgeInsets(top: 12 + expansionMargin, leading: 0, bottom: 8 + expansionMargin, trailing: 0)
self.layoutIfNeeded()
}
}
func collapseIfNeeded() {
guard isExpanded else { return }
isExpanded = false
let newWidth = LockView.width
widthConstraint.constant = newWidth
UIView.animate(withDuration: 0.25) {
self.layer.cornerRadius = newWidth / 2
self.stackView.layoutMargins = UIEdgeInsets(top: 12, leading: 0, bottom: 8, trailing: 0)
self.layoutIfNeeded()
}
}
}
}
// MARK: Delegate
protocol VoiceMessageRecordingViewDelegate {
func startVoiceMessageRecording()
func endVoiceMessageRecording()
func cancelVoiceMessageRecording()
}

View File

@ -4,7 +4,6 @@
import Foundation
import SignalUtilitiesKit
import SignalUtilitiesKit
@objc
public protocol LongTextViewDelegate {
@ -118,7 +117,7 @@ public class LongTextViewController: OWSViewController {
let messageTextView = OWSTextView()
self.messageTextView = messageTextView
messageTextView.font = .systemFont(ofSize: Values.mediumFontSize)
messageTextView.font = .systemFont(ofSize: Values.smallFontSize)
messageTextView.backgroundColor = .clear
messageTextView.isOpaque = true
messageTextView.isEditable = false

View File

@ -1,467 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
@objc
public class MenuAction: NSObject {
let block: (MenuAction) -> Void
let image: UIImage
let title: String
let subtitle: String?
public init(image: UIImage, title: String, subtitle: String?, block: @escaping (MenuAction) -> Void) {
self.image = image
self.title = title
self.subtitle = subtitle
self.block = block
}
}
@objc
protocol MenuActionsViewControllerDelegate: class {
func menuActionsWillPresent(_ menuActionsViewController: MenuActionsViewController)
func menuActionsIsPresenting(_ menuActionsViewController: MenuActionsViewController)
func menuActionsDidPresent(_ menuActionsViewController: MenuActionsViewController)
func menuActionsIsDismissing(_ menuActionsViewController: MenuActionsViewController)
func menuActionsDidDismiss(_ menuActionsViewController: MenuActionsViewController)
}
@objc
class MenuActionsViewController: UIViewController, MenuActionSheetDelegate {
@objc
weak var delegate: MenuActionsViewControllerDelegate?
@objc
public let focusedInteraction: TSInteraction
private let focusedView: UIView
private let actionSheetView: MenuActionSheetView
deinit {
Logger.verbose("")
}
@objc
required init(focusedInteraction: TSInteraction, focusedView: UIView, actions: [MenuAction]) {
self.focusedView = focusedView
self.focusedInteraction = focusedInteraction
self.actionSheetView = MenuActionSheetView(actions: actions)
super.init(nibName: nil, bundle: nil)
actionSheetView.delegate = self
}
required init?(coder aDecoder: NSCoder) {
notImplemented()
}
// MARK: View LifeCycle
var actionSheetViewVerticalConstraint: NSLayoutConstraint?
override func loadView() {
self.view = UIView()
view.addSubview(actionSheetView)
actionSheetView.autoPinWidthToSuperview()
actionSheetView.setContentHuggingVerticalHigh()
actionSheetView.setCompressionResistanceHigh()
self.actionSheetViewVerticalConstraint = actionSheetView.autoPinEdge(.top, to: .bottom, of: self.view)
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapBackground))
self.view.addGestureRecognizer(tapGesture)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(true)
self.animatePresentation()
}
override func viewDidDisappear(_ animated: Bool) {
Logger.debug("")
super.viewDidDisappear(animated)
// When the user has manually dismissed the menu, we do a nice animation
// but if the view otherwise disappears (e.g. due to resigning active),
// we still want to give the delegate the information it needs to restore it's UI.
delegate?.menuActionsDidDismiss(self)
}
// MARK: Orientation
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
return DefaultUIInterfaceOrientationMask()
}
// MARK: Present / Dismiss animations
var snapshotView: UIView?
private func addSnapshotFocusedView() -> UIView? {
guard let snapshotView = self.focusedView.snapshotView(afterScreenUpdates: false) else {
owsFailDebug("snapshotView was unexpectedly nil")
return nil
}
view.addSubview(snapshotView)
guard let focusedViewSuperview = focusedView.superview else {
owsFailDebug("focusedViewSuperview was unexpectedly nil")
return nil
}
let convertedFrame = view.convert(focusedView.frame, from: focusedViewSuperview)
snapshotView.frame = convertedFrame
return snapshotView
}
private func animatePresentation() {
guard let actionSheetViewVerticalConstraint = self.actionSheetViewVerticalConstraint else {
owsFailDebug("actionSheetViewVerticalConstraint was unexpectedly nil")
return
}
guard let focusedViewSuperview = focusedView.superview else {
owsFailDebug("focusedViewSuperview was unexpectedly nil")
return
}
// darken background
guard let snapshotView = addSnapshotFocusedView() else {
owsFailDebug("snapshotView was unexpectedly nil")
return
}
self.snapshotView = snapshotView
snapshotView.superview?.layoutIfNeeded()
let backgroundDuration: TimeInterval = 0.1
UIView.animate(withDuration: backgroundDuration) {
let alpha: CGFloat = isDarkMode ? 0.7 : 0.4
self.view.backgroundColor = UIColor.black.withAlphaComponent(alpha)
}
self.actionSheetView.superview?.layoutIfNeeded()
let oldFocusFrame = self.view.convert(focusedView.frame, from: focusedViewSuperview)
NSLayoutConstraint.deactivate([actionSheetViewVerticalConstraint])
self.actionSheetViewVerticalConstraint = self.actionSheetView.autoPinEdge(toSuperviewEdge: .bottom)
self.delegate?.menuActionsWillPresent(self)
UIView.animate(withDuration: 0.2,
delay: backgroundDuration,
options: .curveEaseOut,
animations: {
self.actionSheetView.superview?.layoutIfNeeded()
let newSheetFrame = self.actionSheetView.frame
var newFocusFrame = oldFocusFrame
// Position focused item just over the action sheet.
let overlap: CGFloat = (oldFocusFrame.maxY + self.vSpacing) - newSheetFrame.minY
newFocusFrame.origin.y = oldFocusFrame.origin.y - overlap
snapshotView.frame = newFocusFrame
self.delegate?.menuActionsIsPresenting(self)
},
completion: { (_) in
self.delegate?.menuActionsDidPresent(self)
})
}
@objc
public let vSpacing: CGFloat = 10
@objc
public var focusUI: UIView {
return actionSheetView
}
private func animateDismiss(action: MenuAction?) {
guard let actionSheetViewVerticalConstraint = self.actionSheetViewVerticalConstraint else {
owsFailDebug("actionSheetVerticalConstraint was unexpectedly nil")
delegate?.menuActionsDidDismiss(self)
return
}
guard let snapshotView = self.snapshotView else {
owsFailDebug("snapshotView was unexpectedly nil")
delegate?.menuActionsDidDismiss(self)
return
}
self.actionSheetView.superview?.layoutIfNeeded()
NSLayoutConstraint.deactivate([actionSheetViewVerticalConstraint])
let dismissDuration: TimeInterval = 0.2
self.actionSheetViewVerticalConstraint = self.actionSheetView.autoPinEdge(.top, to: .bottom, of: self.view)
UIView.animate(withDuration: dismissDuration,
delay: 0,
options: .curveEaseOut,
animations: {
self.view.backgroundColor = UIColor.clear
self.actionSheetView.superview?.layoutIfNeeded()
// this helps when focused view is above navbars, etc.
snapshotView.alpha = 0
self.delegate?.menuActionsIsDismissing(self)
},
completion: { _ in
self.view.isHidden = true
self.delegate?.menuActionsDidDismiss(self)
if let action = action {
action.block(action)
}
})
}
// MARK: Actions
@objc
func didTapBackground() {
animateDismiss(action: nil)
}
// MARK: MenuActionSheetDelegate
func actionSheet(_ actionSheet: MenuActionSheetView, didSelectAction action: MenuAction) {
animateDismiss(action: action)
}
}
protocol MenuActionSheetDelegate: class {
func actionSheet(_ actionSheet: MenuActionSheetView, didSelectAction action: MenuAction)
}
class MenuActionSheetView: UIView, MenuActionViewDelegate {
private let actionStackView: UIStackView
private var actions: [MenuAction]
private var actionViews: [MenuActionView]
private var hapticFeedback: SelectionHapticFeedback
private var hasEverHighlightedAction = false
weak var delegate: MenuActionSheetDelegate?
override var bounds: CGRect {
didSet {
updateMask()
}
}
convenience init(actions: [MenuAction]) {
self.init(frame: CGRect.zero)
actions.forEach { self.addAction($0) }
}
override init(frame: CGRect) {
actionStackView = UIStackView()
actionStackView.axis = .vertical
actionStackView.spacing = CGHairlineWidth()
actions = []
actionViews = []
hapticFeedback = SelectionHapticFeedback()
super.init(frame: frame)
backgroundColor = (isDarkMode
? UIColor.ows_gray90
: UIColor.ows_gray05)
addSubview(actionStackView)
actionStackView.autoPinEdgesToSuperviewEdges()
self.clipsToBounds = true
let touchGesture = UILongPressGestureRecognizer(target: self, action: #selector(didTouch(gesture:)))
touchGesture.minimumPressDuration = 0.0
touchGesture.allowableMovement = CGFloat.greatestFiniteMagnitude
self.addGestureRecognizer(touchGesture)
}
required init?(coder aDecoder: NSCoder) {
notImplemented()
}
@objc
public func didTouch(gesture: UIGestureRecognizer) {
switch gesture.state {
case .possible:
break
case .began:
let location = gesture.location(in: self)
highlightActionView(location: location, fromView: self)
case .changed:
let location = gesture.location(in: self)
highlightActionView(location: location, fromView: self)
case .ended:
Logger.debug("ended")
let location = gesture.location(in: self)
selectActionView(location: location, fromView: self)
case .cancelled:
Logger.debug("canceled")
unhighlightAllActionViews()
case .failed:
Logger.debug("failed")
unhighlightAllActionViews()
default: break
}
}
public func addAction(_ action: MenuAction) {
actions.append(action)
let actionView = MenuActionView(action: action)
actionView.delegate = self
actionViews.append(actionView)
self.actionStackView.addArrangedSubview(actionView)
}
// MARK: MenuActionViewDelegate
func actionView(_ actionView: MenuActionView, didSelectAction action: MenuAction) {
self.delegate?.actionSheet(self, didSelectAction: action)
}
// MARK:
private func updateMask() {
let cornerRadius: CGFloat = 16
let path: UIBezierPath = UIBezierPath(roundedRect: bounds, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))
let mask = CAShapeLayer()
mask.path = path.cgPath
self.layer.mask = mask
}
private func unhighlightAllActionViews() {
for actionView in actionViews {
actionView.isHighlighted = false
}
}
private func actionView(touchedBy touchPoint: CGPoint, fromView: UIView) -> MenuActionView? {
for actionView in actionViews {
let convertedPoint = actionView.convert(touchPoint, from: fromView)
if actionView.point(inside: convertedPoint, with: nil) {
return actionView
}
}
return nil
}
private func highlightActionView(location: CGPoint, fromView: UIView) {
guard let touchedView = actionView(touchedBy: location, fromView: fromView) else {
unhighlightAllActionViews()
return
}
if hasEverHighlightedAction, !touchedView.isHighlighted {
self.hapticFeedback.selectionChanged()
}
touchedView.isHighlighted = true
hasEverHighlightedAction = true
self.actionViews.filter { $0 != touchedView }.forEach { $0.isHighlighted = false }
}
private func selectActionView(location: CGPoint, fromView: UIView) {
guard let selectedView: MenuActionView = actionView(touchedBy: location, fromView: fromView) else {
unhighlightAllActionViews()
return
}
selectedView.isHighlighted = true
self.actionViews.filter { $0 != selectedView }.forEach { $0.isHighlighted = false }
delegate?.actionSheet(self, didSelectAction: selectedView.action)
}
}
protocol MenuActionViewDelegate: class {
func actionView(_ actionView: MenuActionView, didSelectAction action: MenuAction)
}
class MenuActionView: UIButton {
public weak var delegate: MenuActionViewDelegate?
public let action: MenuAction
required init(action: MenuAction) {
self.action = action
super.init(frame: CGRect.zero)
isUserInteractionEnabled = true
backgroundColor = defaultBackgroundColor
let textColor = isLightMode ? UIColor.black : UIColor.white
var image = action.image
image = image.withRenderingMode(.alwaysTemplate)
let imageView = UIImageView(image: image)
imageView.tintColor = textColor.withAlphaComponent(Values.unimportantElementOpacity)
let imageWidth: CGFloat = 24
imageView.autoSetDimensions(to: CGSize(width: imageWidth, height: imageWidth))
imageView.isUserInteractionEnabled = false
let titleLabel = UILabel()
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
titleLabel.textColor = textColor
titleLabel.text = action.title
titleLabel.isUserInteractionEnabled = false
let subtitleLabel = UILabel()
subtitleLabel.font = .systemFont(ofSize: Values.smallFontSize)
subtitleLabel.textColor = textColor.withAlphaComponent(Values.unimportantElementOpacity)
subtitleLabel.text = action.subtitle
subtitleLabel.isUserInteractionEnabled = false
let textColumn = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
textColumn.axis = .vertical
textColumn.alignment = .leading
textColumn.isUserInteractionEnabled = false
let contentRow = UIStackView(arrangedSubviews: [imageView, textColumn])
contentRow.axis = .horizontal
contentRow.alignment = .center
contentRow.spacing = 12
contentRow.isLayoutMarginsRelativeArrangement = true
contentRow.layoutMargins = UIEdgeInsets(top: 7, left: 16, bottom: 7, right: 16)
contentRow.isUserInteractionEnabled = false
self.addSubview(contentRow)
contentRow.autoPinEdgesToSuperviewMargins()
contentRow.autoSetDimension(.height, toSize: 56, relation: .greaterThanOrEqual)
self.isUserInteractionEnabled = false
}
private var defaultBackgroundColor: UIColor {
return isLightMode ? UIColor(hex: 0xFCFCFC) : UIColor(hex: 0x1B1B1B)
}
private var highlightedBackgroundColor: UIColor {
return isLightMode ? UIColor(hex: 0xDFDFDF) : UIColor(hex: 0x0C0C0C)
}
override var isHighlighted: Bool {
didSet {
self.backgroundColor = isHighlighted ? highlightedBackgroundColor : defaultBackgroundColor
}
}
@objc
func didPress(sender: Any) {
Logger.debug("")
self.delegate?.actionView(self, didSelectAction: action)
}
required init?(coder aDecoder: NSCoder) {
notImplemented()
}
}

View File

@ -0,0 +1,51 @@
final class DocumentView : UIView {
private let viewItem: ConversationViewItem
private let textColor: UIColor
// MARK: Settings
private static let iconSize: CGFloat = 24
private static let iconImageViewSize: CGFloat = 40
// MARK: Lifecycle
init(viewItem: ConversationViewItem, textColor: UIColor) {
self.viewItem = viewItem
self.textColor = textColor
super.init(frame: CGRect.zero)
setUpViewHierarchy()
}
override init(frame: CGRect) {
preconditionFailure("Use init(viewItem:textColor:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(viewItem:textColor:) instead.")
}
private func setUpViewHierarchy() {
guard let attachment = viewItem.attachmentStream ?? viewItem.attachmentPointer else { return }
// Image view
let iconSize = DocumentView.iconSize
let icon = UIImage(named: "actionsheet_document_black")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
let imageView = UIImageView(image: icon)
imageView.contentMode = .center
let iconImageViewSize = DocumentView.iconImageViewSize
imageView.set(.width, to: iconImageViewSize)
imageView.set(.height, to: iconImageViewSize)
// Body label
let titleLabel = UILabel()
titleLabel.lineBreakMode = .byTruncatingTail
titleLabel.text = attachment.sourceFilename ?? "File"
titleLabel.textColor = textColor
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
// Stack view
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
stackView.axis = .horizontal
stackView.alignment = .center
stackView.isLayoutMarginsRelativeArrangement = true
stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 12)
addSubview(stackView)
stackView.pin(to: self, withInset: Values.smallSpacing)
}
}

View File

@ -0,0 +1,220 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
extension CGPoint {
public func offsetBy(dx: CGFloat) -> CGPoint {
return CGPoint(x: x + dx, y: y)
}
public func offsetBy(dy: CGFloat) -> CGPoint {
return CGPoint(x: x, y: y + dy)
}
}
// MARK: -
@objc
public enum LinkPreviewImageState: Int {
case none
case loading
case loaded
case invalid
}
// MARK: -
@objc
public protocol LinkPreviewState {
func isLoaded() -> Bool
func urlString() -> String?
func displayDomain() -> String?
func title() -> String?
func imageState() -> LinkPreviewImageState
func image() -> UIImage?
}
// MARK: -
@objc
public class LinkPreviewLoading: NSObject, LinkPreviewState {
override init() {
}
public func isLoaded() -> Bool {
return false
}
public func urlString() -> String? {
return nil
}
public func displayDomain() -> String? {
return nil
}
public func title() -> String? {
return nil
}
public func imageState() -> LinkPreviewImageState {
return .none
}
public func image() -> UIImage? {
return nil
}
}
// MARK: -
@objc
public class LinkPreviewDraft: NSObject, LinkPreviewState {
private let linkPreviewDraft: OWSLinkPreviewDraft
@objc
public required init(linkPreviewDraft: OWSLinkPreviewDraft) {
self.linkPreviewDraft = linkPreviewDraft
}
public func isLoaded() -> Bool {
return true
}
public func urlString() -> String? {
return linkPreviewDraft.urlString
}
public func displayDomain() -> String? {
guard let displayDomain = linkPreviewDraft.displayDomain() else {
owsFailDebug("Missing display domain")
return nil
}
return displayDomain
}
public func title() -> String? {
guard let value = linkPreviewDraft.title,
value.count > 0 else {
return nil
}
return value
}
public func imageState() -> LinkPreviewImageState {
if linkPreviewDraft.jpegImageData != nil {
return .loaded
} else {
return .none
}
}
public func image() -> UIImage? {
guard let jpegImageData = linkPreviewDraft.jpegImageData else {
return nil
}
guard let image = UIImage(data: jpegImageData) else {
owsFailDebug("Could not load image: \(jpegImageData.count)")
return nil
}
return image
}
}
// MARK: -
@objc
public class LinkPreviewSent: NSObject, LinkPreviewState {
private let linkPreview: OWSLinkPreview
private let imageAttachment: TSAttachment?
@objc
public var imageSize: CGSize {
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
return CGSize.zero
}
return attachmentStream.imageSize()
}
@objc
public required init(linkPreview: OWSLinkPreview,
imageAttachment: TSAttachment?) {
self.linkPreview = linkPreview
self.imageAttachment = imageAttachment
}
public func isLoaded() -> Bool {
return true
}
public func urlString() -> String? {
guard let urlString = linkPreview.urlString else {
owsFailDebug("Missing url")
return nil
}
return urlString
}
public func displayDomain() -> String? {
guard let displayDomain = linkPreview.displayDomain() else {
Logger.error("Missing display domain")
return nil
}
return displayDomain
}
public func title() -> String? {
guard let value = linkPreview.title,
value.count > 0 else {
return nil
}
return value
}
public func imageState() -> LinkPreviewImageState {
guard linkPreview.imageAttachmentId != nil else {
return .none
}
guard let imageAttachment = imageAttachment else {
owsFailDebug("Missing imageAttachment.")
return .none
}
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
return .loading
}
guard attachmentStream.isImage,
attachmentStream.isValidImage else {
return .invalid
}
return .loaded
}
public func image() -> UIImage? {
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
return nil
}
guard attachmentStream.isImage,
attachmentStream.isValidImage else {
return nil
}
guard let imageFilepath = attachmentStream.originalFilePath else {
owsFailDebug("Attachment is missing file path.")
return nil
}
guard let image = UIImage(contentsOfFile: imageFilepath) else {
owsFailDebug("Could not load image: \(imageFilepath)")
return nil
}
return image
}
}
// MARK: -
@objc
public protocol LinkPreviewViewDraftDelegate {
func linkPreviewCanCancel() -> Bool
func linkPreviewDidCancel()
}

View File

@ -0,0 +1,174 @@
import NVActivityIndicatorView
final class LinkPreviewView : UIView {
private let viewItem: ConversationViewItem?
private let maxWidth: CGFloat
private let delegate: LinkPreviewViewDelegate
var linkPreviewState: LinkPreviewState? { didSet { update() } }
private lazy var imageViewContainerWidthConstraint = imageView.set(.width, to: 100)
private lazy var imageViewContainerHeightConstraint = imageView.set(.height, to: 100)
private lazy var sentLinkPreviewTextColor: UIColor = {
let isOutgoing = (viewItem!.interaction.interactionType() == .outgoingMessage)
switch (isOutgoing, AppModeManager.shared.currentAppMode) {
case (true, .dark), (false, .light): return .black
default: return .white
}
}()
// MARK: UI Components
private lazy var imageView: UIImageView = {
let result = UIImageView()
result.contentMode = .scaleAspectFill
return result
}()
private lazy var imageViewContainer: UIView = {
let result = UIView()
result.clipsToBounds = true
return result
}()
private lazy var loader: NVActivityIndicatorView = {
let color: UIColor = isLightMode ? .black : .white
return NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: color, padding: nil)
}()
private lazy var titleLabel: UILabel = {
let result = UILabel()
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
result.numberOfLines = 0
return result
}()
private lazy var bodyTextViewContainer = UIView()
private lazy var hStackViewContainer = UIView()
private lazy var hStackView = UIStackView()
private lazy var cancelButton: UIButton = {
let result = UIButton(type: .custom)
let tint: UIColor = isLightMode ? .black : .white
result.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal)
let cancelButtonSize = LinkPreviewView.cancelButtonSize
result.set(.width, to: cancelButtonSize)
result.set(.height, to: cancelButtonSize)
result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
return result
}()
// MARK: Settings
private static let loaderSize: CGFloat = 24
private static let cancelButtonSize: CGFloat = 45
// MARK: Lifecycle
init(for viewItem: ConversationViewItem?, maxWidth: CGFloat, delegate: LinkPreviewViewDelegate) {
self.viewItem = viewItem
self.maxWidth = maxWidth
self.delegate = delegate
super.init(frame: CGRect.zero)
setUpViewHierarchy()
}
override init(frame: CGRect) {
preconditionFailure("Use init(for:maxWidth:delegate:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(for:maxWidth:delegate:) instead.")
}
private func setUpViewHierarchy() {
// Image view
imageViewContainerWidthConstraint.isActive = true
imageViewContainerHeightConstraint.isActive = true
imageViewContainer.addSubview(imageView)
imageView.pin(to: imageViewContainer)
// Title label
let titleLabelContainer = UIView()
titleLabelContainer.addSubview(titleLabel)
titleLabel.pin(to: titleLabelContainer, withInset: Values.smallSpacing)
// Horizontal stack view
hStackView.addArrangedSubview(imageViewContainer)
hStackView.addArrangedSubview(titleLabelContainer)
hStackView.axis = .horizontal
hStackView.alignment = .center
hStackViewContainer.addSubview(hStackView)
hStackView.pin(to: hStackViewContainer)
// Vertical stack view
let vStackView = UIStackView(arrangedSubviews: [ hStackViewContainer, bodyTextViewContainer ])
vStackView.axis = .vertical
addSubview(vStackView)
vStackView.pin(to: self)
// Loader
addSubview(loader)
let loaderSize = LinkPreviewView.loaderSize
loader.set(.width, to: loaderSize)
loader.set(.height, to: loaderSize)
loader.center(in: self)
}
// MARK: Updating
private func update() {
cancelButton.removeFromSuperview()
guard let linkPreviewState = linkPreviewState else { return }
var image = linkPreviewState.image()
if image == nil && (linkPreviewState is LinkPreviewDraft || linkPreviewState is LinkPreviewSent) {
image = UIImage(named: "Link")?.withTint(isLightMode ? .black : .white)
}
// Image view
let imageViewContainerSize: CGFloat = (linkPreviewState is LinkPreviewSent) ? 100 : 80
imageViewContainerWidthConstraint.constant = imageViewContainerSize
imageViewContainerHeightConstraint.constant = imageViewContainerSize
imageViewContainer.layer.cornerRadius = (linkPreviewState is LinkPreviewSent) ? 0 : 8
if linkPreviewState is LinkPreviewLoading {
imageViewContainer.backgroundColor = .clear
} else {
imageViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06)
}
imageView.image = image
imageView.contentMode = (linkPreviewState.image() == nil) ? .center : .scaleAspectFill
// Loader
loader.alpha = (image != nil) ? 0 : 1
if image != nil { loader.stopAnimating() } else { loader.startAnimating() }
// Title
let isSent = (linkPreviewState is LinkPreviewSent)
let isOutgoing = (viewItem?.interaction.interactionType() == .outgoingMessage)
let textColor: UIColor
if isSent && isOutgoing && isLightMode {
textColor = .white
} else {
textColor = isDarkMode ? .white : .black
}
titleLabel.textColor = textColor
titleLabel.text = linkPreviewState.title()
// Horizontal stack view
switch linkPreviewState {
case is LinkPreviewSent: hStackViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06)
default: hStackViewContainer.backgroundColor = nil
}
// Body text view
bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() }
if let viewItem = viewItem {
let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: sentLinkPreviewTextColor, searchText: delegate.lastSearchedText, delegate: delegate)
bodyTextViewContainer.addSubview(bodyTextView)
bodyTextView.pin(to: bodyTextViewContainer, withInset: 12)
}
if linkPreviewState is LinkPreviewDraft {
hStackView.addArrangedSubview(cancelButton)
}
}
// MARK: Interaction
@objc private func cancel() {
delegate.handleLinkPreviewCanceled()
}
}
// MARK: Delegate
protocol LinkPreviewViewDelegate : UITextViewDelegate & BodyTextViewDelegate {
var lastSearchedText: String? { get }
func handleLinkPreviewCanceled()
}

View File

@ -4,15 +4,15 @@
import Foundation
@objc(OWSMediaAlbumCellView)
public class MediaAlbumCellView: UIStackView {
@objc(OWSMediaAlbumView)
public class MediaAlbumView: UIStackView {
private let items: [ConversationMediaAlbumItem]
@objc
public let itemViews: [ConversationMediaView]
public let itemViews: [MediaView]
@objc
public var moreItemsView: ConversationMediaView?
public var moreItemsView: MediaView?
private static let kSpacingPts: CGFloat = 2
private static let kMaxItems = 5
@ -26,22 +26,20 @@ public class MediaAlbumCellView: UIStackView {
public required init(mediaCache: NSCache<NSString, AnyObject>,
items: [ConversationMediaAlbumItem],
isOutgoing: Bool,
maxMessageWidth: CGFloat,
isOnionRouted: Bool) {
maxMessageWidth: CGFloat) {
self.items = items
self.itemViews = MediaAlbumCellView.itemsToDisplay(forItems: items).map {
let result = ConversationMediaView(mediaCache: mediaCache,
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items).map {
let result = MediaView(mediaCache: mediaCache,
attachment: $0.attachment,
isOutgoing: isOutgoing,
maxMessageWidth: maxMessageWidth,
isOnionRouted: isOnionRouted)
maxMessageWidth: maxMessageWidth)
return result
}
super.init(frame: .zero)
// UIStackView's backgroundColor property has no effect.
addBackgroundView(withBackgroundColor: Theme.backgroundColor)
addBackgroundView(withBackgroundColor: Colors.navigationBarBackground)
createContents(maxMessageWidth: maxMessageWidth)
}
@ -62,19 +60,19 @@ public class MediaAlbumCellView: UIStackView {
case 2:
// X X
// side-by-side.
let imageSize = (maxMessageWidth - MediaAlbumCellView.kSpacingPts) / 2
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
autoSet(viewSize: imageSize, ofViews: itemViews)
for itemView in itemViews {
addArrangedSubview(itemView)
}
self.axis = .horizontal
self.spacing = MediaAlbumCellView.kSpacingPts
self.spacing = MediaAlbumView.kSpacingPts
case 3:
// x
// X x
// Big on left, 2 small on right.
let smallImageSize = (maxMessageWidth - MediaAlbumCellView.kSpacingPts * 2) / 3
let bigImageSize = smallImageSize * 2 + MediaAlbumCellView.kSpacingPts
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
let bigImageSize = smallImageSize * 2 + MediaAlbumView.kSpacingPts
guard let leftItemView = itemViews.first else {
owsFailDebug("Missing view")
@ -88,12 +86,12 @@ public class MediaAlbumCellView: UIStackView {
axis: .vertical,
viewSize: smallImageSize))
self.axis = .horizontal
self.spacing = MediaAlbumCellView.kSpacingPts
self.spacing = MediaAlbumView.kSpacingPts
case 4:
// X X
// X X
// Square
let imageSize = (maxMessageWidth - MediaAlbumCellView.kSpacingPts) / 2
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
let topViews = Array(itemViews[0..<2])
addArrangedSubview(newRow(rowViews: topViews,
@ -106,13 +104,13 @@ public class MediaAlbumCellView: UIStackView {
viewSize: imageSize))
self.axis = .vertical
self.spacing = MediaAlbumCellView.kSpacingPts
self.spacing = MediaAlbumView.kSpacingPts
default:
// X X
// xxx
// 2 big on top, 3 small on bottom.
let bigImageSize = (maxMessageWidth - MediaAlbumCellView.kSpacingPts) / 2
let smallImageSize = (maxMessageWidth - MediaAlbumCellView.kSpacingPts * 2) / 3
let bigImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
let topViews = Array(itemViews[0..<2])
addArrangedSubview(newRow(rowViews: topViews,
@ -125,9 +123,9 @@ public class MediaAlbumCellView: UIStackView {
viewSize: smallImageSize))
self.axis = .vertical
self.spacing = MediaAlbumCellView.kSpacingPts
self.spacing = MediaAlbumView.kSpacingPts
if items.count > MediaAlbumCellView.kMaxItems {
if items.count > MediaAlbumView.kMaxItems {
guard let lastView = bottomViews.last else {
owsFailDebug("Missing lastView")
return
@ -140,7 +138,7 @@ public class MediaAlbumCellView: UIStackView {
lastView.addSubview(tintView)
tintView.autoPinEdgesToSuperviewEdges()
let moreCount = max(1, items.count - MediaAlbumCellView.kMaxItems)
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
let moreCountText = OWSFormat.formatInt(Int32(moreCount))
let moreText = String(format: NSLocalizedString("MEDIA_GALLERY_MORE_ITEMS_FORMAT",
comment: "Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}."), moreCountText)
@ -184,24 +182,24 @@ public class MediaAlbumCellView: UIStackView {
}
private func autoSet(viewSize: CGFloat,
ofViews views: [ConversationMediaView]) {
ofViews views: [MediaView]) {
for itemView in views {
itemView.autoSetDimensions(to: CGSize(width: viewSize, height: viewSize))
}
}
private func newRow(rowViews: [ConversationMediaView],
private func newRow(rowViews: [MediaView],
axis: NSLayoutConstraint.Axis,
viewSize: CGFloat) -> UIStackView {
autoSet(viewSize: viewSize, ofViews: rowViews)
return newRow(rowViews: rowViews, axis: axis)
}
private func newRow(rowViews: [ConversationMediaView],
private func newRow(rowViews: [MediaView],
axis: NSLayoutConstraint.Axis) -> UIStackView {
let stackView = UIStackView(arrangedSubviews: rowViews)
stackView.axis = axis
stackView.spacing = MediaAlbumCellView.kSpacingPts
stackView.spacing = MediaAlbumView.kSpacingPts
return stackView
}
@ -267,8 +265,8 @@ public class MediaAlbumCellView: UIStackView {
}
@objc
public func mediaView(forLocation location: CGPoint) -> ConversationMediaView? {
var bestMediaView: ConversationMediaView?
public func mediaView(forLocation location: CGPoint) -> MediaView? {
var bestMediaView: MediaView?
var bestDistance: CGFloat = 0
for itemView in itemViews {
let itemCenter = convert(itemView.center, from: itemView.superview)
@ -283,7 +281,7 @@ public class MediaAlbumCellView: UIStackView {
}
@objc
public func isMoreItemsView(mediaView: ConversationMediaView) -> Bool {
public func isMoreItemsView(mediaView: MediaView) -> Bool {
return moreItemsView == mediaView
}
}

View File

@ -0,0 +1,78 @@
final class MediaLoaderView : UIView {
private let bar = UIView()
private lazy var barLeftConstraint = bar.pin(.left, to: .left, of: self)
private lazy var barRightConstraint = bar.pin(.right, to: .right, of: self)
// MARK: Lifecycle
override init(frame: CGRect) {
super.init(frame: frame)
setUpViewHierarchy()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setUpViewHierarchy()
}
private func setUpViewHierarchy() {
bar.backgroundColor = Colors.accent
bar.set(.height, to: 8)
addSubview(bar)
barLeftConstraint.isActive = true
bar.pin(.top, to: .top, of: self)
barRightConstraint.isActive = true
bar.pin(.bottom, to: .bottom, of: self)
step1()
}
// MARK: Animation
func step1() {
barRightConstraint.constant = -bounds.width
UIView.animate(withDuration: 0.5, animations: { [weak self] in
guard let self = self else { return }
self.barRightConstraint.constant = 0
self.layoutIfNeeded()
}, completion: { [weak self] _ in
self?.step2()
})
}
func step2() {
barLeftConstraint.constant = 0
UIView.animate(withDuration: 0.5, animations: { [weak self] in
guard let self = self else { return }
self.barLeftConstraint.constant = self.bounds.width
self.layoutIfNeeded()
}, completion: { [weak self] _ in
Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { _ in
self?.step3()
}
})
}
func step3() {
barLeftConstraint.constant = bounds.width
UIView.animate(withDuration: 0.5, animations: { [weak self] in
guard let self = self else { return }
self.barLeftConstraint.constant = 0
self.layoutIfNeeded()
}, completion: { [weak self] _ in
self?.step4()
})
}
func step4() {
barRightConstraint.constant = 0
UIView.animate(withDuration: 0.5, animations: { [weak self] in
guard let self = self else { return }
self.barRightConstraint.constant = -self.bounds.width
self.layoutIfNeeded()
}, completion: { [weak self] _ in
Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { _ in
self?.step1()
}
})
}
}

View File

@ -0,0 +1,78 @@
final class MediaTextOverlayView : UIView {
private let viewItem: ConversationViewItem
private let albumViewWidth: CGFloat
private let delegate: MessageCellDelegate
var readMoreButton: UIButton?
// MARK: Settings
private static let maxHeight: CGFloat = 88;
// MARK: Lifecycle
init(viewItem: ConversationViewItem, albumViewWidth: CGFloat, delegate: MessageCellDelegate) {
self.viewItem = viewItem
self.albumViewWidth = albumViewWidth
self.delegate = delegate
super.init(frame: CGRect.zero)
setUpViewHierarchy()
}
override init(frame: CGRect) {
preconditionFailure("Use init(text:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(text:) instead.")
}
private func setUpViewHierarchy() {
guard let message = viewItem.interaction as? TSMessage, let body = message.body, body.count > 0 else { return }
// Shadow
let shadowView = GradientView(from: .clear, to: UIColor.black.withAlphaComponent(0.7))
addSubview(shadowView)
shadowView.pin(to: self)
// Line
let lineView = UIView()
lineView.backgroundColor = Colors.accent
lineView.set(.width, to: Values.accentLineThickness)
// Body label
let bodyLabel = UILabel()
bodyLabel.numberOfLines = 0
bodyLabel.lineBreakMode = .byTruncatingTail
bodyLabel.text = given(body) { MentionUtilities.highlightMentions(in: $0, threadID: viewItem.interaction.uniqueThreadId) }
bodyLabel.textColor = .white
bodyLabel.font = .systemFont(ofSize: Values.smallFontSize)
// Content stack view
let contentStackView = UIStackView(arrangedSubviews: [ lineView, bodyLabel ])
contentStackView.axis = .horizontal
contentStackView.spacing = Values.smallSpacing
addSubview(contentStackView)
let inset = Values.mediumSpacing
contentStackView.pin(.left, to: .left, of: self, withInset: inset)
contentStackView.pin(.top, to: .top, of: self, withInset: 3 * inset)
contentStackView.pin(.right, to: .right, of: self, withInset: -inset)
// Max height
bodyLabel.heightAnchor.constraint(lessThanOrEqualToConstant: MediaTextOverlayView.maxHeight).isActive = true
// Overflow button
let bodyLabelTargetSize = bodyLabel.sizeThatFits(CGSize(width: albumViewWidth - 2 * inset, height: .greatestFiniteMagnitude))
if bodyLabelTargetSize.height > MediaTextOverlayView.maxHeight {
let readMoreButton = UIButton()
self.readMoreButton = readMoreButton
readMoreButton.setTitle("Read More", for: UIControl.State.normal)
readMoreButton.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize)
readMoreButton.setTitleColor(.white, for: UIControl.State.normal)
readMoreButton.addTarget(self, action: #selector(readMore), for: UIControl.Event.touchUpInside)
addSubview(readMoreButton)
readMoreButton.pin(.left, to: .left, of: self, withInset: inset)
readMoreButton.pin(.top, to: .bottom, of: contentStackView, withInset: Values.smallSpacing)
readMoreButton.pin(.bottom, to: .bottom, of: self, withInset: -Values.smallSpacing)
} else {
contentStackView.pin(.bottom, to: .bottom, of: self, withInset: -inset)
}
}
// MARK: Interaction
@objc private func readMore() {
delegate.showFullText(viewItem)
}
}

View File

@ -4,8 +4,8 @@
import Foundation
@objc(OWSConversationMediaView)
public class ConversationMediaView: UIView {
@objc(OWSMediaView)
public class MediaView: UIView {
private enum MediaError {
case missing
@ -22,7 +22,6 @@ public class ConversationMediaView: UIView {
private let maxMessageWidth: CGFloat
private var loadBlock: (() -> Void)?
private var unloadBlock: (() -> Void)?
private let isOnionRouted: Bool
// MARK: - LoadState
@ -85,17 +84,15 @@ public class ConversationMediaView: UIView {
public required init(mediaCache: NSCache<NSString, AnyObject>,
attachment: TSAttachment,
isOutgoing: Bool,
maxMessageWidth: CGFloat,
isOnionRouted: Bool) {
maxMessageWidth: CGFloat) {
self.mediaCache = mediaCache
self.attachment = attachment
self.isOutgoing = isOutgoing
self.maxMessageWidth = maxMessageWidth
self.isOnionRouted = isOnionRouted
super.init(frame: .zero)
backgroundColor = Theme.offBackgroundColor
backgroundColor = Colors.unimportant
clipsToBounds = true
createContents()
@ -152,53 +149,19 @@ public class ConversationMediaView: UIView {
configure(forError: .missing)
return
}
guard let attachmentId = attachmentPointer.uniqueId else {
owsFailDebug("Attachment missing unique ID.")
configure(forError: .invalid)
return
}
/*
guard nil != attachmentDownloads.downloadProgress(forAttachmentId: attachmentId) else {
// Not being downloaded.
configure(forError: .missing)
return
}
*/
backgroundColor = (Theme.isDarkThemeEnabled ? .ows_gray90 : .ows_gray05)
let view: UIView
if isOnionRouted { // Loki: Due to the way onion routing works we can't get upload progress for those attachments
let activityIndicatorView = UIActivityIndicatorView(style: .white)
activityIndicatorView.isHidden = false
activityIndicatorView.startAnimating()
view = activityIndicatorView
} else {
view = MediaDownloadView(attachmentId: attachmentId, radius: maxMessageWidth * 0.1)
}
addSubview(view)
view.autoPinEdgesToSuperviewEdges()
let loader = MediaLoaderView()
addSubview(loader)
loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self)
}
private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool {
guard isOutgoing else { return false }
guard let attachmentStream = attachment as? TSAttachmentStream else { return false }
guard let attachmentId = attachmentStream.uniqueId else {
owsFailDebug("Attachment missing unique ID.")
configure(forError: .invalid)
return false
}
guard !attachmentStream.isUploaded else { return false }
let view: UIView
if isOnionRouted { // Loki: Due to the way onion routing works we can't get upload progress for those attachments
let activityIndicatorView = UIActivityIndicatorView(style: .white)
activityIndicatorView.isHidden = false
activityIndicatorView.startAnimating()
view = activityIndicatorView
} else {
view = MediaUploadView(attachmentId: attachmentId, radius: maxMessageWidth * 0.1)
}
addSubview(view)
view.autoPinEdgesToSuperviewEdges()
let loader = MediaLoaderView()
addSubview(loader)
loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self)
return true
}
@ -215,7 +178,7 @@ public class ConversationMediaView: UIView {
// some performance cost.
animatedImageView.layer.minificationFilter = .trilinear
animatedImageView.layer.magnificationFilter = .trilinear
animatedImageView.backgroundColor = Theme.offBackgroundColor
animatedImageView.backgroundColor = Colors.unimportant
addSubview(animatedImageView)
animatedImageView.autoPinEdgesToSuperviewEdges()
_ = addUploadProgressIfNecessary(animatedImageView)
@ -274,7 +237,7 @@ public class ConversationMediaView: UIView {
// some performance cost.
stillImageView.layer.minificationFilter = .trilinear
stillImageView.layer.magnificationFilter = .trilinear
stillImageView.backgroundColor = Theme.offBackgroundColor
stillImageView.backgroundColor = Colors.unimportant
addSubview(stillImageView)
stillImageView.autoPinEdgesToSuperviewEdges()
_ = addUploadProgressIfNecessary(stillImageView)
@ -329,7 +292,7 @@ public class ConversationMediaView: UIView {
// some performance cost.
stillImageView.layer.minificationFilter = .trilinear
stillImageView.layer.magnificationFilter = .trilinear
stillImageView.backgroundColor = Theme.offBackgroundColor
stillImageView.backgroundColor = Colors.unimportant
addSubview(stillImageView)
stillImageView.autoPinEdgesToSuperviewEdges()
@ -408,7 +371,7 @@ public class ConversationMediaView: UIView {
return
}
let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
iconView.tintColor = Theme.primaryColor.withAlphaComponent(0.6)
iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
addSubview(iconView)
iconView.autoCenterInSuperview()
}
@ -457,7 +420,7 @@ public class ConversationMediaView: UIView {
Logger.verbose("media cache miss")
let threadSafeLoadState = self.threadSafeLoadState
ConversationMediaView.loadQueue.async {
MediaView.loadQueue.async {
guard threadSafeLoadState.get() == .loading else {
Logger.verbose("Skipping obsolete load.")
return

View File

@ -0,0 +1,254 @@
final class QuoteView : UIView {
private let mode: Mode
private let direction: Direction
private let hInset: CGFloat
private let maxWidth: CGFloat
private let delegate: QuoteViewDelegate?
private var maxBodyLabelHeight: CGFloat {
switch mode {
case .regular: return 60
case .draft: return 40
}
}
private var attachments: [OWSAttachmentInfo] {
switch mode {
case .regular(let viewItem): return (viewItem.interaction as? TSMessage)?.quotedMessage!.quotedAttachments ?? []
case .draft(let model): return given(model.attachmentStream) { [ OWSAttachmentInfo(attachmentStream: $0) ] } ?? []
}
}
private var thumbnail: UIImage? {
switch mode {
case .regular(let viewItem): return viewItem.quotedReply!.thumbnailImage
case .draft(let model): return model.thumbnailImage
}
}
private var body: String? {
switch mode {
case .regular(let viewItem): return (viewItem.interaction as? TSMessage)?.quotedMessage!.body
case .draft(let model): return model.body
}
}
private var threadID: String {
switch mode {
case .regular(let viewItem): return viewItem.interaction.uniqueThreadId
case .draft(let model): return model.threadId
}
}
private var isGroupThread: Bool {
switch mode {
case .regular(let viewItem): return viewItem.isGroupThread
case .draft(let model):
var result = false
Storage.read { transaction in
result = TSThread.fetch(uniqueId: model.threadId, transaction: transaction)?.isGroupThread() ?? false
}
return result
}
}
private var authorID: String {
switch mode {
case .regular(let viewItem): return viewItem.quotedReply!.authorId
case .draft(let model): return model.authorId
}
}
private var lineColor: UIColor {
switch (mode, AppModeManager.shared.currentAppMode) {
case (.regular, .light), (.draft, .light): return .black
case (.regular, .dark): return (direction == .outgoing) ? .black : Colors.accent
case (.draft, .dark): return Colors.accent
}
}
private var textColor: UIColor {
if case .draft = mode { return Colors.text }
switch (direction, AppModeManager.shared.currentAppMode) {
case (.outgoing, .dark), (.incoming, .light): return .black
default: return .white
}
}
// MARK: Mode
enum Mode {
case regular(ConversationViewItem)
case draft(OWSQuotedReplyModel)
}
// MARK: Direction
enum Direction { case incoming, outgoing }
// MARK: Settings
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
// MARK: Lifecycle
init(for viewItem: ConversationViewItem, direction: Direction, hInset: CGFloat, maxWidth: CGFloat) {
self.mode = .regular(viewItem)
self.maxWidth = maxWidth
self.direction = direction
self.hInset = hInset
self.delegate = nil
super.init(frame: CGRect.zero)
setUpViewHierarchy()
}
init(for model: OWSQuotedReplyModel, direction: Direction, hInset: CGFloat, maxWidth: CGFloat, delegate: QuoteViewDelegate) {
self.mode = .draft(model)
self.maxWidth = maxWidth
self.direction = direction
self.hInset = hInset
self.delegate = delegate
super.init(frame: CGRect.zero)
setUpViewHierarchy()
}
override init(frame: CGRect) {
preconditionFailure("Use init(for:maxMessageWidth:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(for:maxMessageWidth:) instead.")
}
private func setUpViewHierarchy() {
let hasAttachments = !attachments.isEmpty
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 !hasAttachments {
availableWidth = maxWidth - 2 * hInset - Values.accentLineThickness - 2 * smallSpacing
} else {
availableWidth = maxWidth - 2 * hInset - thumbnailSize - 2 * smallSpacing
}
if case .draft = mode {
availableWidth -= cancelButtonSize
}
let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude)
var body = self.body
// 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([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self)
contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true
// Line view
let lineView = UIView()
lineView.backgroundColor = lineColor
lineView.set(.width, to: Values.accentLineThickness)
if !hasAttachments {
mainStackView.addArrangedSubview(lineView)
} else {
let isAudio = MIMETypeUtil.isAudio(attachments.first!.contentType!)
let fallbackImageName = isAudio ? "attachment_audio" : "actionsheet_document_black"
let fallbackImage = UIImage(named: fallbackImageName)?.withTint(.white)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
let imageView = UIImageView(image: thumbnail ?? fallbackImage)
imageView.contentMode = (thumbnail != nil) ? .scaleAspectFill : .center
imageView.backgroundColor = lineColor
imageView.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
imageView.layer.masksToBounds = true
imageView.set(.width, to: thumbnailSize)
imageView.set(.height, to: thumbnailSize)
mainStackView.addArrangedSubview(imageView)
body = (thumbnail != nil) ? "Image" : (isAudio ? "Audio" : "Document")
}
// Body label
let bodyLabel = UILabel()
bodyLabel.numberOfLines = 0
bodyLabel.lineBreakMode = .byTruncatingTail
let isOutgoing = (direction == .outgoing)
bodyLabel.font = .systemFont(ofSize: Values.smallFontSize)
bodyLabel.attributedText = given(body) { MentionUtilities.highlightMentions(in: $0, isOutgoingMessage: isOutgoing, threadID: threadID, attributes: [:]) }
?? given(attachments.first?.contentType) { NSAttributedString(string: MIMETypeUtil.isAudio($0) ? "Audio" : "Document") } ?? NSAttributedString(string: "Document")
bodyLabel.textColor = textColor
if hasAttachments {
bodyLabel.numberOfLines = 1
}
let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace)
// Label stack view
var authorLabelHeight: CGFloat?
if isGroupThread {
let authorLabel = UILabel()
authorLabel.lineBreakMode = .byTruncatingTail
authorLabel.text = SSKEnvironment.shared.profileManager.profileNameForRecipient(withID: authorID, avoidingWriteTransaction: true)
authorLabel.textColor = textColor
authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize)
let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace)
authorLabel.set(.height, to: authorLabelSize.height)
authorLabelHeight = authorLabelSize.height
let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ])
labelStackView.axis = .vertical
labelStackView.spacing = labelStackViewSpacing
labelStackView.set(.width, to: max(bodyLabelSize.width, authorLabelSize.width))
labelStackView.isLayoutMarginsRelativeArrangement = true
labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0)
mainStackView.addArrangedSubview(labelStackView)
} else {
mainStackView.addArrangedSubview(bodyLabel)
}
// Cancel button
let cancelButton = UIButton(type: .custom)
let tint: UIColor = isLightMode ? .black : .white
cancelButton.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal)
cancelButton.set(.width, to: cancelButtonSize)
cancelButton.set(.height, to: cancelButtonSize)
cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
// Constraints
contentView.addSubview(mainStackView)
mainStackView.pin(to: contentView)
if !isGroupThread {
bodyLabel.set(.width, to: bodyLabelSize.width)
}
let bodyLabelHeight = bodyLabelSize.height.clamp(0, maxBodyLabelHeight)
let contentViewHeight: CGFloat
if hasAttachments {
contentViewHeight = thumbnailSize + 8 // Add a small amount of spacing above and below the thumbnail
} else {
if let authorLabelHeight = authorLabelHeight { // Group thread
contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin
} else {
contentViewHeight = bodyLabelHeight + 2 * smallSpacing
}
}
contentView.set(.height, to: contentViewHeight)
lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line
if case .draft = mode {
addSubview(cancelButton)
cancelButton.center(.vertical, in: self)
cancelButton.pin(.right, to: .right, of: self)
}
}
// MARK: Interaction
@objc private func cancel() {
delegate?.handleQuoteViewCancelButtonTapped()
}
}
// MARK: Delegate
protocol QuoteViewDelegate {
func handleQuoteViewCancelButtonTapped()
}

View File

@ -0,0 +1,173 @@
import NVActivityIndicatorView
@objc(SNVoiceMessageView)
public final class VoiceMessageView : UIView {
private let viewItem: ConversationViewItem
private var isShowingSpeedUpLabel = false
@objc var progress: Int = 0 { didSet { handleProgressChanged() } }
@objc var isPlaying = false { didSet { handleIsPlayingChanged() } }
private lazy var progressViewRightConstraint = progressView.pin(.right, to: .right, of: self, withInset: -VoiceMessageView.width)
private var attachment: TSAttachment? { viewItem.attachmentStream ?? viewItem.attachmentPointer }
private var duration: Int { Int(viewItem.audioDurationSeconds) }
// MARK: UI Components
private lazy var progressView: UIView = {
let result = UIView()
result.backgroundColor = UIColor.black.withAlphaComponent(0.2)
return result
}()
private lazy var toggleImageView: UIImageView = {
let result = UIImageView(image: UIImage(named: "Play"))
result.set(.width, to: 8)
result.set(.height, to: 8)
result.contentMode = .scaleAspectFit
return result
}()
private lazy var loader: NVActivityIndicatorView = {
let result = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: Colors.text, padding: nil)
result.set(.width, to: VoiceMessageView.toggleContainerSize + 2)
result.set(.height, to: VoiceMessageView.toggleContainerSize + 2)
return result
}()
private lazy var countdownLabelContainer: UIView = {
let result = UIView()
result.backgroundColor = .white
result.layer.masksToBounds = true
result.set(.height, to: VoiceMessageView.toggleContainerSize)
result.set(.width, to: 44)
return result
}()
private lazy var countdownLabel: UILabel = {
let result = UILabel()
result.textColor = .black
result.font = .systemFont(ofSize: Values.smallFontSize)
result.text = "00:00"
return result
}()
private lazy var speedUpLabel: UILabel = {
let result = UILabel()
result.textColor = .black
result.font = .systemFont(ofSize: Values.smallFontSize)
result.alpha = 0
result.text = "1.5x"
result.textAlignment = .center
return result
}()
// MARK: Settings
private static let width: CGFloat = 160
private static let toggleContainerSize: CGFloat = 20
private static let inset = Values.smallSpacing
// MARK: Lifecycle
init(viewItem: ConversationViewItem) {
self.viewItem = viewItem
self.progress = Int(viewItem.audioProgressSeconds)
super.init(frame: CGRect.zero)
setUpViewHierarchy()
handleProgressChanged()
}
override init(frame: CGRect) {
preconditionFailure("Use init(viewItem:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(viewItem:) instead.")
}
private func setUpViewHierarchy() {
let toggleContainerSize = VoiceMessageView.toggleContainerSize
let inset = VoiceMessageView.inset
// Width & height
set(.width, to: VoiceMessageView.width)
// Toggle
let toggleContainer = UIView()
toggleContainer.backgroundColor = .white
toggleContainer.set(.width, to: toggleContainerSize)
toggleContainer.set(.height, to: toggleContainerSize)
toggleContainer.addSubview(toggleImageView)
toggleImageView.center(in: toggleContainer)
toggleContainer.layer.cornerRadius = toggleContainerSize / 2
toggleContainer.layer.masksToBounds = true
// Line
let lineView = UIView()
lineView.backgroundColor = .white
lineView.set(.height, to: 1)
// Countdown label
countdownLabelContainer.addSubview(countdownLabel)
countdownLabel.center(in: countdownLabelContainer)
// Speed up label
countdownLabelContainer.addSubview(speedUpLabel)
speedUpLabel.center(in: countdownLabelContainer)
// Constraints
addSubview(progressView)
progressView.pin(.left, to: .left, of: self)
progressView.pin(.top, to: .top, of: self)
progressViewRightConstraint.isActive = true
progressView.pin(.bottom, to: .bottom, of: self)
addSubview(toggleContainer)
toggleContainer.pin(.left, to: .left, of: self, withInset: inset)
toggleContainer.pin(.top, to: .top, of: self, withInset: inset)
toggleContainer.pin(.bottom, to: .bottom, of: self, withInset: -inset)
addSubview(lineView)
lineView.pin(.left, to: .right, of: toggleContainer)
lineView.center(.vertical, in: self)
addSubview(countdownLabelContainer)
countdownLabelContainer.pin(.left, to: .right, of: lineView)
countdownLabelContainer.pin(.right, to: .right, of: self, withInset: -inset)
countdownLabelContainer.center(.vertical, in: self)
addSubview(loader)
loader.center(in: toggleContainer)
}
// MARK: Updating
public override func layoutSubviews() {
super.layoutSubviews()
countdownLabelContainer.layer.cornerRadius = countdownLabelContainer.bounds.height / 2
}
private func handleIsPlayingChanged() {
toggleImageView.image = isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play")
if !isPlaying { progress = 0 }
}
private func handleProgressChanged() {
let isDownloaded = (attachment?.isDownloaded == true)
loader.isHidden = isDownloaded
if isDownloaded { loader.stopAnimating() } else if !loader.isAnimating { loader.startAnimating() }
guard isDownloaded else { return }
countdownLabel.text = OWSFormat.formatDurationSeconds(duration - progress)
guard viewItem.audioProgressSeconds > 0 && viewItem.audioDurationSeconds > 0 else {
return progressViewRightConstraint.constant = -VoiceMessageView.width
}
let fraction = viewItem.audioProgressSeconds / viewItem.audioDurationSeconds
progressViewRightConstraint.constant = -(VoiceMessageView.width * (1 - fraction))
}
func showSpeedUpLabel() {
guard !isShowingSpeedUpLabel else { return }
isShowingSpeedUpLabel = true
UIView.animate(withDuration: 0.25) { [weak self] in
guard let self = self else { return }
self.countdownLabel.alpha = 0
self.speedUpLabel.alpha = 1
}
Timer.scheduledTimer(withTimeInterval: 1.25, repeats: false) { [weak self] _ in
UIView.animate(withDuration: 0.25, animations: {
guard let self = self else { return }
self.countdownLabel.alpha = 1
self.speedUpLabel.alpha = 0
}, completion: { _ in
self?.isShowingSpeedUpLabel = false
})
}
}
}

View File

@ -0,0 +1,71 @@
final class InfoMessageCell : MessageCell {
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: InfoMessageCell.iconSize)
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: InfoMessageCell.iconSize)
// MARK: UI Components
private lazy var iconImageView = UIImageView()
private lazy var label: UILabel = {
let result = UILabel()
result.numberOfLines = 0
result.lineBreakMode = .byWordWrapping
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
result.textColor = Colors.text
result.textAlignment = .center
return result
}()
private lazy var stackView: UIStackView = {
let result = UIStackView(arrangedSubviews: [ iconImageView, label ])
result.axis = .vertical
result.alignment = .center
result.spacing = Values.smallSpacing
return result
}()
// MARK: Settings
private static let iconSize: CGFloat = 16
private static let inset = Values.mediumSpacing
override class var identifier: String { "InfoMessageCell" }
// MARK: Lifecycle
override func setUpViewHierarchy() {
super.setUpViewHierarchy()
iconImageViewWidthConstraint.isActive = true
iconImageViewHeightConstraint.isActive = true
addSubview(stackView)
stackView.pin(.left, to: .left, of: self, withInset: InfoMessageCell.inset)
stackView.pin(.top, to: .top, of: self, withInset: InfoMessageCell.inset)
stackView.pin(.right, to: .right, of: self, withInset: -InfoMessageCell.inset)
stackView.pin(.bottom, to: .bottom, of: self, withInset: -InfoMessageCell.inset)
}
// MARK: Updating
override func update() {
guard let message = viewItem?.interaction as? TSInfoMessage else { return }
let icon: UIImage?
switch message.messageType {
case .typeDisappearingMessagesUpdate:
var configuration: OWSDisappearingMessagesConfiguration?
Storage.read { transaction in
configuration = message.thread(with: transaction).disappearingMessagesConfiguration(with: transaction)
}
if let configuration = configuration {
icon = configuration.isEnabled ? UIImage(named: "ic_timer") : UIImage(named: "ic_timer_disabled")
} else {
icon = nil
}
default: icon = nil
}
if let icon = icon {
iconImageView.image = icon.withTint(Colors.text)
}
iconImageViewWidthConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0
iconImageViewHeightConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0
Storage.read { transaction in
self.label.text = message.previewText(with: transaction)
}
}
}

View File

@ -0,0 +1,61 @@
import UIKit
class MessageCell : UITableViewCell {
var delegate: MessageCellDelegate?
var viewItem: ConversationViewItem? { didSet { update() } }
// MARK: Settings
class var identifier: String { preconditionFailure("Must be overridden by subclasses.") }
// MARK: Lifecycle
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setUpViewHierarchy()
setUpGestureRecognizers()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setUpViewHierarchy()
setUpGestureRecognizers()
}
func setUpViewHierarchy() {
backgroundColor = .clear
let selectedBackgroundView = UIView()
selectedBackgroundView.backgroundColor = .clear
self.selectedBackgroundView = selectedBackgroundView
}
func setUpGestureRecognizers() {
// To be overridden by subclasses
}
// MARK: Updating
func update() {
preconditionFailure("Must be overridden by subclasses.")
}
// MARK: Convenience
static func getCellType(for viewItem: ConversationViewItem) -> MessageCell.Type {
switch viewItem.interaction {
case is TSIncomingMessage: fallthrough
case is TSOutgoingMessage: return VisibleMessageCell.self
case is TSInfoMessage: return InfoMessageCell.self
case is TypingIndicatorInteraction: return TypingIndicatorCell.self
default: preconditionFailure()
}
}
}
protocol MessageCellDelegate {
var lastSearchedText: String? { get }
func getMediaCache() -> NSCache<NSString, AnyObject>
func handleViewItemLongPressed(_ viewItem: ConversationViewItem)
func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer)
func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem)
func showFullText(_ viewItem: ConversationViewItem)
func openURL(_ url: URL)
func handleReplyButtonTapped(for viewItem: ConversationViewItem)
}

View File

@ -0,0 +1,85 @@
// Assumptions
// We'll never encounter an outgoing typing indicator.
// Typing indicators are only sent in contact threads.
final class TypingIndicatorCell : MessageCell {
private var positionInCluster: Position? {
guard let viewItem = viewItem else { return nil }
if viewItem.isFirstInCluster { return .top }
if viewItem.isLastInCluster { return .bottom }
return .middle
}
private var isOnlyMessageInCluster: Bool { viewItem?.isFirstInCluster == true && viewItem?.isLastInCluster == true }
// MARK: UI Components
private lazy var bubbleView: UIView = {
let result = UIView()
result.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
result.backgroundColor = Colors.receivedMessageBackground
return result
}()
private let bubbleViewMaskLayer = CAShapeLayer()
private lazy var typingIndicatorView = TypingIndicatorView()
// MARK: Settings
override class var identifier: String { "TypingIndicatorCell" }
// MARK: Direction & Position
enum Position { case top, middle, bottom }
// MARK: Lifecycle
override func setUpViewHierarchy() {
super.setUpViewHierarchy()
// Bubble view
addSubview(bubbleView)
bubbleView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing)
bubbleView.pin(.top, to: .top, of: self, withInset: 1)
// Typing indicator view
bubbleView.addSubview(typingIndicatorView)
typingIndicatorView.pin(to: bubbleView, withInset: 12)
}
// MARK: Updating
override func update() {
guard let viewItem = viewItem, viewItem.interaction is TypingIndicatorInteraction else { return }
// Bubble view
updateBubbleViewCorners()
// Typing indicator view
typingIndicatorView.startAnimation()
}
override func layoutSubviews() {
super.layoutSubviews()
updateBubbleViewCorners()
}
private func updateBubbleViewCorners() {
let maskPath = UIBezierPath(roundedRect: bubbleView.bounds, byRoundingCorners: getCornersToRound(),
cornerRadii: CGSize(width: VisibleMessageCell.largeCornerRadius, height: VisibleMessageCell.largeCornerRadius))
bubbleViewMaskLayer.path = maskPath.cgPath
bubbleView.layer.mask = bubbleViewMaskLayer
}
override func prepareForReuse() {
super.prepareForReuse()
typingIndicatorView.stopAnimation()
}
// MARK: Convenience
private func getCornersToRound() -> UIRectCorner {
guard !isOnlyMessageInCluster else { return .allCorners }
let result: UIRectCorner
switch positionInCluster {
case .top: result = [ .topLeft, .topRight, .bottomRight ]
case .middle: result = [ .topRight, .bottomRight ]
case .bottom: result = [ .topRight, .bottomRight, .bottomLeft ]
case nil: result = .allCorners
}
return result
}
}

View File

@ -0,0 +1,608 @@
final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
private var unloadContent: (() -> Void)?
private var previousX: CGFloat = 0
var albumView: MediaAlbumView?
var bodyTextView: UITextView?
var mediaTextOverlayView: MediaTextOverlayView?
// Constraints
private lazy var headerViewTopConstraint = headerView.pin(.top, to: .top, of: self, withInset: 1)
private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0)
private lazy var profilePictureViewLeftConstraint = profilePictureView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.groupThreadHSpacing)
private lazy var profilePictureViewWidthConstraint = profilePictureView.set(.width, to: Values.verySmallProfilePictureSize)
private lazy var bubbleViewLeftConstraint1 = bubbleView.pin(.left, to: .right, of: profilePictureView, withInset: VisibleMessageCell.groupThreadHSpacing)
private lazy var bubbleViewLeftConstraint2 = bubbleView.leftAnchor.constraint(greaterThanOrEqualTo: leftAnchor, constant: VisibleMessageCell.gutterSize)
private lazy var bubbleViewTopConstraint = bubbleView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing)
private lazy var bubbleViewRightConstraint1 = bubbleView.pin(.right, to: .right, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
private lazy var bubbleViewRightConstraint2 = bubbleView.rightAnchor.constraint(lessThanOrEqualTo: rightAnchor, constant: -VisibleMessageCell.gutterSize)
private lazy var messageStatusImageViewTopConstraint = messageStatusImageView.pin(.top, to: .bottom, of: bubbleView, withInset: 0)
private lazy var messageStatusImageViewWidthConstraint = messageStatusImageView.set(.width, to: VisibleMessageCell.messageStatusImageViewSize)
private lazy var messageStatusImageViewHeightConstraint = messageStatusImageView.set(.height, to: VisibleMessageCell.messageStatusImageViewSize)
private lazy var timerViewOutgoingMessageConstraint = timerView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing)
private lazy var timerViewIncomingMessageConstraint = timerView.pin(.right, to: .right, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
let result = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
result.delegate = self
return result
}()
var lastSearchedText: String? { delegate?.lastSearchedText }
private var positionInCluster: Position? {
guard let viewItem = viewItem else { return nil }
if viewItem.isFirstInCluster { return .top }
if viewItem.isLastInCluster { return .bottom }
return .middle
}
private var isOnlyMessageInCluster: Bool { viewItem?.isFirstInCluster == true && viewItem?.isLastInCluster == true }
private var direction: Direction {
guard let message = viewItem?.interaction as? TSMessage else { preconditionFailure() }
switch message {
case is TSIncomingMessage: return .incoming
case is TSOutgoingMessage: return .outgoing
default: preconditionFailure()
}
}
private var shouldInsetHeader: Bool {
guard let viewItem = viewItem else { preconditionFailure() }
return (positionInCluster == .top || isOnlyMessageInCluster) && !viewItem.wasPreviousItemInfoMessage
}
// MARK: UI Components
private lazy var profilePictureView: ProfilePictureView = {
let result = ProfilePictureView()
let size = Values.verySmallProfilePictureSize
result.set(.height, to: size)
result.size = size
return result
}()
private lazy var moderatorIconImageView = UIImageView(image: #imageLiteral(resourceName: "Crown"))
lazy var bubbleView: UIView = {
let result = UIView()
result.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
return result
}()
private let bubbleViewMaskLayer = CAShapeLayer()
private lazy var headerView = UIView()
private lazy var authorLabel: UILabel = {
let result = UILabel()
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
return result
}()
private lazy var snContentView = UIView()
private lazy var messageStatusImageView: UIImageView = {
let result = UIImageView()
result.contentMode = .scaleAspectFit
result.layer.cornerRadius = VisibleMessageCell.messageStatusImageViewSize / 2
result.layer.masksToBounds = true
return result
}()
private lazy var replyButton: UIView = {
let result = UIView()
let size = VisibleMessageCell.replyButtonSize + 8
result.set(.width, to: size)
result.set(.height, to: size)
result.layer.borderWidth = 1
result.layer.borderColor = Colors.text.cgColor
result.layer.cornerRadius = size / 2
result.layer.masksToBounds = true
result.alpha = 0
return result
}()
private lazy var replyIconImageView: UIImageView = {
let result = UIImageView()
let size = VisibleMessageCell.replyButtonSize
result.set(.width, to: size)
result.set(.height, to: size)
result.image = UIImage(named: "ic_reply")!.withTint(Colors.text)
return result
}()
private lazy var timerView = OWSMessageTimerView()
// MARK: Settings
private static let messageStatusImageViewSize: CGFloat = 16
private static let authorLabelBottomSpacing: CGFloat = 4
private static let groupThreadHSpacing: CGFloat = 12
private static let profilePictureSize = Values.verySmallProfilePictureSize
private static let authorLabelInset: CGFloat = 12
private static let replyButtonSize: CGFloat = 24
private static let maxBubbleTranslationX: CGFloat = 40
private static let swipeToReplyThreshold: CGFloat = 130
static let smallCornerRadius: CGFloat = 4
static let largeCornerRadius: CGFloat = 18
static let contactThreadHSpacing = Values.mediumSpacing
static var gutterSize: CGFloat { groupThreadHSpacing + profilePictureSize + groupThreadHSpacing }
private var bodyLabelTextColor: UIColor {
switch (direction, AppModeManager.shared.currentAppMode) {
case (.outgoing, .dark), (.incoming, .light): return .black
default: return .white
}
}
override class var identifier: String { "VisibleMessageCell" }
// MARK: Direction & Position
enum Direction { case incoming, outgoing }
enum Position { case top, middle, bottom }
// MARK: Lifecycle
override func setUpViewHierarchy() {
super.setUpViewHierarchy()
// Header view
addSubview(headerView)
headerViewTopConstraint.isActive = true
headerView.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self)
// Author label
addSubview(authorLabel)
authorLabelHeightConstraint.isActive = true
authorLabel.pin(.top, to: .bottom, of: headerView)
// Profile picture view
addSubview(profilePictureView)
profilePictureViewLeftConstraint.isActive = true
profilePictureViewWidthConstraint.isActive = true
profilePictureView.pin(.bottom, to: .bottom, of: self, withInset: -1)
// Moderator icon image view
moderatorIconImageView.set(.width, to: 20)
moderatorIconImageView.set(.height, to: 20)
addSubview(moderatorIconImageView)
moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1)
moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5)
// Bubble view
addSubview(bubbleView)
bubbleViewLeftConstraint1.isActive = true
bubbleViewTopConstraint.isActive = true
bubbleViewRightConstraint1.isActive = true
// Timer view
addSubview(timerView)
timerView.center(.vertical, in: bubbleView)
timerViewOutgoingMessageConstraint.isActive = true
// Content view
bubbleView.addSubview(snContentView)
snContentView.pin(to: bubbleView)
// Message status image view
addSubview(messageStatusImageView)
messageStatusImageViewTopConstraint.isActive = true
messageStatusImageView.pin(.right, to: .right, of: bubbleView, withInset: -1)
messageStatusImageView.pin(.bottom, to: .bottom, of: self, withInset: -1)
messageStatusImageViewWidthConstraint.isActive = true
messageStatusImageViewHeightConstraint.isActive = true
// Reply button
addSubview(replyButton)
replyButton.addSubview(replyIconImageView)
replyIconImageView.center(in: replyButton)
replyButton.pin(.left, to: .right, of: bubbleView, withInset: Values.smallSpacing)
replyButton.center(.vertical, in: bubbleView)
// Remaining constraints
authorLabel.pin(.left, to: .left, of: bubbleView, withInset: VisibleMessageCell.authorLabelInset)
}
override func setUpGestureRecognizers() {
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
addGestureRecognizer(longPressRecognizer)
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tapGestureRecognizer.numberOfTapsRequired = 1
addGestureRecognizer(tapGestureRecognizer)
let doubleTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap))
doubleTapGestureRecognizer.numberOfTapsRequired = 2
addGestureRecognizer(doubleTapGestureRecognizer)
tapGestureRecognizer.require(toFail: doubleTapGestureRecognizer)
addGestureRecognizer(panGestureRecognizer)
}
// MARK: Updating
override func update() {
guard let viewItem = viewItem, let message = viewItem.interaction as? TSMessage else { return }
let thread = message.thread
let isGroupThread = thread.isGroupThread()
// Profile picture view
profilePictureViewLeftConstraint.constant = isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0
profilePictureViewWidthConstraint.constant = isGroupThread ? VisibleMessageCell.profilePictureSize : 0
let senderSessionID = (message as? TSIncomingMessage)?.authorId
profilePictureView.isHidden = !VisibleMessageCell.shouldShowProfilePicture(for: viewItem)
if let senderSessionID = senderSessionID {
profilePictureView.update(for: senderSessionID)
}
if let thread = thread as? TSGroupThread, thread.isOpenGroup,
let openGroup = Storage.shared.getOpenGroup(for: thread.uniqueId!), let senderSessionID = senderSessionID {
let isUserModerator = OpenGroupAPI.isUserModerator(senderSessionID, for: openGroup.channel, on: openGroup.server)
moderatorIconImageView.isHidden = !isUserModerator || profilePictureView.isHidden
} else {
moderatorIconImageView.isHidden = true
}
// Bubble view
bubbleViewLeftConstraint1.isActive = (direction == .incoming)
bubbleViewLeftConstraint1.constant = isGroupThread ? VisibleMessageCell.groupThreadHSpacing : VisibleMessageCell.contactThreadHSpacing
bubbleViewLeftConstraint2.isActive = (direction == .outgoing)
bubbleViewTopConstraint.constant = (viewItem.senderName == nil) ? 0 : VisibleMessageCell.authorLabelBottomSpacing
bubbleViewRightConstraint1.isActive = (direction == .outgoing)
bubbleViewRightConstraint2.isActive = (direction == .incoming)
bubbleView.backgroundColor = (direction == .incoming) ? Colors.receivedMessageBackground : Colors.sentMessageBackground
updateBubbleViewCorners()
// Content view
populateContentView(for: viewItem)
// Date break
headerViewTopConstraint.constant = shouldInsetHeader ? Values.mediumSpacing : 1
headerView.subviews.forEach { $0.removeFromSuperview() }
if viewItem.shouldShowDate {
populateHeader(for: viewItem)
}
// Author label
authorLabel.textColor = Colors.text
authorLabel.isHidden = (viewItem.senderName == nil)
authorLabel.text = viewItem.senderName?.string // Will only be set if it should be shown
let authorLabelAvailableWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - 2 * VisibleMessageCell.authorLabelInset
let authorLabelAvailableSpace = CGSize(width: authorLabelAvailableWidth, height: .greatestFiniteMagnitude)
let authorLabelSize = authorLabel.sizeThatFits(authorLabelAvailableSpace)
authorLabelHeightConstraint.constant = (viewItem.senderName != nil) ? authorLabelSize.height : 0
// Message status image view
let (image, backgroundColor) = getMessageStatusImage(for: message)
messageStatusImageView.image = image
messageStatusImageView.backgroundColor = backgroundColor
if let message = message as? TSOutgoingMessage {
messageStatusImageView.isHidden = (message.messageState == .sent && message.thread.lastInteraction != message)
} else {
messageStatusImageView.isHidden = true
}
messageStatusImageViewTopConstraint.constant = (messageStatusImageView.isHidden) ? 0 : 5
[ messageStatusImageViewWidthConstraint, messageStatusImageViewHeightConstraint ].forEach {
$0.constant = (messageStatusImageView.isHidden) ? 0 : VisibleMessageCell.messageStatusImageViewSize
}
// Timer
if viewItem.isExpiringMessage {
let expirationTimestamp = message.expiresAt
let expiresInSeconds = message.expiresInSeconds
timerView.configure(withExpirationTimestamp: expirationTimestamp, initialDurationSeconds: expiresInSeconds, tintColor: Colors.text)
}
timerView.isHidden = !viewItem.isExpiringMessage
timerViewOutgoingMessageConstraint.isActive = (direction == .outgoing)
timerViewIncomingMessageConstraint.isActive = (direction == .incoming)
}
private func populateHeader(for viewItem: ConversationViewItem) {
guard viewItem.shouldShowDate else { return }
let dateBreakLabel = UILabel()
dateBreakLabel.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
dateBreakLabel.textColor = Colors.text
dateBreakLabel.textAlignment = .center
let date = viewItem.interaction.receivedAtDate()
let description = DateUtil.formatDate(forConversationDateBreaks: date)
dateBreakLabel.text = description
headerView.addSubview(dateBreakLabel)
dateBreakLabel.pin(.top, to: .top, of: headerView, withInset: Values.smallSpacing)
let additionalBottomInset = shouldInsetHeader ? Values.mediumSpacing : 1
headerView.pin(.bottom, to: .bottom, of: dateBreakLabel, withInset: Values.smallSpacing + additionalBottomInset)
dateBreakLabel.center(.horizontal, in: headerView)
let availableWidth = VisibleMessageCell.getMaxWidth(for: viewItem)
let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude)
let dateBreakLabelSize = dateBreakLabel.sizeThatFits(availableSpace)
dateBreakLabel.set(.height, to: dateBreakLabelSize.height)
}
private func populateContentView(for viewItem: ConversationViewItem) {
snContentView.subviews.forEach { $0.removeFromSuperview() }
albumView = nil
bodyTextView = nil
mediaTextOverlayView = nil
let isOutgoing = (viewItem.interaction.interactionType() == .outgoingMessage)
switch viewItem.messageCellType {
case .textOnlyMessage:
let inset: CGFloat = 12
let maxWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - 2 * inset
if let linkPreview = viewItem.linkPreview {
let linkPreviewView = LinkPreviewView(for: viewItem, maxWidth: maxWidth, delegate: self)
linkPreviewView.linkPreviewState = LinkPreviewSent(linkPreview: linkPreview, imageAttachment: viewItem.linkPreviewAttachment)
snContentView.addSubview(linkPreviewView)
linkPreviewView.pin(to: snContentView)
} else {
// Stack view
let stackView = UIStackView(arrangedSubviews: [])
stackView.axis = .vertical
stackView.spacing = 2
// Quote view
if viewItem.quotedReply != nil {
let direction: QuoteView.Direction = isOutgoing ? .outgoing : .incoming
let hInset: CGFloat = 2
let quoteView = QuoteView(for: viewItem, direction: direction, hInset: hInset, maxWidth: maxWidth)
let quoteViewContainer = UIView(wrapping: quoteView, withInsets: UIEdgeInsets(top: 0, leading: hInset, bottom: 0, trailing: hInset))
stackView.addArrangedSubview(quoteViewContainer)
}
// Body text view
let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: bodyLabelTextColor, searchText: delegate?.lastSearchedText, delegate: self)
self.bodyTextView = bodyTextView
stackView.addArrangedSubview(bodyTextView)
// Constraints
snContentView.addSubview(stackView)
stackView.pin(to: snContentView, withInset: inset)
}
case .mediaMessage:
guard let cache = delegate?.getMediaCache() else { preconditionFailure() }
let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: viewItem)
let albumView = MediaAlbumView(mediaCache: cache, items: viewItem.mediaAlbumItems!, isOutgoing: isOutgoing, maxMessageWidth: maxMessageWidth)
self.albumView = albumView
snContentView.addSubview(albumView)
let size = getSize(for: viewItem)
albumView.set(.width, to: size.width)
albumView.set(.height, to: size.height)
albumView.pin(to: snContentView)
albumView.loadMedia()
albumView.layer.mask = bubbleViewMaskLayer
if let message = viewItem.interaction as? TSMessage, let body = message.body, body.count > 0,
let delegate = delegate { // delegate should always be set at this point
let overlayView = MediaTextOverlayView(viewItem: viewItem, albumViewWidth: size.width, delegate: delegate)
self.mediaTextOverlayView = overlayView
snContentView.addSubview(overlayView)
overlayView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: snContentView)
}
unloadContent = { albumView.unloadMedia() }
case .audio:
let voiceMessageView = VoiceMessageView(viewItem: viewItem)
snContentView.addSubview(voiceMessageView)
voiceMessageView.pin(to: snContentView)
viewItem.lastAudioMessageView = voiceMessageView
case .genericAttachment:
let documentView = DocumentView(viewItem: viewItem, textColor: bodyLabelTextColor)
snContentView.addSubview(documentView)
documentView.pin(to: snContentView)
default: return
}
}
override func layoutSubviews() {
super.layoutSubviews()
updateBubbleViewCorners()
}
private func updateBubbleViewCorners() {
let maskPath = UIBezierPath(roundedRect: bubbleView.bounds, byRoundingCorners: getCornersToRound(),
cornerRadii: CGSize(width: VisibleMessageCell.largeCornerRadius, height: VisibleMessageCell.largeCornerRadius))
bubbleViewMaskLayer.path = maskPath.cgPath
bubbleView.layer.mask = bubbleViewMaskLayer
}
override func prepareForReuse() {
super.prepareForReuse()
unloadContent?()
let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ]
viewsToMove.forEach { $0.transform = .identity }
replyButton.alpha = 0
timerView.prepareForReuse()
}
// MARK: Interaction
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let bodyTextView = bodyTextView {
let pointInBodyTextViewCoordinates = convert(point, to: bodyTextView)
if bodyTextView.bounds.contains(pointInBodyTextViewCoordinates) {
return bodyTextView
}
}
return super.hitTest(point, with: event)
}
override func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer == panGestureRecognizer {
let v = panGestureRecognizer.velocity(in: self)
guard v.x < 0 else { return false }
return abs(v.x) > abs(v.y)
} else {
return true
}
}
@objc func handleLongPress() {
guard let viewItem = viewItem else { return }
delegate?.handleViewItemLongPressed(viewItem)
}
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
guard let viewItem = viewItem else { return }
let location = gestureRecognizer.location(in: self)
if replyButton.frame.contains(location) {
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
reply()
} else {
delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer)
}
}
@objc private func handleDoubleTap() {
guard let viewItem = viewItem else { return }
delegate?.handleViewItemDoubleTapped(viewItem)
}
@objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ]
let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0)
switch gestureRecognizer.state {
case .changed:
let damping: CGFloat = 20
let sign: CGFloat = -1
let x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign
viewsToMove.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) }
replyButton.alpha = abs(translationX) / VisibleMessageCell.maxBubbleTranslationX
if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold && abs(previousX) < VisibleMessageCell.swipeToReplyThreshold {
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
}
previousX = translationX
case .ended, .cancelled:
if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold {
reply()
} else {
resetReply()
}
default: break
}
}
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
delegate?.openURL(URL)
return false
}
private func resetReply() {
let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ]
UIView.animate(withDuration: 0.25) {
viewsToMove.forEach { $0.transform = .identity }
self.replyButton.alpha = 0
}
}
private func reply() {
guard let viewItem = viewItem else { return }
resetReply()
delegate?.handleReplyButtonTapped(for: viewItem)
}
func handleLinkPreviewCanceled() {
// Not relevant in this case
}
// MARK: Convenience
private func getCornersToRound() -> UIRectCorner {
guard !isOnlyMessageInCluster else { return .allCorners }
let result: UIRectCorner
switch (positionInCluster, direction) {
case (.top, .outgoing): result = [ .bottomLeft, .topLeft, .topRight ]
case (.middle, .outgoing): result = [ .bottomLeft, .topLeft ]
case (.bottom, .outgoing): result = [ .bottomRight, .bottomLeft, .topLeft ]
case (.top, .incoming): result = [ .topLeft, .topRight, .bottomRight ]
case (.middle, .incoming): result = [ .topRight, .bottomRight ]
case (.bottom, .incoming): result = [ .topRight, .bottomRight, .bottomLeft ]
case (nil, _): result = .allCorners
}
return result
}
private static func getFontSize(for viewItem: ConversationViewItem) -> CGFloat {
let baselineFontSize = Values.mediumFontSize
switch viewItem.displayableBodyText?.jumbomojiCount {
case 1: return baselineFontSize + 30
case 2: return baselineFontSize + 24
case 3, 4, 5: return baselineFontSize + 18
default: return baselineFontSize
}
}
private func getMessageStatusImage(for message: TSMessage) -> (image: UIImage?, backgroundColor: UIColor?) {
guard let message = message as? TSOutgoingMessage else { return (nil, nil) }
let image: UIImage
var backgroundColor: UIColor? = nil
let status = MessageRecipientStatusUtils.recipientStatus(outgoingMessage: message)
switch status {
case .uploading, .sending: image = #imageLiteral(resourceName: "CircleDotDotDot").asTintedImage(color: Colors.text)!
case .sent, .skipped, .delivered: image = #imageLiteral(resourceName: "CircleCheck").asTintedImage(color: Colors.text)!
case .read:
backgroundColor = isLightMode ? .black : .white
image = isLightMode ? #imageLiteral(resourceName: "FilledCircleCheckLightMode") : #imageLiteral(resourceName: "FilledCircleCheckDarkMode")
case .failed: image = #imageLiteral(resourceName: "message_status_failed").asTintedImage(color: Colors.destructive)!
}
return (image, backgroundColor)
}
private func getSize(for viewItem: ConversationViewItem) -> CGSize {
guard let albumItems = viewItem.mediaAlbumItems else { preconditionFailure() }
let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: viewItem)
let defaultSize = MediaAlbumView.layoutSize(forMaxMessageWidth: maxMessageWidth, items: albumItems)
guard albumItems.count == 1 else { return defaultSize }
// Honor the content aspect ratio for single media
let albumItem = albumItems.first!
let size = albumItem.mediaSize
guard size.width > 0 && size.height > 0 else { return defaultSize }
var aspectRatio = (size.width / size.height)
// Clamp the aspect ratio so that very thin/wide content still looks alright
let minAspectRatio: CGFloat = 0.35
let maxAspectRatio = 1 / minAspectRatio
aspectRatio = aspectRatio.clamp(minAspectRatio, maxAspectRatio)
let maxSize = CGSize(width: maxMessageWidth, height: maxMessageWidth)
var width = with(maxSize.height * aspectRatio) { $0 > maxSize.width ? maxSize.width : $0 }
var height = (width > maxSize.width) ? (maxSize.width / aspectRatio) : maxSize.height
// Don't blow up small images unnecessarily
let minSize: CGFloat = 150
let shortSourceDimension = min(size.width, size.height)
let shortDestinationDimension = min(width, height)
if shortDestinationDimension > minSize && shortDestinationDimension > shortSourceDimension {
let factor = minSize / shortDestinationDimension
width *= factor; height *= factor
}
return CGSize(width: width, height: height)
}
static func getMaxWidth(for viewItem: ConversationViewItem) -> CGFloat {
let screen = UIScreen.main.bounds
switch viewItem.interaction.interactionType() {
case .outgoingMessage: return screen.width - contactThreadHSpacing - gutterSize
case .incomingMessage:
let leftGutterSize = shouldShowProfilePicture(for: viewItem) ? gutterSize : contactThreadHSpacing
return screen.width - leftGutterSize - gutterSize
default: preconditionFailure()
}
}
private static func shouldShowProfilePicture(for viewItem: ConversationViewItem) -> Bool {
guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() }
let isGroupThread = message.thread.isGroupThread()
let senderSessionID = (message as? TSIncomingMessage)?.authorId
return isGroupThread && viewItem.shouldShowSenderProfilePicture && senderSessionID != nil
}
static func getBodyTextView(for viewItem: ConversationViewItem, with availableWidth: CGFloat, textColor: UIColor, searchText: String?, delegate: UITextViewDelegate & BodyTextViewDelegate) -> UITextView {
guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() }
let isOutgoing = (message.interactionType() == .outgoingMessage)
let result = BodyTextView(snDelegate: delegate)
result.isEditable = false
let attributes: [NSAttributedString.Key:Any] = [
.foregroundColor : textColor,
.font : UIFont.systemFont(ofSize: getFontSize(for: viewItem))
]
let attributedText = NSMutableAttributedString(attributedString: MentionUtilities.highlightMentions(in: message.body ?? "", isOutgoingMessage: isOutgoing, threadID: viewItem.interaction.uniqueThreadId, attributes: attributes))
if let searchText = searchText, searchText.count >= ConversationSearchController.kMinimumSearchTextLength {
let normalizedSearchText = FullTextSearchFinder.normalize(text: searchText)
do {
let regex = try NSRegularExpression(pattern: NSRegularExpression.escapedPattern(for: normalizedSearchText), options: .caseInsensitive)
let matches = regex.matches(in: attributedText.string, options: .withoutAnchoringBounds, range: NSRange(location: 0, length: (attributedText.string as NSString).length))
for match in matches {
guard match.range.location + match.range.length < attributedText.length else { continue }
attributedText.addAttribute(.backgroundColor, value: UIColor.white, range: match.range)
attributedText.addAttribute(.foregroundColor, value: UIColor.black, range: match.range)
}
} catch {
// Do nothing
}
}
result.attributedText = attributedText
result.dataDetectorTypes = .link
result.backgroundColor = .clear
result.isOpaque = false
result.textContainerInset = UIEdgeInsets.zero
result.contentInset = UIEdgeInsets.zero
result.textContainer.lineFragmentPadding = 0
result.isScrollEnabled = false
result.isUserInteractionEnabled = true
result.delegate = delegate
result.linkTextAttributes = [ .foregroundColor : textColor, .underlineStyle : NSUnderlineStyle.single.rawValue ]
let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude)
let size = result.sizeThatFits(availableSpace)
result.set(.height, to: size.height)
return result
}
}

View File

@ -1,202 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
import Foundation
@objc
protocol MessageActionsDelegate: class {
func banUser(_ conversationViewItem: ConversationViewItem)
func messageActionsShowDetailsForItem(_ conversationViewItem: ConversationViewItem)
func messageActionsReplyToItem(_ conversationViewItem: ConversationViewItem)
func copyPublicKey(for conversationViewItem: ConversationViewItem)
}
struct MessageActionBuilder {
static func reply(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction {
return MenuAction(image: #imageLiteral(resourceName: "ic_reply"),
title: NSLocalizedString("MESSAGE_ACTION_REPLY", comment: "Action sheet button title"),
subtitle: nil,
block: { [weak delegate] _ in delegate?.messageActionsReplyToItem(conversationViewItem) }
)
}
static func copyText(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction {
return MenuAction(image: #imageLiteral(resourceName: "ic_copy"),
title: NSLocalizedString("MESSAGE_ACTION_COPY_TEXT", comment: "Action sheet button title"),
subtitle: nil,
block: { _ in conversationViewItem.copyTextAction() }
)
}
static func copyPublicKey(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction {
return MenuAction(image: #imageLiteral(resourceName: "Key").scaled(to: CGSize(width: 24, height: 24)),
title: NSLocalizedString("Copy Session ID", comment: ""),
subtitle: nil,
block: { [weak delegate] _ in delegate?.copyPublicKey(for: conversationViewItem) }
)
}
static func showDetails(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction {
return MenuAction(image: #imageLiteral(resourceName: "ic_info"),
title: NSLocalizedString("MESSAGE_ACTION_DETAILS", comment: "Action sheet button title"),
subtitle: nil,
block: { [weak delegate] _ in delegate?.messageActionsShowDetailsForItem(conversationViewItem) }
)
}
static func deleteMessage(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction {
return MenuAction(image: #imageLiteral(resourceName: "ic_trash"),
title: NSLocalizedString("MESSAGE_ACTION_DELETE_MESSAGE", comment: "Action sheet button title"),
subtitle: nil,
block: { _ in conversationViewItem.deleteAction() }
)
}
static func banUser(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction {
return MenuAction(image: #imageLiteral(resourceName: "ic_block"),
title: "Ban User",
subtitle: nil,
block: { [weak delegate] _ in delegate?.banUser(conversationViewItem) }
)
}
static func copyMedia(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction {
return MenuAction(image: #imageLiteral(resourceName: "ic_copy"),
title: NSLocalizedString("MESSAGE_ACTION_COPY_MEDIA", comment: "Action sheet button title"),
subtitle: nil,
block: { _ in conversationViewItem.copyMediaAction() }
)
}
static func saveMedia(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> MenuAction {
return MenuAction(image: #imageLiteral(resourceName: "ic_download"),
title: NSLocalizedString("MESSAGE_ACTION_SAVE_MEDIA", comment: "Action sheet button title"),
subtitle: nil,
block: { _ in conversationViewItem.saveMediaAction() }
)
}
}
@objc
class ConversationViewItemActions: NSObject {
@objc
class func textActions(conversationViewItem: ConversationViewItem, shouldAllowReply: Bool, delegate: MessageActionsDelegate) -> [MenuAction] {
var actions: [MenuAction] = []
let isGroup = conversationViewItem.isGroupThread;
if shouldAllowReply {
let replyAction = MessageActionBuilder.reply(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(replyAction)
}
if conversationViewItem.hasBodyTextActionContent {
let copyTextAction = MessageActionBuilder.copyText(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(copyTextAction)
}
if isGroup && conversationViewItem.interaction is TSIncomingMessage {
let copyPublicKeyAction = MessageActionBuilder.copyPublicKey(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(copyPublicKeyAction)
}
if !isGroup || conversationViewItem.userCanDeleteGroupMessage {
let deleteAction = MessageActionBuilder.deleteMessage(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(deleteAction)
}
if isGroup && conversationViewItem.interaction is TSIncomingMessage && conversationViewItem.userHasModerationPermission {
let banAction = MessageActionBuilder.banUser(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(banAction)
}
let showDetailsAction = MessageActionBuilder.showDetails(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(showDetailsAction)
return actions
}
@objc
class func mediaActions(conversationViewItem: ConversationViewItem, shouldAllowReply: Bool, delegate: MessageActionsDelegate) -> [MenuAction] {
var actions: [MenuAction] = []
let isGroup = conversationViewItem.isGroupThread;
if shouldAllowReply {
let replyAction = MessageActionBuilder.reply(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(replyAction)
}
if conversationViewItem.hasMediaActionContent {
if conversationViewItem.canCopyMedia() {
let copyMediaAction = MessageActionBuilder.copyMedia(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(copyMediaAction)
}
if conversationViewItem.canSaveMedia() {
let saveMediaAction = MessageActionBuilder.saveMedia(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(saveMediaAction)
}
}
if isGroup && conversationViewItem.interaction is TSIncomingMessage {
let copyPublicKeyAction = MessageActionBuilder.copyPublicKey(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(copyPublicKeyAction)
}
if !isGroup || conversationViewItem.userCanDeleteGroupMessage {
let deleteAction = MessageActionBuilder.deleteMessage(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(deleteAction)
}
if isGroup && conversationViewItem.interaction is TSIncomingMessage && conversationViewItem.userHasModerationPermission {
let banAction = MessageActionBuilder.banUser(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(banAction)
}
let showDetailsAction = MessageActionBuilder.showDetails(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(showDetailsAction)
return actions
}
@objc
class func quotedMessageActions(conversationViewItem: ConversationViewItem, shouldAllowReply: Bool, delegate: MessageActionsDelegate) -> [MenuAction] {
var actions: [MenuAction] = []
if shouldAllowReply {
let replyAction = MessageActionBuilder.reply(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(replyAction)
}
let isGroup = conversationViewItem.isGroupThread;
if isGroup && conversationViewItem.interaction is TSIncomingMessage {
let copyPublicKeyAction = MessageActionBuilder.copyPublicKey(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(copyPublicKeyAction)
}
if !isGroup || conversationViewItem.userCanDeleteGroupMessage {
let deleteAction = MessageActionBuilder.deleteMessage(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(deleteAction)
}
if isGroup && conversationViewItem.interaction is TSIncomingMessage && conversationViewItem.userHasModerationPermission {
let banAction = MessageActionBuilder.banUser(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(banAction)
}
let showDetailsAction = MessageActionBuilder.showDetails(conversationViewItem: conversationViewItem, delegate: delegate)
actions.append(showDetailsAction)
return actions
}
@objc
class func infoMessageActions(conversationViewItem: ConversationViewItem, delegate: MessageActionsDelegate) -> [MenuAction] {
let deleteAction = MessageActionBuilder.deleteMessage(conversationViewItem: conversationViewItem, delegate: delegate)
return [deleteAction ]
}
}

View File

@ -1,728 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
import SignalUtilitiesKit
import SignalUtilitiesKit
@objc
enum MessageMetadataViewMode: UInt {
case focusOnMessage
case focusOnMetadata
}
@objc
protocol MessageDetailViewDelegate: AnyObject {
func detailViewMessageWasDeleted(_ messageDetailViewController: MessageDetailViewController)
}
@objc
class MessageDetailViewController: OWSViewController, MediaGalleryDataSourceDelegate, OWSMessageBubbleViewDelegate {
@objc
weak var delegate: MessageDetailViewDelegate?
// MARK: Properties
let uiDatabaseConnection: YapDatabaseConnection
var bubbleView: UIView?
let mode: MessageMetadataViewMode
let viewItem: ConversationViewItem
var message: TSMessage
var wasDeleted: Bool = false
var messageBubbleView: OWSMessageBubbleView?
var messageBubbleViewWidthLayoutConstraint: NSLayoutConstraint?
var messageBubbleViewHeightLayoutConstraint: NSLayoutConstraint?
var scrollView: UIScrollView!
var contentView: UIView?
var attachment: TSAttachment?
var dataSource: DataSource?
var attachmentStream: TSAttachmentStream?
var messageBody: String?
lazy var shouldShowUD: Bool = {
return self.preferences.shouldShowUnidentifiedDeliveryIndicators()
}()
var conversationStyle: ConversationStyle
// MARK: Dependencies
var preferences: OWSPreferences {
return Environment.shared.preferences
}
// MARK: Initializers
@available(*, unavailable, message:"use other constructor instead.")
required init?(coder aDecoder: NSCoder) {
notImplemented()
}
@objc
required init(viewItem: ConversationViewItem, message: TSMessage, thread: TSThread, mode: MessageMetadataViewMode) {
self.viewItem = viewItem
self.message = message
self.mode = mode
self.uiDatabaseConnection = OWSPrimaryStorage.shared().uiDatabaseConnection
self.conversationStyle = ConversationStyle(thread: thread)
super.init(nibName: nil, bundle: nil)
}
// MARK: View Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
do {
try updateMessageToLatest()
} catch DetailViewError.messageWasDeleted {
self.delegate?.detailViewMessageWasDeleted(self)
} catch {
owsFailDebug("unexpected error")
}
self.conversationStyle.viewWidth = view.width()
ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: NSLocalizedString("MESSAGE_METADATA_VIEW_TITLE", comment: "Title for the 'message metadata' view."), hasCustomBackButton: false)
createViews()
self.view.layoutIfNeeded()
NotificationCenter.default.addObserver(self,
selector: #selector(uiDatabaseDidUpdate),
name: .OWSUIDatabaseConnectionDidUpdate,
object: OWSPrimaryStorage.shared().dbNotificationObject)
}
override public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
Logger.debug("")
super.viewWillTransition(to: size, with: coordinator)
self.conversationStyle.viewWidth = size.width
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
updateMessageBubbleViewLayout()
if mode == .focusOnMetadata {
if let bubbleView = self.bubbleView {
// Force layout.
view.setNeedsLayout()
view.layoutIfNeeded()
let contentHeight = scrollView.contentSize.height
let scrollViewHeight = scrollView.frame.size.height
guard contentHeight >= scrollViewHeight else {
// All content is visible within the scroll view. No need to offset.
return
}
// We want to include at least a little portion of the message, but scroll no farther than necessary.
let showAtLeast: CGFloat = 50
let bubbleViewBottom = bubbleView.superview!.convert(bubbleView.frame, to: scrollView).maxY
let maxOffset = bubbleViewBottom - showAtLeast
let lastPage = contentHeight - scrollViewHeight
let offset = CGPoint(x: 0, y: min(maxOffset, lastPage))
scrollView.setContentOffset(offset, animated: false)
}
}
}
// MARK: - Create Views
private func createViews() {
view.backgroundColor = .clear
let scrollView = UIScrollView()
self.scrollView = scrollView
view.addSubview(scrollView)
scrollView.autoPinWidthToSuperview(withMargin: 0)
if scrollView.applyInsetsFix() {
scrollView.autoPinEdge(.top, to: .top, of: view)
} else {
scrollView.autoPinEdge(toSuperviewEdge: .top)
}
let contentView = UIView.container()
self.contentView = contentView
scrollView.addSubview(contentView)
contentView.autoPinLeadingToSuperviewMargin()
contentView.autoPinTrailingToSuperviewMargin()
contentView.autoPinEdge(toSuperviewEdge: .top)
contentView.autoPinEdge(toSuperviewEdge: .bottom)
scrollView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
scrollView.contentInset = UIEdgeInsets(top: 20, left: 0, bottom: 20, right: 0)
if hasMediaAttachment {
let footer = UIToolbar()
view.addSubview(footer)
footer.autoPinWidthToSuperview(withMargin: 0)
footer.autoPinEdge(.top, to: .bottom, of: scrollView)
footer.autoPinEdge(.bottom, to: .bottom, of: view)
footer.items = [
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(shareButtonPressed)),
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
]
} else {
scrollView.autoPinEdge(toSuperviewEdge: .bottom)
}
updateContent()
}
lazy var thread: TSThread = {
var thread: TSThread?
self.uiDatabaseConnection.read { transaction in
thread = self.message.thread(with: transaction)
}
return thread!
}()
private func updateContent() {
guard let contentView = contentView else {
owsFailDebug("Missing contentView")
return
}
// Remove any existing content views.
for subview in contentView.subviews {
subview.removeFromSuperview()
}
var rows = [UIView]()
// Content
rows += contentRows()
// Sender?
if let incomingMessage = message as? TSIncomingMessage {
let senderId = incomingMessage.authorId
let threadID = thread.uniqueId!
var senderName: String!
Storage.writeSync { transaction in
senderName = DisplayNameUtilities2.getDisplayName(for: senderId, inThreadWithID: threadID, using: transaction)
}
rows.append(valueRow(name: NSLocalizedString("MESSAGE_METADATA_VIEW_SENDER",
comment: "Label for the 'sender' field of the 'message metadata' view."),
value: senderName))
}
// Recipient(s)
if let outgoingMessage = message as? TSOutgoingMessage {
func getSeparator() -> UIView {
let result = UIView()
result.set(.height, to: Values.separatorThickness)
result.backgroundColor = Colors.separator
return result
}
if !outgoingMessage.recipientIds().isEmpty {
rows += [ getSeparator() ]
}
rows += outgoingMessage.recipientIds().flatMap { publicKey -> [UIView] in
// We use ContactCellView, not ContactTableViewCell.
// Table view cells don't layout properly outside the
// context of a table view.
let cellView = ContactCellView()
cellView.configure(withRecipientId: publicKey)
let wrapper = UIView()
wrapper.layoutMargins = UIEdgeInsets(top: 8, left: 20, bottom: 8, right: 20)
wrapper.addSubview(cellView)
cellView.autoPinEdgesToSuperviewMargins()
return [ wrapper, getSeparator() ]
}
if !outgoingMessage.recipientIds().isEmpty {
rows += [ UIView.vSpacer(10) ]
}
}
let sentText = DateUtil.formatPastTimestampRelativeToNow(message.timestamp)
let sentRow: UIStackView = valueRow(name: NSLocalizedString("MESSAGE_METADATA_VIEW_SENT_DATE_TIME",
comment: "Label for the 'sent date & time' field of the 'message metadata' view."),
value: sentText)
if let incomingMessage = message as? TSIncomingMessage {
if self.shouldShowUD, incomingMessage.wasReceivedByUD {
let icon = #imageLiteral(resourceName: "ic_secret_sender_indicator").withRenderingMode(.alwaysTemplate)
let iconView = UIImageView(image: icon)
iconView.tintColor = Theme.secondaryColor
iconView.setContentHuggingHigh()
sentRow.addArrangedSubview(iconView)
// keep the icon close to the label.
let spacerView = UIView()
spacerView.setContentHuggingLow()
sentRow.addArrangedSubview(spacerView)
}
}
sentRow.isUserInteractionEnabled = true
sentRow.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(didLongPressSent)))
rows.append(sentRow)
if message is TSIncomingMessage {
rows.append(valueRow(name: NSLocalizedString("MESSAGE_METADATA_VIEW_RECEIVED_DATE_TIME",
comment: "Label for the 'received date & time' field of the 'message metadata' view."),
value: DateUtil.formatPastTimestampRelativeToNow(message.receivedAtTimestamp)))
}
rows += addAttachmentMetadataRows()
// TODO: We could include the "disappearing messages" state here.
let rowStack = UIStackView(arrangedSubviews: rows)
rowStack.axis = .vertical
rowStack.spacing = 5
contentView.addSubview(rowStack)
rowStack.autoPinEdgesToSuperviewMargins()
contentView.layoutIfNeeded()
updateMessageBubbleViewLayout()
}
private func displayableTextIfText() -> String? {
guard viewItem.hasBodyText else {
return nil
}
guard let displayableText = viewItem.displayableBodyText else {
return nil
}
let messageBody = displayableText.fullText
guard messageBody.count > 0 else {
return nil
}
return messageBody
}
let bubbleViewHMargin: CGFloat = 10
private func contentRows() -> [UIView] {
var rows = [UIView]()
let messageBubbleView = OWSMessageBubbleView(frame: CGRect.zero)
messageBubbleView.delegate = self
messageBubbleView.addTapGestureHandler()
self.messageBubbleView = messageBubbleView
messageBubbleView.viewItem = viewItem
messageBubbleView.cellMediaCache = NSCache()
messageBubbleView.conversationStyle = conversationStyle
messageBubbleView.configureViews()
messageBubbleView.loadContent()
assert(messageBubbleView.isUserInteractionEnabled)
let row = UIView()
row.addSubview(messageBubbleView)
messageBubbleView.autoPinHeightToSuperview()
let isIncoming = self.message as? TSIncomingMessage != nil
messageBubbleView.autoPinEdge(toSuperviewEdge: isIncoming ? .leading : .trailing, withInset: bubbleViewHMargin)
self.messageBubbleViewWidthLayoutConstraint = messageBubbleView.autoSetDimension(.width, toSize: 0)
self.messageBubbleViewHeightLayoutConstraint = messageBubbleView.autoSetDimension(.height, toSize: 0)
rows.append(row)
if rows.isEmpty {
// Neither attachment nor body.
owsFailDebug("Message has neither attachment nor body.")
rows.append(valueRow(name: NSLocalizedString("MESSAGE_METADATA_VIEW_NO_ATTACHMENT_OR_BODY",
comment: "Label for messages without a body or attachment in the 'message metadata' view."),
value: ""))
}
let spacer = UIView()
spacer.autoSetDimension(.height, toSize: 15)
rows.append(spacer)
return rows
}
private func fetchAttachment(transaction: YapDatabaseReadTransaction) -> TSAttachment? {
// TODO: Support multi-image messages.
guard let attachmentId = message.attachmentIds.firstObject as? String else {
return nil
}
guard let attachment = TSAttachment.fetch(uniqueId: attachmentId, transaction: transaction) else {
Logger.warn("Missing attachment. Was it deleted?")
return nil
}
return attachment
}
var hasMediaAttachment: Bool {
guard let attachment = self.attachment else {
return false
}
guard attachment.contentType != OWSMimeTypeOversizeTextMessage else {
// to the user, oversized text attachments should behave
// just like regular text messages.
return false
}
return true
}
private func addAttachmentMetadataRows() -> [UIView] {
guard hasMediaAttachment else {
return []
}
var rows = [UIView]()
if let attachment = self.attachment {
// Only show MIME types in DEBUG builds.
if _isDebugAssertConfiguration() {
let contentType = attachment.contentType
rows.append(valueRow(name: NSLocalizedString("MESSAGE_METADATA_VIEW_ATTACHMENT_MIME_TYPE",
comment: "Label for the MIME type of attachments in the 'message metadata' view."),
value: contentType))
}
if let sourceFilename = attachment.sourceFilename {
rows.append(valueRow(name: NSLocalizedString("MESSAGE_METADATA_VIEW_SOURCE_FILENAME",
comment: "Label for the original filename of any attachment in the 'message metadata' view."),
value: sourceFilename))
}
}
if let dataSource = self.dataSource {
let fileSize = dataSource.dataLength()
rows.append(valueRow(name: NSLocalizedString("MESSAGE_METADATA_VIEW_ATTACHMENT_FILE_SIZE",
comment: "Label for file size of attachments in the 'message metadata' view."),
value: OWSFormat.formatFileSize(UInt(fileSize))))
}
return rows
}
private func buildUDAccessoryView(text: String) -> UIView {
let label = UILabel()
label.textColor = Theme.secondaryColor
label.text = text
label.textAlignment = .right
label.font = UIFont.ows_mediumFont(withSize: 13)
let image = #imageLiteral(resourceName: "ic_secret_sender_indicator").withRenderingMode(.alwaysTemplate)
let imageView = UIImageView(image: image)
imageView.tintColor = Theme.middleGrayColor
let hStack = UIStackView(arrangedSubviews: [imageView, label])
hStack.axis = .horizontal
hStack.spacing = 8
return hStack
}
private func nameLabel(text: String) -> UILabel {
let label = UILabel()
label.textColor = Theme.primaryColor
label.font = UIFont.ows_mediumFont(withSize: 14)
label.text = text
label.setContentHuggingHorizontalHigh()
return label
}
private func valueLabel(text: String) -> UILabel {
let label = UILabel()
label.textColor = Theme.primaryColor
label.font = UIFont.ows_regularFont(withSize: 14)
label.text = text
label.setContentHuggingHorizontalLow()
return label
}
private func valueRow(name: String, value: String, subtitle: String = "") -> UIStackView {
let nameLabel = self.nameLabel(text: name)
let valueLabel = self.valueLabel(text: value)
let hStackView = UIStackView(arrangedSubviews: [nameLabel, valueLabel])
hStackView.axis = .horizontal
hStackView.spacing = 10
hStackView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20)
hStackView.isLayoutMarginsRelativeArrangement = true
if subtitle.count > 0 {
let subtitleLabel = self.valueLabel(text: subtitle)
subtitleLabel.textColor = Theme.secondaryColor
hStackView.addArrangedSubview(subtitleLabel)
}
return hStackView
}
// MARK: - Actions
@objc func shareButtonPressed() {
guard let attachmentStream = attachmentStream else {
Logger.error("Share button should only be shown with attachment, but no attachment found.")
return
}
AttachmentSharing.showShareUI(forAttachment: attachmentStream)
}
// MARK: - Actions
enum DetailViewError: Error {
case messageWasDeleted
}
// This method should be called after self.databaseConnection.beginLongLivedReadTransaction().
private func updateMessageToLatest() throws {
AssertIsOnMainThread()
try self.uiDatabaseConnection.read { transaction in
guard let uniqueId = self.message.uniqueId else {
Logger.error("Message is missing uniqueId.")
return
}
guard let newMessage = TSInteraction.fetch(uniqueId: uniqueId, transaction: transaction) as? TSMessage else {
Logger.error("Message was deleted")
throw DetailViewError.messageWasDeleted
}
self.message = newMessage
self.attachment = self.fetchAttachment(transaction: transaction)
self.attachmentStream = self.attachment as? TSAttachmentStream
}
}
@objc internal func uiDatabaseDidUpdate(notification: NSNotification) {
AssertIsOnMainThread()
guard !wasDeleted else {
// Item was deleted in the tile view gallery.
// Don't bother re-rendering, it will fail and we'll soon be dismissed.
return
}
guard let notifications = notification.userInfo?[OWSUIDatabaseConnectionNotificationsKey] as? [Notification] else {
owsFailDebug("notifications was unexpectedly nil")
return
}
guard let uniqueId = self.message.uniqueId else {
Logger.error("Message is missing uniqueId.")
return
}
guard self.uiDatabaseConnection.hasChange(forKey: uniqueId,
inCollection: TSInteraction.collection(),
in: notifications) else {
Logger.debug("No relevant changes.")
return
}
do {
try updateMessageToLatest()
} catch DetailViewError.messageWasDeleted {
DispatchQueue.main.async {
self.delegate?.detailViewMessageWasDeleted(self)
}
return
} catch {
owsFailDebug("unexpected error: \(error)")
}
updateContent()
}
private func string(for messageReceiptStatus: MessageReceiptStatus) -> String {
switch messageReceiptStatus {
case .uploading:
return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_UPLOADING",
comment: "Status label for messages which are uploading.")
case .sending:
return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_SENDING",
comment: "Status label for messages which are sending.")
case .sent:
return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_SENT",
comment: "Status label for messages which are sent.")
case .delivered:
return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_DELIVERED",
comment: "Status label for messages which are delivered.")
case .read:
return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_READ",
comment: "Status label for messages which are read.")
case .failed:
return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_FAILED",
comment: "Status label for messages which are failed.")
case .skipped:
return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_SKIPPED",
comment: "Status label for messages which were skipped.")
}
}
// MARK: - Message Bubble Layout
private func updateMessageBubbleViewLayout() {
guard let messageBubbleView = messageBubbleView else {
return
}
guard let messageBubbleViewWidthLayoutConstraint = messageBubbleViewWidthLayoutConstraint else {
return
}
guard let messageBubbleViewHeightLayoutConstraint = messageBubbleViewHeightLayoutConstraint else {
return
}
let messageBubbleSize = messageBubbleView.measureSize()
messageBubbleViewWidthLayoutConstraint.constant = messageBubbleSize.width
messageBubbleViewHeightLayoutConstraint.constant = messageBubbleSize.height
}
// MARK: OWSMessageBubbleViewDelegate
func didTapImageViewItem(_ viewItem: ConversationViewItem, attachmentStream: TSAttachmentStream, imageView: UIView) {
let mediaGallery = MediaGallery(thread: self.thread)
mediaGallery.addDataSourceDelegate(self)
mediaGallery.presentDetailView(fromViewController: self, mediaAttachment: attachmentStream, replacingView: imageView)
}
func didTapVideoViewItem(_ viewItem: ConversationViewItem, attachmentStream: TSAttachmentStream, imageView: UIView) {
let mediaGallery = MediaGallery(thread: self.thread)
mediaGallery.addDataSourceDelegate(self)
mediaGallery.presentDetailView(fromViewController: self, mediaAttachment: attachmentStream, replacingView: imageView)
}
var audioAttachmentPlayer: OWSAudioPlayer?
func didTapAudioViewItem(_ viewItem: ConversationViewItem, attachmentStream: TSAttachmentStream) {
AssertIsOnMainThread()
guard let mediaURL = attachmentStream.originalMediaURL else {
owsFailDebug("mediaURL was unexpectedly nil for attachment: \(attachmentStream)")
return
}
guard FileManager.default.fileExists(atPath: mediaURL.path) else {
owsFailDebug("audio file missing at path: \(mediaURL)")
return
}
if let audioAttachmentPlayer = self.audioAttachmentPlayer {
// Is this player associated with this media adapter?
if audioAttachmentPlayer.owner === viewItem {
// Tap to pause & unpause.
audioAttachmentPlayer.togglePlayState()
return
}
audioAttachmentPlayer.stop()
self.audioAttachmentPlayer = nil
}
let audioAttachmentPlayer = OWSAudioPlayer(mediaUrl: mediaURL, audioBehavior: .audioMessagePlayback, delegate: viewItem)
self.audioAttachmentPlayer = audioAttachmentPlayer
// Associate the player with this media adapter.
audioAttachmentPlayer.owner = viewItem
audioAttachmentPlayer.play()
}
func didPanAudioViewItem(toCurrentTime currentTime: TimeInterval) {
// TODO: Implement
}
func didTapTruncatedTextMessage(_ conversationItem: ConversationViewItem) {
guard let navigationController = self.navigationController else {
owsFailDebug("navigationController was unexpectedly nil")
return
}
let viewController = LongTextViewController(viewItem: viewItem)
viewController.delegate = self
navigationController.pushViewController(viewController, animated: true)
}
func didTapFailedIncomingAttachment(_ viewItem: ConversationViewItem) {
// no - op
}
func didTapFailedOutgoingMessage(_ message: TSOutgoingMessage) {
// no - op
}
func didTapConversationItem(_ viewItem: ConversationViewItem, quotedReply: OWSQuotedReplyModel) {
// no - op
}
func didTapConversationItem(_ viewItem: ConversationViewItem, quotedReply: OWSQuotedReplyModel, failedThumbnailDownloadAttachmentPointer attachmentPointer: TSAttachmentPointer) {
// no - op
}
func didTapConversationItem(_ viewItem: ConversationViewItem, linkPreview: OWSLinkPreview) {
guard let urlString = linkPreview.urlString else {
owsFailDebug("Missing url.")
return
}
guard let url = URL(string: urlString) else {
owsFailDebug("Invalid url: \(urlString).")
return
}
UIApplication.shared.openURL(url)
}
@objc func didLongPressSent(sender: UIGestureRecognizer) {
guard sender.state == .began else {
return
}
let messageTimestamp = "\(message.timestamp)"
UIPasteboard.general.string = messageTimestamp
}
var lastSearchedText: String? {
return nil
}
// MediaGalleryDataSourceDelegate
func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject) {
Logger.info("")
guard (items.map({ $0.message }) == [self.message]) else {
// Should only be one message we can delete when viewing message details
owsFailDebug("Unexpectedly informed of irrelevant message deletion")
return
}
self.wasDeleted = true
}
func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, deletedSections: IndexSet, deletedItems: [IndexPath]) {
self.dismiss(animated: true) {
self.navigationController?.popViewController(animated: true)
}
}
// MARK: - ContactShareViewHelperDelegate
public func didCreateOrEditContact() {
updateContent()
self.dismiss(animated: true)
}
}
extension MessageDetailViewController: LongTextViewDelegate {
func longTextViewMessageWasDeleted(_ longTextViewController: LongTextViewController) {
self.delegate?.detailViewMessageWasDeleted(self)
}
}

View File

@ -4,19 +4,14 @@
#import "OWSConversationSettingsViewController.h"
#import "BlockListUIUtils.h"
#import "OWSBlockingManager.h"
#import "OWSSoundSettingsViewController.h"
#import "Session-Swift.h"
#import "UIFont+OWS.h"
#import "UIView+OWS.h"
#import <Curve25519Kit/Curve25519.h>
#import <SignalCoreKit/NSDate+OWS.h>
#import <SessionMessagingKit/Environment.h>
#import <SignalUtilitiesKit/OWSProfileManager.h>
#import <SessionMessagingKit/OWSSounds.h>
#import <SessionMessagingKit/OWSUserProfile.h>
@ -24,7 +19,6 @@
#import <SignalUtilitiesKit/UIUtil.h>
#import <SessionMessagingKit/OWSDisappearingConfigurationUpdateInfoMessage.h>
#import <SessionMessagingKit/OWSDisappearingMessagesConfiguration.h>
#import <SessionMessagingKit/OWSPrimaryStorage.h>
#import <SessionMessagingKit/TSGroupThread.h>
#import <SessionMessagingKit/TSOutgoingMessage.h>
@ -35,29 +29,19 @@
NS_ASSUME_NONNULL_BEGIN
//#define SHOW_COLOR_PICKER
CGFloat kIconViewLength = 24;
const CGFloat kIconViewLength = 24;
@interface OWSConversationSettingsViewController () <
#ifdef SHOW_COLOR_PICKER
ColorPickerDelegate,
#endif
OWSSheetViewControllerDelegate>
@interface OWSConversationSettingsViewController () <OWSSheetViewControllerDelegate>
@property (nonatomic) TSThread *thread;
@property (nonatomic) YapDatabaseConnection *uiDatabaseConnection;
@property (nonatomic, readonly) YapDatabaseConnection *editingDatabaseConnection;
@property (nonatomic) NSArray<NSNumber *> *disappearingMessagesDurations;
@property (nonatomic) OWSDisappearingMessagesConfiguration *disappearingMessagesConfiguration;
@property (nullable, nonatomic) MediaGallery *mediaGallery;
@property (nonatomic, readonly) ContactsViewHelper *contactsViewHelper;
@property (nonatomic, readonly) UIImageView *avatarView;
@property (nonatomic, readonly) UILabel *disappearingMessagesDurationLabel;
#ifdef SHOW_COLOR_PICKER
@property (nonatomic) OWSColorPicker *colorPicker;
#endif
@end
@ -242,11 +226,6 @@ const CGFloat kIconViewLength = 24;
[[OWSDisappearingMessagesConfiguration alloc] initDefaultWithThreadId:self.thread.uniqueId];
}
#ifdef SHOW_COLOR_PICKER
self.colorPicker = [[OWSColorPicker alloc] initWithThread:self.thread];
self.colorPicker.delegate = self;
#endif
[self updateTableContents];
NSString *title;
@ -259,18 +238,6 @@ const CGFloat kIconViewLength = 24;
self.tableView.backgroundColor = UIColor.clearColor;
}
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
if (self.showVerificationOnAppear) {
self.showVerificationOnAppear = NO;
if (self.isGroupThread) {
[self showGroupMembersView];
}
}
}
- (void)updateTableContents
{
OWSTableContents *contents = [OWSTableContents new];
@ -285,13 +252,7 @@ const CGFloat kIconViewLength = 24;
OWSTableSection *mainSection = [OWSTableSection new];
mainSection.customHeaderView = [self mainSectionHeader];
if (self.isGroupThread) {
mainSection.customHeaderHeight = @(147.f);
} else {
BOOL isSmallScreen = (UIScreen.mainScreen.bounds.size.height - 568) < 1;
mainSection.customHeaderHeight = isSmallScreen ? @(201.f) : @(208.f);
}
mainSection.customHeaderHeight = @(UITableViewAutomaticDimension);
if ([self.thread isKindOfClass:TSContactThread.class]) {
[mainSection addItem:[OWSTableItem
@ -450,29 +411,6 @@ const CGFloat kIconViewLength = 24;
actionBlock:nil]];
}
}
#ifdef SHOW_COLOR_PICKER
[mainSection
addItem:[OWSTableItem
itemWithCustomCellBlock:^{
OWSConversationSettingsViewController *strongSelf = weakSelf;
OWSCAssertDebug(strongSelf);
ConversationColorName colorName = strongSelf.thread.conversationColorName;
UIColor *currentColor =
[OWSConversationColor conversationColorOrDefaultForColorName:colorName].themeColor;
NSString *title = NSLocalizedString(@"CONVERSATION_SETTINGS_CONVERSATION_COLOR",
@"Label for table cell which leads to picking a new conversation color");
return [strongSelf
cellWithName:title
iconName:@"ic_color_palette"
disclosureIconColor:currentColor
accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(
OWSConversationSettingsViewController, @"conversation_color")];
}
actionBlock:^{
[weakSelf showColorPicker];
}]];
#endif
[contents addSection:mainSection];
@ -637,9 +575,6 @@ const CGFloat kIconViewLength = 24;
// Block Conversation section.
if (!isNoteToSelf && [self.thread isKindOfClass:TSContactThread.class]) {
mainSection.footerTitle = NSLocalizedString(
@"BLOCK_USER_BEHAVIOR_EXPLANATION", @"An explanation of the consequences of blocking another user.");
[mainSection addItem:[OWSTableItem
itemWithCustomCellBlock:^{
OWSConversationSettingsViewController *strongSelf = weakSelf;
@ -727,48 +662,18 @@ const CGFloat kIconViewLength = 24;
return cell;
}
static CGRect oldframe;
-(void)showProfilePicture:(UITapGestureRecognizer *)tapGesture
- (void)showProfilePicture:(UITapGestureRecognizer *)tapGesture
{
LKProfilePictureView *profilePictureView = (LKProfilePictureView *)tapGesture.view;
UIImage *image = [profilePictureView getProfilePicture];
if (image == nil) { return; }
UIWindow *window = [UIApplication sharedApplication].keyWindow;
UIView *backgroundView = [[UIView alloc]initWithFrame:CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, [UIScreen mainScreen].bounds.size.height)];
oldframe = [profilePictureView convertRect:profilePictureView.bounds toView:window];
backgroundView.backgroundColor = [UIColor blackColor];
backgroundView.alpha = 0;
UIImageView *imageView = [[UIImageView alloc] initWithFrame:oldframe];
imageView.image = image;
imageView.tag = 1;
imageView.layer.cornerRadius = [UIScreen mainScreen].bounds.size.width / 2;
imageView.layer.masksToBounds = true;
[backgroundView addSubview:imageView];
[window addSubview:backgroundView];
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc]initWithTarget:self action:@selector(hideImage:)];
[backgroundView addGestureRecognizer: tap];
[UIView animateWithDuration:0.25 animations:^{
imageView.frame = CGRectMake(0,([UIScreen mainScreen].bounds.size.height - oldframe.size.height * [UIScreen mainScreen].bounds.size.width / oldframe.size.width) / 2, [UIScreen mainScreen].bounds.size.width, oldframe.size.height * [UIScreen mainScreen].bounds.size.width / oldframe.size.width);
backgroundView.alpha = 1;
} completion:nil];
NSString *title = (self.threadName != nil && self.threadName.length > 0) ? self.threadName : @"Anonymous";
SNProfilePictureVC *profilePictureVC = [[SNProfilePictureVC alloc] initWithImage:image title:title];
UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:profilePictureVC];
navController.modalPresentationStyle = UIModalPresentationFullScreen;
[self presentViewController:navController animated:YES completion:nil];
}
-(void)hideImage:(UITapGestureRecognizer *)tap{
UIView *backgroundView = tap.view;
UIImageView *imageView = (UIImageView *)[tap.view viewWithTag:1];
[UIView animateWithDuration:0.25 animations:^{
imageView.frame = oldframe;
backgroundView.alpha = 0;
} completion:^(BOOL finished) {
[backgroundView removeFromSuperview];
}];
}
- (UIView *)mainSectionHeader
{
UITapGestureRecognizer *profilePictureTapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showProfilePicture:)];
@ -872,22 +777,6 @@ static CGRect oldframe;
#pragma mark - Actions
- (void)showShareProfileAlert
{
[self.profileManager presentAddThreadToProfileWhitelist:self.thread
fromViewController:self
success:^{
[self updateTableContents];
}];
}
- (void)showGroupMembersView
{
TSGroupThread *thread = (TSGroupThread *)self.thread;
LKGroupMembersVC *groupMembersVC = [[LKGroupMembersVC alloc] initWithThread:thread];
[self.navigationController pushViewController:groupMembersVC animated:YES];
}
- (void)editGroup
{
LKEditClosedGroupVC *editClosedGroupVC = [[LKEditClosedGroupVC alloc] initWithThreadID:self.thread.uniqueId];
@ -1206,44 +1095,6 @@ static CGRect oldframe;
}
}
#pragma mark - ColorPickerDelegate
#ifdef SHOW_COLOR_PICKER
- (void)showColorPicker
{
OWSSheetViewController *sheetViewController = self.colorPicker.sheetViewController;
sheetViewController.delegate = self;
[self presentViewController:sheetViewController
animated:YES
completion:^() {
OWSLogInfo(@"presented sheet view");
}];
}
- (void)colorPicker:(OWSColorPicker *)colorPicker
didPickConversationColor:(OWSConversationColor *_Nonnull)conversationColor
{
OWSLogDebug(@"picked color: %@", conversationColor.name);
[LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) {
[self.thread updateConversationColorName:conversationColor.name transaction:transaction];
}];
[self.contactsManager.avatarCache removeAllImages];
[self updateTableContents];
[self.conversationSettingsViewDelegate conversationColorWasUpdated];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
ConversationConfigurationSyncOperation *operation =
[[ConversationConfigurationSyncOperation alloc] initWithThread:self.thread];
OWSAssertDebug(operation.isReady);
[operation start];
});
}
#endif
#pragma mark - OWSSheetViewController
- (void)sheetViewControllerRequestedDismiss:(OWSSheetViewController *)sheetViewController

View File

@ -9,7 +9,6 @@ NS_ASSUME_NONNULL_BEGIN
@protocol OWSConversationSettingsViewDelegate <NSObject>
- (void)conversationColorWasUpdated;
- (void)groupWasUpdated:(TSGroupModel *)groupModel;
- (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController;

View File

@ -3,7 +3,6 @@
//
#import "OWSMessageTimerView.h"
#import "ConversationViewController.h"
#import "OWSMath.h"
#import "UIColor+OWS.h"
#import "UIView+OWS.h"

View File

@ -0,0 +1,48 @@
@objc(SNProfilePictureVC)
final class ProfilePictureVC : BaseVC {
private let image: UIImage
private let snTitle: String
@objc init(image: UIImage, title: String) {
self.image = image
self.snTitle = title
super.init(nibName: nil, bundle: nil)
}
override init(nibName: String?, bundle: Bundle?) {
preconditionFailure("Use init(image:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(coder:) instead.")
}
override func viewDidLoad() {
view.backgroundColor = .clear
setUpGradientBackground()
setUpNavBarStyle()
setNavBarTitle(snTitle)
// Close button
let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close))
closeButton.tintColor = Colors.text
navigationItem.leftBarButtonItem = closeButton
// Image view
let imageView = UIImageView(image: image)
let size = UIScreen.main.bounds.width - 2 * Values.largeSpacing
imageView.set(.width, to: size)
imageView.set(.height, to: size)
imageView.layer.cornerRadius = size / 2
imageView.layer.masksToBounds = true
view.addSubview(imageView)
imageView.center(in: view)
// Gesture recognizer
let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(close))
swipeGestureRecognizer.direction = .down
view.addGestureRecognizer(swipeGestureRecognizer)
}
@objc private func close() {
presentingViewController?.dismiss(animated: true, completion: nil)
}
}

View File

@ -1,23 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@class TSAttachmentStream;
// This entity is used to display upload progress for outgoing
// attachments in conversation view cells.
//
// During attachment uploads we want to:
//
// * Dim the media view using a mask layer.
// * Show and update a progress bar.
// * Disable any media view controls using a callback.
@interface AttachmentUploadView : UIView
- (instancetype)initWithAttachment:(TSAttachmentStream *)attachment;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,118 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "AttachmentUploadView.h"
#import "OWSBezierPathView.h"
#import "OWSProgressView.h"
#import <SignalUtilitiesKit/UIFont+OWS.h>
#import <SessionUtilitiesKit/UIView+OWS.h>
#import <SessionUtilitiesKit/AppContext.h>
#import <SessionMessagingKit/TSAttachmentStream.h>
NS_ASSUME_NONNULL_BEGIN
@interface AttachmentUploadView ()
@property (nonatomic) TSAttachmentStream *attachment;
@property (nonatomic) OWSProgressView *progressView;
@property (nonatomic) UILabel *progressLabel;
@property (nonatomic) BOOL isAttachmentReady;
@property (nonatomic) CGFloat lastProgress;
@end
#pragma mark -
@implementation AttachmentUploadView
- (instancetype)initWithAttachment:(TSAttachmentStream *)attachment
{
self = [super init];
if (self) {
OWSAssertDebug(attachment);
self.attachment = attachment;
[self createContents];
_isAttachmentReady = self.attachment.isUploaded;
[self ensureViewState];
}
return self;
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (void)createContents
{
// The progress view is white. It will only be shown
// while the mask layer is visible, so it will show up
// even against all-white attachments.
_progressView = [OWSProgressView new];
self.progressView.color = [UIColor whiteColor];
[self.progressView autoSetDimension:ALDimensionWidth toSize:80.f];
[self.progressView autoSetDimension:ALDimensionHeight toSize:6.f];
self.progressLabel = [UILabel new];
self.progressLabel.text = NSLocalizedString(
@"MESSAGE_METADATA_VIEW_MESSAGE_STATUS_UPLOADING", @"Status label for messages which are uploading.")
.localizedUppercaseString;
self.progressLabel.textColor = UIColor.whiteColor;
self.progressLabel.font = [UIFont ows_dynamicTypeCaption1Font];
self.progressLabel.textAlignment = NSTextAlignmentCenter;
UIStackView *stackView = [[UIStackView alloc] initWithArrangedSubviews:@[
self.progressView,
self.progressLabel,
]];
stackView.axis = UILayoutConstraintAxisVertical;
stackView.spacing = 4;
stackView.layoutMargins = UIEdgeInsetsMake(4, 4, 4, 4);
stackView.layoutMarginsRelativeArrangement = YES;
[self addSubview:stackView];
[stackView autoCenterInSuperview];
[stackView autoPinEdgeToSuperviewMargin:ALEdgeTop relation:NSLayoutRelationGreaterThanOrEqual];
[stackView autoPinEdgeToSuperviewMargin:ALEdgeBottom relation:NSLayoutRelationGreaterThanOrEqual];
[stackView autoPinEdgeToSuperviewMargin:ALEdgeLeading relation:NSLayoutRelationGreaterThanOrEqual];
[stackView autoPinEdgeToSuperviewMargin:ALEdgeTrailing relation:NSLayoutRelationGreaterThanOrEqual];
}
- (void)setIsAttachmentReady:(BOOL)isAttachmentReady
{
if (_isAttachmentReady == isAttachmentReady) {
return;
}
_isAttachmentReady = isAttachmentReady;
[self ensureViewState];
}
- (void)setLastProgress:(CGFloat)lastProgress
{
_lastProgress = lastProgress;
[self ensureViewState];
}
- (void)ensureViewState
{
BOOL isUploading = !self.isAttachmentReady && self.lastProgress != 0;
self.backgroundColor = (isUploading ? [UIColor colorWithWhite:0.f alpha:0.2f] : nil);
self.progressView.hidden = !isUploading;
self.progressLabel.hidden = !isUploading;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,81 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@class ConversationStyle;
@class ConversationViewCell;
@class OWSContactOffersInteraction;
@class OWSContactsManager;
@class TSAttachmentStream;
@class TSCall;
@class TSErrorMessage;
@class TSInteraction;
@class TSInvalidIdentityKeyErrorMessage;
@class TSMessage;
@class TSOutgoingMessage;
@class TSQuotedMessage;
@protocol ConversationViewItem;
@protocol ConversationViewCellDelegate <NSObject>
- (void)conversationCell:(ConversationViewCell *)cell
shouldAllowReply:(BOOL)shouldAllowReply
didLongpressTextViewItem:(id<ConversationViewItem>)viewItem;
- (void)conversationCell:(ConversationViewCell *)cell
shouldAllowReply:(BOOL)shouldAllowReply
didLongpressMediaViewItem:(id<ConversationViewItem>)viewItem;
- (void)conversationCell:(ConversationViewCell *)cell
shouldAllowReply:(BOOL)shouldAllowReply
didLongpressQuoteViewItem:(id<ConversationViewItem>)viewItem;
- (void)conversationCell:(ConversationViewCell *)cell
didLongpressSystemMessageViewItem:(id<ConversationViewItem>)viewItem;
#pragma mark - System Cell
- (void)tappedCorruptedMessage:(TSErrorMessage *)message;
- (void)resendGroupUpdateForErrorMessage:(TSErrorMessage *)message;
- (void)showConversationSettings;
#pragma mark - Caching
- (NSCache *)cellMediaCache;
#pragma mark - Messages
- (void)didTapFailedOutgoingMessage:(TSOutgoingMessage *)message;
@end
#pragma mark -
// TODO: Consider making this a protocol.
@interface ConversationViewCell : UICollectionViewCell
@property (nonatomic, nullable, weak) id<ConversationViewCellDelegate> delegate;
@property (nonatomic, nullable) id<ConversationViewItem> viewItem;
// Cells are prefetched but expensive cells (e.g. media) should only load
// when visible and unload when no longer visible. Non-visible cells can
// cache their contents on their ConversationViewItem, but that cache may
// be evacuated before the cell becomes visible again.
//
// ConversationViewController also uses this property to evacuate the cell's
// meda views when:
//
// * App enters background.
// * Users enters another view (e.g. conversation settings view, call screen, etc.).
@property (nonatomic) BOOL isCellVisible;
@property (nonatomic, nullable) ConversationStyle *conversationStyle;
- (void)loadForDisplay;
- (CGSize)cellSize;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,43 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "ConversationViewCell.h"
#import "ConversationViewItem.h"
NS_ASSUME_NONNULL_BEGIN
@implementation ConversationViewCell
- (void)prepareForReuse
{
[super prepareForReuse];
self.viewItem = nil;
self.delegate = nil;
self.isCellVisible = NO;
self.conversationStyle = nil;
}
- (void)loadForDisplay
{
OWSAbstractMethod();
}
- (CGSize)cellSize
{
OWSAbstractMethod();
return CGSizeZero;
}
// For perf reasons, skip the default implementation which is only relevant for self-sizing cells.
- (UICollectionViewLayoutAttributes *)preferredLayoutAttributesFittingAttributes:
(UICollectionViewLayoutAttributes *)layoutAttributes
{
return layoutAttributes;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,844 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
extension CGPoint {
public func offsetBy(dx: CGFloat) -> CGPoint {
return CGPoint(x: x + dx, y: y)
}
public func offsetBy(dy: CGFloat) -> CGPoint {
return CGPoint(x: x, y: y + dy)
}
}
// MARK: -
@objc
public enum LinkPreviewImageState: Int {
case none
case loading
case loaded
case invalid
}
// MARK: -
@objc
public protocol LinkPreviewState {
func isLoaded() -> Bool
func urlString() -> String?
func displayDomain() -> String?
func title() -> String?
func imageState() -> LinkPreviewImageState
func image() -> UIImage?
}
// MARK: -
@objc
public class LinkPreviewLoading: NSObject, LinkPreviewState {
override init() {
}
public func isLoaded() -> Bool {
return false
}
public func urlString() -> String? {
return nil
}
public func displayDomain() -> String? {
return nil
}
public func title() -> String? {
return nil
}
public func imageState() -> LinkPreviewImageState {
return .none
}
public func image() -> UIImage? {
return nil
}
}
// MARK: -
@objc
public class LinkPreviewDraft: NSObject, LinkPreviewState {
private let linkPreviewDraft: OWSLinkPreviewDraft
@objc
public required init(linkPreviewDraft: OWSLinkPreviewDraft) {
self.linkPreviewDraft = linkPreviewDraft
}
public func isLoaded() -> Bool {
return true
}
public func urlString() -> String? {
return linkPreviewDraft.urlString
}
public func displayDomain() -> String? {
guard let displayDomain = linkPreviewDraft.displayDomain() else {
owsFailDebug("Missing display domain")
return nil
}
return displayDomain
}
public func title() -> String? {
guard let value = linkPreviewDraft.title,
value.count > 0 else {
return nil
}
return value
}
public func imageState() -> LinkPreviewImageState {
if linkPreviewDraft.jpegImageData != nil {
return .loaded
} else {
return .none
}
}
public func image() -> UIImage? {
assert(imageState() == .loaded)
guard let jpegImageData = linkPreviewDraft.jpegImageData else {
return nil
}
guard let image = UIImage(data: jpegImageData) else {
owsFailDebug("Could not load image: \(jpegImageData.count)")
return nil
}
return image
}
}
// MARK: -
@objc
public class LinkPreviewSent: NSObject, LinkPreviewState {
private let linkPreview: OWSLinkPreview
private let imageAttachment: TSAttachment?
@objc public let conversationStyle: ConversationStyle
@objc
public var imageSize: CGSize {
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
return CGSize.zero
}
return attachmentStream.imageSize()
}
@objc
public required init(linkPreview: OWSLinkPreview,
imageAttachment: TSAttachment?,
conversationStyle: ConversationStyle) {
self.linkPreview = linkPreview
self.imageAttachment = imageAttachment
self.conversationStyle = conversationStyle
}
public func isLoaded() -> Bool {
return true
}
public func urlString() -> String? {
guard let urlString = linkPreview.urlString else {
owsFailDebug("Missing url")
return nil
}
return urlString
}
public func displayDomain() -> String? {
guard let displayDomain = linkPreview.displayDomain() else {
Logger.error("Missing display domain")
return nil
}
return displayDomain
}
public func title() -> String? {
guard let value = linkPreview.title,
value.count > 0 else {
return nil
}
return value
}
public func imageState() -> LinkPreviewImageState {
guard linkPreview.imageAttachmentId != nil else {
return .none
}
guard let imageAttachment = imageAttachment else {
owsFailDebug("Missing imageAttachment.")
return .none
}
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
return .loading
}
guard attachmentStream.isImage,
attachmentStream.isValidImage else {
return .invalid
}
return .loaded
}
public func image() -> UIImage? {
assert(imageState() == .loaded)
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
owsFailDebug("Could not load image.")
return nil
}
guard attachmentStream.isImage,
attachmentStream.isValidImage else {
return nil
}
guard let imageFilepath = attachmentStream.originalFilePath else {
owsFailDebug("Attachment is missing file path.")
return nil
}
guard let image = UIImage(contentsOfFile: imageFilepath) else {
owsFailDebug("Could not load image: \(imageFilepath)")
return nil
}
return image
}
}
// MARK: -
@objc
public protocol LinkPreviewViewDraftDelegate {
func linkPreviewCanCancel() -> Bool
func linkPreviewDidCancel()
}
// MARK: -
@objc
public class LinkPreviewImageView: UIImageView {
private let maskLayer = CAShapeLayer()
private let hasAsymmetricalRounding: Bool
@objc
public init(hasAsymmetricalRounding: Bool) {
self.hasAsymmetricalRounding = hasAsymmetricalRounding
super.init(frame: .zero)
self.layer.mask = maskLayer
}
public required init?(coder aDecoder: NSCoder) {
self.hasAsymmetricalRounding = false
super.init(coder: aDecoder)
}
public override var bounds: CGRect {
didSet {
updateMaskLayer()
}
}
public override var frame: CGRect {
didSet {
updateMaskLayer()
}
}
public override var center: CGPoint {
didSet {
updateMaskLayer()
}
}
private func updateMaskLayer() {
let layerBounds = self.bounds
// One of the corners has assymetrical rounding to match the input toolbar border.
// This is somewhat inconvenient.
let upperLeft = CGPoint(x: 0, y: 0)
let upperRight = CGPoint(x: layerBounds.size.width, y: 0)
let lowerRight = CGPoint(x: layerBounds.size.width, y: layerBounds.size.height)
let lowerLeft = CGPoint(x: 0, y: layerBounds.size.height)
let bigRounding: CGFloat = 14
let smallRounding: CGFloat = 4
let upperLeftRounding: CGFloat
let upperRightRounding: CGFloat
if hasAsymmetricalRounding {
upperLeftRounding = CurrentAppContext().isRTL ? smallRounding : bigRounding
upperRightRounding = CurrentAppContext().isRTL ? bigRounding : smallRounding
} else {
upperLeftRounding = smallRounding
upperRightRounding = smallRounding
}
let lowerRightRounding = smallRounding
let lowerLeftRounding = smallRounding
let path = UIBezierPath()
// It's sufficient to "draw" the rounded corners and not the edges that connect them.
path.addArc(withCenter: upperLeft.offsetBy(dx: +upperLeftRounding).offsetBy(dy: +upperLeftRounding),
radius: upperLeftRounding,
startAngle: CGFloat.pi * 1.0,
endAngle: CGFloat.pi * 1.5,
clockwise: true)
path.addArc(withCenter: upperRight.offsetBy(dx: -upperRightRounding).offsetBy(dy: +upperRightRounding),
radius: upperRightRounding,
startAngle: CGFloat.pi * 1.5,
endAngle: CGFloat.pi * 0.0,
clockwise: true)
path.addArc(withCenter: lowerRight.offsetBy(dx: -lowerRightRounding).offsetBy(dy: -lowerRightRounding),
radius: lowerRightRounding,
startAngle: CGFloat.pi * 0.0,
endAngle: CGFloat.pi * 0.5,
clockwise: true)
path.addArc(withCenter: lowerLeft.offsetBy(dx: +lowerLeftRounding).offsetBy(dy: -lowerLeftRounding),
radius: lowerLeftRounding,
startAngle: CGFloat.pi * 0.5,
endAngle: CGFloat.pi * 1.0,
clockwise: true)
maskLayer.path = path.cgPath
}
}
// MARK: -
@objc
public class LinkPreviewView: UIStackView {
private weak var draftDelegate: LinkPreviewViewDraftDelegate?
@objc
public var state: LinkPreviewState? {
didSet {
AssertIsOnMainThread()
assert(state == nil || oldValue == nil)
updateContents()
}
}
@objc
public var hasAsymmetricalRounding: Bool = false {
didSet {
AssertIsOnMainThread()
if hasAsymmetricalRounding != oldValue {
updateContents()
}
}
}
@available(*, unavailable, message:"use other constructor instead.")
required init(coder aDecoder: NSCoder) {
notImplemented()
}
@available(*, unavailable, message:"use other constructor instead.")
override init(frame: CGRect) {
notImplemented()
}
private var cancelButton: UIButton?
private weak var heroImageView: UIView?
private weak var sentBodyView: UIView?
private var layoutConstraints = [NSLayoutConstraint]()
@objc
public init(draftDelegate: LinkPreviewViewDraftDelegate?) {
self.draftDelegate = draftDelegate
super.init(frame: .zero)
if let draftDelegate = draftDelegate,
draftDelegate.linkPreviewCanCancel() {
self.isUserInteractionEnabled = true
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(wasTapped)))
}
}
private var isDraft: Bool {
return draftDelegate != nil
}
private func resetContents() {
for subview in subviews {
subview.removeFromSuperview()
}
self.axis = .horizontal
self.alignment = .center
self.distribution = .fill
self.spacing = 0
self.isLayoutMarginsRelativeArrangement = false
self.layoutMargins = .zero
cancelButton = nil
heroImageView = nil
sentBodyView = nil
NSLayoutConstraint.deactivate(layoutConstraints)
layoutConstraints = []
}
private func updateContents() {
resetContents()
guard let state = state else {
return
}
guard isDraft else {
createSentContents()
return
}
guard state.isLoaded() else {
createDraftLoadingContents()
return
}
createDraftContents(state: state)
}
private func createSentContents() {
guard let state = state as? LinkPreviewSent else {
owsFailDebug("Invalid state")
return
}
self.addBackgroundView(withBackgroundColor: Theme.backgroundColor)
if let imageView = createImageView(state: state) {
if sentIsHero(state: state) {
createHeroSentContents(state: state,
imageView: imageView)
} else {
createNonHeroSentContents(state: state,
imageView: imageView)
}
} else {
createNonHeroSentContents(state: state,
imageView: nil)
}
}
private func sentHeroImageSize(state: LinkPreviewSent) -> CGSize {
let maxMessageWidth = state.conversationStyle.maxMessageWidth
let imageSize = state.imageSize
let minImageHeight: CGFloat = maxMessageWidth * 0.5
let maxImageHeight: CGFloat = maxMessageWidth
let rawImageHeight = maxMessageWidth * imageSize.height / imageSize.width
let imageHeight: CGFloat = min(maxImageHeight, max(minImageHeight, rawImageHeight))
return CGSizeCeil(CGSize(width: maxMessageWidth, height: imageHeight))
}
private func createHeroSentContents(state: LinkPreviewSent,
imageView: UIImageView) {
self.layoutMargins = .zero
self.axis = .vertical
self.alignment = .fill
let heroImageSize = sentHeroImageSize(state: state)
imageView.autoSetDimensions(to: heroImageSize)
imageView.contentMode = .scaleAspectFill
imageView.setContentHuggingHigh()
imageView.setCompressionResistanceHigh()
imageView.clipsToBounds = true
// TODO: Cropping, stroke.
addArrangedSubview(imageView)
let textStack = createSentTextStack(state: state)
textStack.isLayoutMarginsRelativeArrangement = true
textStack.layoutMargins = UIEdgeInsets(top: sentHeroVMargin, left: sentHeroHMargin, bottom: sentHeroVMargin, right: sentHeroHMargin)
addArrangedSubview(textStack)
heroImageView = imageView
sentBodyView = textStack
}
private func createNonHeroSentContents(state: LinkPreviewSent,
imageView: UIImageView?) {
self.layoutMargins = .zero
self.axis = .horizontal
self.isLayoutMarginsRelativeArrangement = true
self.layoutMargins = UIEdgeInsets(top: sentNonHeroVMargin, left: sentNonHeroHMargin, bottom: sentNonHeroVMargin, right: sentNonHeroHMargin)
self.spacing = sentNonHeroHSpacing
if let imageView = imageView {
imageView.autoSetDimensions(to: CGSize(width: sentNonHeroImageSize, height: sentNonHeroImageSize))
imageView.contentMode = .scaleAspectFill
imageView.setContentHuggingHigh()
imageView.setCompressionResistanceHigh()
imageView.clipsToBounds = true
// TODO: Cropping, stroke.
addArrangedSubview(imageView)
}
let textStack = createSentTextStack(state: state)
addArrangedSubview(textStack)
sentBodyView = self
}
private func createSentTextStack(state: LinkPreviewSent) -> UIStackView {
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = sentVSpacing
if let titleLabel = sentTitleLabel(state: state) {
textStack.addArrangedSubview(titleLabel)
}
let domainLabel = sentDomainLabel(state: state)
textStack.addArrangedSubview(domainLabel)
return textStack
}
private let sentMinimumHeroSize: CGFloat = 200
private let sentTitleFontSizePoints: CGFloat = Values.mediumFontSize
private let sentDomainFontSizePoints: CGFloat = Values.verySmallFontSize
private let sentVSpacing: CGFloat = 4
// The "sent message" mode has two submodes: "hero" and "non-hero".
private let sentNonHeroHMargin: CGFloat = 12
private let sentNonHeroVMargin: CGFloat = 12
private let sentNonHeroImageSize: CGFloat = 72
private let sentNonHeroHSpacing: CGFloat = 8
private let sentHeroHMargin: CGFloat = 12
private let sentHeroVMargin: CGFloat = 12
private func sentIsHero(state: LinkPreviewSent) -> Bool {
let imageSize = state.imageSize
return imageSize.width >= sentMinimumHeroSize && imageSize.height >= sentMinimumHeroSize
}
private let sentTitleLineCount: Int = 2
private func sentTitleLabel(state: LinkPreviewState) -> UILabel? {
guard let text = state.title() else {
return nil
}
let label = UILabel()
label.text = text
label.font = UIFont.systemFont(ofSize: sentTitleFontSizePoints).ows_mediumWeight()
label.textColor = Theme.primaryColor
label.numberOfLines = sentTitleLineCount
label.lineBreakMode = .byWordWrapping
return label
}
private func sentDomainLabel(state: LinkPreviewState) -> UILabel {
let label = UILabel()
if let displayDomain = state.displayDomain(),
displayDomain.count > 0 {
label.text = displayDomain
} else {
label.text = NSLocalizedString("LINK_PREVIEW_UNKNOWN_DOMAIN", comment: "Label for link previews with an unknown host.")
}
label.font = UIFont.systemFont(ofSize: sentDomainFontSizePoints)
label.textColor = Theme.secondaryColor
return label
}
private let draftHeight: CGFloat = 72
private let draftMarginTop: CGFloat = 6
private func createDraftContents(state: LinkPreviewState) {
self.axis = .horizontal
self.alignment = .fill
self.distribution = .fill
self.spacing = 8
self.isLayoutMarginsRelativeArrangement = true
self.layoutConstraints.append(self.autoSetDimension(.height, toSize: draftHeight + draftMarginTop))
// Image
let draftImageView = createDraftImageView(state: state)
if let imageView = draftImageView {
imageView.contentMode = .scaleAspectFill
imageView.autoPinToSquareAspectRatio()
let imageSize = draftHeight
imageView.autoSetDimensions(to: CGSize(width: imageSize, height: imageSize))
imageView.setContentHuggingHigh()
imageView.setCompressionResistanceHigh()
imageView.clipsToBounds = true
addArrangedSubview(imageView)
}
let hasImage = draftImageView != nil
let hMarginLeading: CGFloat = hasImage ? 6 : 12
let hMarginTrailing: CGFloat = 12
self.layoutMargins = UIEdgeInsets(top: draftMarginTop,
leading: hMarginLeading,
bottom: 0,
trailing: hMarginTrailing)
// Right
let rightStack = UIStackView()
rightStack.axis = .horizontal
rightStack.alignment = .fill
rightStack.distribution = .equalSpacing
rightStack.spacing = 8
rightStack.setContentHuggingHorizontalLow()
rightStack.setCompressionResistanceHorizontalLow()
addArrangedSubview(rightStack)
// Text
let textStack = UIStackView()
textStack.axis = .vertical
textStack.alignment = .leading
textStack.spacing = 2
textStack.setContentHuggingHorizontalLow()
textStack.setCompressionResistanceHorizontalLow()
if let title = state.title(),
title.count > 0 {
let label = UILabel()
label.text = title
label.textColor = Colors.text
label.font = .systemFont(ofSize: Values.mediumFontSize)
textStack.addArrangedSubview(label)
}
if let displayDomain = state.displayDomain(),
displayDomain.count > 0 {
let label = UILabel()
label.text = displayDomain
label.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity)
label.font = .systemFont(ofSize: Values.verySmallFontSize)
textStack.addArrangedSubview(label)
}
let textWrapper = UIStackView(arrangedSubviews: [textStack])
textWrapper.axis = .horizontal
textWrapper.alignment = .center
textWrapper.setContentHuggingHorizontalLow()
textWrapper.setCompressionResistanceHorizontalLow()
rightStack.addArrangedSubview(textWrapper)
// Cancel
let cancelStack = UIStackView()
cancelStack.axis = .horizontal
cancelStack.alignment = .top
cancelStack.setContentHuggingHigh()
cancelStack.setCompressionResistanceHigh()
let cancelImage = UIImage(named: "compose-cancel")?.withRenderingMode(.alwaysTemplate)
let cancelButton = UIButton(type: .custom)
cancelButton.setImage(cancelImage, for: .normal)
cancelButton.addTarget(self, action: #selector(didTapCancel(sender:)), for: .touchUpInside)
self.cancelButton = cancelButton
cancelButton.tintColor = Theme.secondaryColor
cancelButton.setContentHuggingHigh()
cancelButton.setCompressionResistanceHigh()
cancelButton.isHidden = false
cancelStack.addArrangedSubview(cancelButton)
rightStack.addArrangedSubview(cancelStack)
// Stroke
let strokeView = UIView()
strokeView.backgroundColor = Theme.secondaryColor
rightStack.addSubview(strokeView)
strokeView.autoPinWidthToSuperview()
strokeView.autoPinEdge(toSuperviewEdge: .bottom)
strokeView.autoSetDimension(.height, toSize: CGHairlineWidth())
}
private func createImageView(state: LinkPreviewState) -> UIImageView? {
guard state.isLoaded() else {
owsFailDebug("State not loaded.")
return nil
}
guard state.imageState() == .loaded else {
return nil
}
guard let image = state.image() else {
owsFailDebug("Could not load image.")
return nil
}
let imageView = UIImageView()
imageView.image = image
return imageView
}
private func createDraftImageView(state: LinkPreviewState) -> UIImageView? {
guard state.isLoaded() else {
owsFailDebug("State not loaded.")
return nil
}
guard state.imageState() == .loaded else {
return nil
}
guard let image = state.image() else {
owsFailDebug("Could not load image.")
return nil
}
let imageView = LinkPreviewImageView(hasAsymmetricalRounding: self.hasAsymmetricalRounding)
imageView.image = image
return imageView
}
private func createDraftLoadingContents() {
self.axis = .vertical
self.alignment = .center
self.layoutConstraints.append(self.autoSetDimension(.height, toSize: draftHeight + draftMarginTop))
let activityIndicatorStyle: UIActivityIndicatorView.Style = (Theme.isDarkThemeEnabled
? .white
: .gray)
let activityIndicator = UIActivityIndicatorView(style: activityIndicatorStyle)
activityIndicator.startAnimating()
addArrangedSubview(activityIndicator)
let activityIndicatorSize: CGFloat = 25
activityIndicator.autoSetDimensions(to: CGSize(width: activityIndicatorSize, height: activityIndicatorSize))
// Stroke
let strokeView = UIView()
strokeView.backgroundColor = Theme.secondaryColor
self.addSubview(strokeView)
strokeView.autoPinWidthToSuperview(withMargin: 12)
strokeView.autoPinEdge(toSuperviewEdge: .bottom)
strokeView.autoSetDimension(.height, toSize: CGHairlineWidth())
}
// MARK: Events
@objc func wasTapped(sender: UIGestureRecognizer) {
guard sender.state == .recognized else {
return
}
if let cancelButton = cancelButton {
let cancelLocation = sender.location(in: cancelButton)
// Permissive hot area to make it very easy to cancel the link preview.
let hotAreaInset: CGFloat = -20
let cancelButtonHotArea = cancelButton.bounds.insetBy(dx: hotAreaInset, dy: hotAreaInset)
if cancelButtonHotArea.contains(cancelLocation) {
self.draftDelegate?.linkPreviewDidCancel()
return
}
}
}
// MARK: Measurement
@objc
public func measure(withSentState state: LinkPreviewSent) -> CGSize {
switch state.imageState() {
case .loaded:
if sentIsHero(state: state) {
return measureSentHero(state: state)
} else {
return measureSentNonHero(state: state, hasImage: true)
}
default:
return measureSentNonHero(state: state, hasImage: false)
}
}
private func measureSentHero(state: LinkPreviewSent) -> CGSize {
let maxMessageWidth = state.conversationStyle.maxMessageWidth
var messageHeight: CGFloat = 0
let heroImageSize = sentHeroImageSize(state: state)
messageHeight += heroImageSize.height
let textStackSize = sentTextStackSize(state: state, maxWidth: maxMessageWidth - 2 * sentHeroHMargin)
messageHeight += textStackSize.height + 2 * sentHeroVMargin
return CGSizeCeil(CGSize(width: maxMessageWidth, height: messageHeight))
}
private func measureSentNonHero(state: LinkPreviewSent, hasImage: Bool) -> CGSize {
let maxMessageWidth = state.conversationStyle.maxMessageWidth
var maxTextWidth = maxMessageWidth - 2 * sentNonHeroHMargin
if hasImage {
maxTextWidth -= (sentNonHeroImageSize + sentNonHeroHSpacing)
}
let textStackSize = sentTextStackSize(state: state, maxWidth: maxTextWidth)
var result = textStackSize
if hasImage {
result.width += sentNonHeroImageSize + sentNonHeroHSpacing
result.height = max(result.height, sentNonHeroImageSize)
}
result.width += 2 * sentNonHeroHMargin
result.height += 2 * sentNonHeroVMargin
return CGSizeCeil(result)
}
private func sentTextStackSize(state: LinkPreviewSent, maxWidth: CGFloat) -> CGSize {
let domainLabel = sentDomainLabel(state: state)
let domainLabelSize = CGSizeCeil(domainLabel.sizeThatFits(CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude)))
var result = domainLabelSize
if let titleLabel = sentTitleLabel(state: state) {
let titleLabelSize = CGSizeCeil(titleLabel.sizeThatFits(CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude)))
let maxTitleLabelHeight: CGFloat = ceil(CGFloat(sentTitleLineCount) * titleLabel.font.lineHeight)
result.width = max(result.width, titleLabelSize.width)
result.height += min(maxTitleLabelHeight, titleLabelSize.height) + sentVSpacing
}
return result
}
@objc
public func addBorderViews(bubbleView: OWSBubbleView) {
if let heroImageView = self.heroImageView {
let borderView = OWSBubbleShapeView(draw: ())
borderView.strokeColor = UIColor.clear
borderView.strokeThickness = 0
heroImageView.addSubview(borderView)
bubbleView.addPartnerView(borderView)
borderView.ows_autoPinToSuperviewEdges()
}
if let sentBodyView = self.sentBodyView {
let borderView = OWSBubbleShapeView(draw: ())
borderView.strokeColor = UIColor.clear
borderView.strokeThickness = 0
sentBodyView.addSubview(borderView)
bubbleView.addPartnerView(borderView)
borderView.ows_autoPinToSuperviewEdges()
} else {
owsFailDebug("Missing sentBodyView")
}
}
@objc func didTapCancel(sender: UIButton) {
self.draftDelegate?.linkPreviewDidCancel()
}
}

View File

@ -1,114 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
@objc
public class MediaDownloadView: UIView {
// MARK: -
private let attachmentId: String
private let radius: CGFloat
private let shapeLayer1 = CAShapeLayer()
private let shapeLayer2 = CAShapeLayer()
@objc
public required init(attachmentId: String, radius: CGFloat) {
self.attachmentId = attachmentId
self.radius = radius
super.init(frame: .zero)
shapeLayer1.zPosition = 1
shapeLayer2.zPosition = 2
layer.addSublayer(shapeLayer1)
layer.addSublayer(shapeLayer2)
NotificationCenter.default.addObserver(forName: NSNotification.Name.attachmentDownloadProgress, object: nil, queue: nil) { [weak self] notification in
guard let strongSelf = self else { return }
guard let notificationAttachmentId = notification.userInfo?[kAttachmentDownloadAttachmentIDKey] as? String else {
return
}
guard notificationAttachmentId == strongSelf.attachmentId else {
return
}
strongSelf.updateLayers()
}
}
@available(*, unavailable, message: "use other init() instead.")
required public init?(coder aDecoder: NSCoder) {
notImplemented()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc public override var bounds: CGRect {
didSet {
if oldValue != bounds {
updateLayers()
}
}
}
@objc public override var frame: CGRect {
didSet {
if oldValue != frame {
updateLayers()
}
}
}
internal func updateLayers() {
AssertIsOnMainThread()
shapeLayer1.frame = self.bounds
shapeLayer2.frame = self.bounds
shapeLayer1.path = nil
shapeLayer2.path = nil
return
// We can't display download progress yet
/*
// Prevent the shape layer from animating changes.
CATransaction.begin()
CATransaction.setDisableActions(true)
let center = CGPoint(x: self.bounds.width * 0.5,
y: self.bounds.height * 0.5)
let outerRadius: CGFloat = radius * 1.0
let innerRadius: CGFloat = radius * 0.9
let startAngle: CGFloat = CGFloat.pi * 1.5
let endAngle: CGFloat = CGFloat.pi * (1.5 + 2 * CGFloat(progress.floatValue))
let bezierPath1 = UIBezierPath()
bezierPath1.append(UIBezierPath(ovalIn: CGRect(origin: center.minus(CGPoint(x: innerRadius,
y: innerRadius)),
size: CGSize(width: innerRadius * 2,
height: innerRadius * 2))))
bezierPath1.append(UIBezierPath(ovalIn: CGRect(origin: center.minus(CGPoint(x: outerRadius,
y: outerRadius)),
size: CGSize(width: outerRadius * 2,
height: outerRadius * 2))))
shapeLayer1.path = bezierPath1.cgPath
let fillColor1: UIColor = UIColor(white: 1.0, alpha: 0.4)
shapeLayer1.fillColor = fillColor1.cgColor
shapeLayer1.fillRule = .evenOdd
let bezierPath2 = UIBezierPath()
bezierPath2.addArc(withCenter: center, radius: outerRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
bezierPath2.addArc(withCenter: center, radius: innerRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
shapeLayer2.path = bezierPath2.cgPath
let fillColor2: UIColor = (Theme.isDarkThemeEnabled ? .ows_gray25 : .ows_white)
shapeLayer2.fillColor = fillColor2.cgColor
CATransaction.commit()
*/
}
}

View File

@ -1,102 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
@objc
public class MediaUploadView: UIView {
// MARK: -
private let attachmentId: String
private let radius: CGFloat
private let shapeLayer1 = CAShapeLayer()
private let shapeLayer2 = CAShapeLayer()
private var isAttachmentReady: Bool = false
private var lastProgress: CGFloat = 0
@objc
public required init(attachmentId: String, radius: CGFloat) {
self.attachmentId = attachmentId
self.radius = radius
super.init(frame: .zero)
layer.addSublayer(shapeLayer1)
layer.addSublayer(shapeLayer2)
}
@available(*, unavailable, message: "use other init() instead.")
required public init?(coder aDecoder: NSCoder) {
notImplemented()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc public override var bounds: CGRect {
didSet {
if oldValue != bounds {
updateLayers()
}
}
}
@objc public override var frame: CGRect {
didSet {
if oldValue != frame {
updateLayers()
}
}
}
internal func updateLayers() {
AssertIsOnMainThread()
shapeLayer1.frame = self.bounds
shapeLayer2.frame = self.bounds
guard !isAttachmentReady else {
shapeLayer1.path = nil
shapeLayer2.path = nil
return
}
// Prevent the shape layer from animating changes.
CATransaction.begin()
CATransaction.setDisableActions(true)
let center = CGPoint(x: self.bounds.width * 0.5,
y: self.bounds.height * 0.5)
let outerRadius: CGFloat = radius * 1.0
let innerRadius: CGFloat = radius * 0.9
let startAngle: CGFloat = CGFloat.pi * 1.5
let endAngle: CGFloat = CGFloat.pi * (1.5 + 2 * lastProgress)
let bezierPath1 = UIBezierPath()
bezierPath1.addArc(withCenter: center, radius: outerRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
bezierPath1.addArc(withCenter: center, radius: innerRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
shapeLayer1.path = bezierPath1.cgPath
shapeLayer1.fillColor = UIColor.ows_white.cgColor
let innerCircleBounds = CGRect(x: center.x - innerRadius,
y: center.y - innerRadius,
width: innerRadius * 2,
height: innerRadius * 2)
let outerCircleBounds = CGRect(x: center.x - outerRadius,
y: center.y - outerRadius,
width: outerRadius * 2,
height: outerRadius * 2)
let bezierPath2 = UIBezierPath()
bezierPath2.append(UIBezierPath(ovalIn: innerCircleBounds))
bezierPath2.append(UIBezierPath(ovalIn: outerCircleBounds))
shapeLayer2.path = bezierPath2.cgPath
shapeLayer2.fillColor = UIColor(white: 1.0, alpha: 0.4).cgColor
shapeLayer2.fillRule = .evenOdd
CATransaction.commit()
}
}

View File

@ -1,42 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSBubbleView.h"
NS_ASSUME_NONNULL_BEGIN
@class OWSBubbleView;
// While rendering message bubbles, we often need to render
// into a subregion of the bubble that reflects the intersection
// of some subview (e.g. a media view) and the bubble shape
// (including its rounding).
//
// This view serves three different roles:
//
// * Drawing: Filling and/or stroking a subregion of the bubble shape.
// * Shadows: Casting a shadow over a subregion of the bubble shape.
// * Clipping: Clipping subviews to subregion of the bubble shape.
@interface OWSBubbleShapeView : UIView <OWSBubbleViewPartner>
@property (nonatomic, nullable) UIColor *fillColor;
@property (nonatomic, nullable) UIColor *strokeColor;
@property (nonatomic) CGFloat strokeThickness;
@property (nonatomic, nullable) UIColor *innerShadowColor;
@property (nonatomic) CGFloat innerShadowRadius;
@property (nonatomic) float innerShadowOpacity;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initDraw NS_DESIGNATED_INITIALIZER;
- (instancetype)initShadow NS_DESIGNATED_INITIALIZER;
- (instancetype)initClip NS_DESIGNATED_INITIALIZER;
- (instancetype)initInnerShadowWithColor:(UIColor *)color
radius:(CGFloat)radius
opacity:(float)opacity NS_DESIGNATED_INITIALIZER;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,290 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSBubbleShapeView.h"
#import "OWSBubbleView.h"
#import <SessionUtilitiesKit/UIView+OWS.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSUInteger, OWSBubbleShapeViewMode) {
// For stroking or filling.
OWSBubbleShapeViewMode_Draw,
OWSBubbleShapeViewMode_Shadow,
OWSBubbleShapeViewMode_Clip,
OWSBubbleShapeViewMode_InnerShadow,
};
@interface OWSBubbleShapeView ()
@property (nonatomic) OWSBubbleShapeViewMode mode;
@property (nonatomic) CAShapeLayer *shapeLayer;
@property (nonatomic) CAShapeLayer *maskLayer;
@property (nonatomic, nullable, weak) OWSBubbleView *bubbleView;
@property (nonatomic) BOOL isConfigured;
@end
#pragma mark -
@implementation OWSBubbleShapeView
- (void)configure
{
self.opaque = NO;
self.backgroundColor = [UIColor clearColor];
self.layoutMargins = UIEdgeInsetsZero;
self.shapeLayer = [CAShapeLayer new];
[self.layer addSublayer:self.shapeLayer];
self.maskLayer = [CAShapeLayer new];
self.isConfigured = YES;
[self updateLayers];
}
- (instancetype)initDraw
{
self = [super initWithFrame:CGRectZero];
if (!self) {
return self;
}
self.mode = OWSBubbleShapeViewMode_Draw;
[self configure];
return self;
}
- (instancetype)initShadow
{
self = [super initWithFrame:CGRectZero];
if (!self) {
return self;
}
self.mode = OWSBubbleShapeViewMode_Shadow;
[self configure];
return self;
}
- (instancetype)initClip
{
self = [super initWithFrame:CGRectZero];
if (!self) {
return self;
}
self.mode = OWSBubbleShapeViewMode_Clip;
[self configure];
return self;
}
- (instancetype)initInnerShadowWithColor:(UIColor *)color radius:(CGFloat)radius opacity:(float)opacity
{
self = [super initWithFrame:CGRectZero];
if (!self) {
return self;
}
self.mode = OWSBubbleShapeViewMode_InnerShadow;
_innerShadowColor = color;
_innerShadowRadius = radius;
_innerShadowOpacity = opacity;
[self configure];
return self;
}
- (void)setFillColor:(nullable UIColor *)fillColor
{
_fillColor = fillColor;
[self updateLayers];
}
- (void)setStrokeColor:(nullable UIColor *)strokeColor
{
_strokeColor = strokeColor;
[self updateLayers];
}
- (void)setStrokeThickness:(CGFloat)strokeThickness
{
_strokeThickness = strokeThickness;
[self updateLayers];
}
- (void)setInnerShadowColor:(nullable UIColor *)innerShadowColor
{
_innerShadowColor = innerShadowColor;
[self updateLayers];
}
- (void)setInnerShadowRadius:(CGFloat)innerShadowRadius
{
_innerShadowRadius = innerShadowRadius;
[self updateLayers];
}
- (void)setInnerShadowOpacity:(float)innerShadowOpacity
{
_innerShadowOpacity = innerShadowOpacity;
[self updateLayers];
}
- (void)setFrame:(CGRect)frame
{
BOOL didChange = !CGRectEqualToRect(self.frame, frame);
[super setFrame:frame];
if (didChange) {
[self updateLayers];
}
}
- (void)setBounds:(CGRect)bounds
{
BOOL didChange = !CGRectEqualToRect(self.bounds, bounds);
[super setBounds:bounds];
if (didChange) {
[self updateLayers];
}
}
- (void)setCenter:(CGPoint)center
{
[super setCenter:center];
[self updateLayers];
}
- (void)setBubbleView:(nullable OWSBubbleView *)bubbleView
{
_bubbleView = bubbleView;
[self updateLayers];
}
- (void)updateLayers
{
if (!self.shapeLayer) {
return;
}
if (!self.bubbleView) {
return;
}
if (!self.isConfigured) {
return;
}
// Prevent the layer from animating changes.
[CATransaction begin];
[CATransaction setDisableActions:YES];
UIBezierPath *bezierPath = [UIBezierPath new];
// Add the bubble view's path to the local path.
UIBezierPath *bubbleBezierPath = [self.bubbleView maskPath];
// We need to convert between coordinate systems using layers, not views.
CGPoint bubbleOffset = [self.layer convertPoint:CGPointZero fromLayer:self.bubbleView.layer];
CGAffineTransform transform = CGAffineTransformMakeTranslation(bubbleOffset.x, bubbleOffset.y);
[bubbleBezierPath applyTransform:transform];
[bezierPath appendPath:bubbleBezierPath];
switch (self.mode) {
case OWSBubbleShapeViewMode_Draw: {
UIBezierPath *boundsBezierPath = [UIBezierPath bezierPathWithRect:self.bounds];
[bezierPath appendPath:boundsBezierPath];
self.clipsToBounds = YES;
if (self.strokeColor) {
self.shapeLayer.strokeColor = self.strokeColor.CGColor;
self.shapeLayer.lineWidth = self.strokeThickness;
self.shapeLayer.zPosition = 100.f;
} else {
self.shapeLayer.strokeColor = nil;
self.shapeLayer.lineWidth = 0.f;
}
if (self.fillColor) {
self.shapeLayer.fillColor = self.fillColor.CGColor;
} else {
self.shapeLayer.fillColor = nil;
}
self.shapeLayer.path = bezierPath.CGPath;
break;
}
case OWSBubbleShapeViewMode_Shadow:
self.clipsToBounds = NO;
if (self.fillColor) {
self.shapeLayer.fillColor = self.fillColor.CGColor;
} else {
self.shapeLayer.fillColor = nil;
}
self.shapeLayer.path = bezierPath.CGPath;
self.shapeLayer.frame = self.bounds;
self.shapeLayer.masksToBounds = YES;
break;
case OWSBubbleShapeViewMode_Clip:
self.maskLayer.path = bezierPath.CGPath;
self.layer.mask = self.maskLayer;
break;
case OWSBubbleShapeViewMode_InnerShadow: {
self.maskLayer.path = bezierPath.CGPath;
self.layer.mask = self.maskLayer;
// Inner shadow.
// This should usually not be visible; it is used to distinguish
// profile pics from the background if they are similar.
self.shapeLayer.frame = self.bounds;
self.shapeLayer.masksToBounds = YES;
CGRect shadowBounds = self.bounds;
UIBezierPath *shadowPath = [bezierPath copy];
// This can be any value large enough to cast a sufficiently large shadow.
CGFloat shadowInset = -(self.innerShadowRadius * 4.f);
[shadowPath
appendPath:[UIBezierPath bezierPathWithRect:CGRectInset(shadowBounds, shadowInset, shadowInset)]];
// This can be any color since the fill should be clipped.
self.shapeLayer.fillColor = UIColor.blackColor.CGColor;
self.shapeLayer.path = shadowPath.CGPath;
self.shapeLayer.fillRule = kCAFillRuleEvenOdd;
self.shapeLayer.shadowColor = self.innerShadowColor.CGColor;
self.shapeLayer.shadowRadius = self.innerShadowRadius;
self.shapeLayer.shadowOpacity = self.innerShadowOpacity;
self.shapeLayer.shadowOffset = CGSizeZero;
break;
}
}
[CATransaction commit];
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,60 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
extern const CGFloat kOWSMessageCellCornerRadius_Large;
extern const CGFloat kOWSMessageCellCornerRadius_Small;
typedef NS_OPTIONS(NSUInteger, OWSDirectionalRectCorner) {
OWSDirectionalRectCornerTopLeading = 1 << 0,
OWSDirectionalRectCornerTopTrailing = 1 << 1,
OWSDirectionalRectCornerBottomLeading = 1 << 2,
OWSDirectionalRectCornerBottomTrailing = 1 << 3,
OWSDirectionalRectCornerAllCorners = ~0UL
};
@class OWSBubbleView;
@protocol OWSBubbleViewPartner <NSObject>
- (void)updateLayers;
- (void)setBubbleView:(nullable OWSBubbleView *)bubbleView;
@end
#pragma mark -
@interface OWSBubbleView : UIView
+ (UIBezierPath *)roundedBezierRectWithBubbleTop:(CGFloat)bubbleTop
bubbleLeft:(CGFloat)bubbleLeft
bubbleBottom:(CGFloat)bubbleBottom
bubbleRight:(CGFloat)bubbleRight
sharpCornerRadius:(CGFloat)sharpCornerRadius
wideCornerRadius:(CGFloat)wideCornerRadius
sharpCorners:(OWSDirectionalRectCorner)sharpCorners;
@property (nonatomic, nullable) UIColor *bubbleColor;
@property (nonatomic) OWSDirectionalRectCorner sharpCorners;
- (UIBezierPath *)maskPath;
#pragma mark - Coordination
- (void)addPartnerView:(id<OWSBubbleViewPartner>)view;
- (void)clearPartnerViews;
- (void)updatePartnerViews;
- (CGFloat)minWidth;
- (CGFloat)minHeight;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,284 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSBubbleView.h"
#import "MainAppContext.h"
#import <SessionUtilitiesKit/UIView+OWS.h>
#import "Session-Swift.h"
NS_ASSUME_NONNULL_BEGIN
UIRectCorner UIRectCornerForOWSDirectionalRectCorner(OWSDirectionalRectCorner corner);
UIRectCorner UIRectCornerForOWSDirectionalRectCorner(OWSDirectionalRectCorner corner)
{
if (corner == OWSDirectionalRectCornerAllCorners) {
return UIRectCornerAllCorners;
}
UIRectCorner rectCorner = 0;
BOOL isRTL = CurrentAppContext().isRTL;
if (corner & OWSDirectionalRectCornerTopLeading) {
rectCorner = rectCorner | (isRTL ? UIRectCornerTopRight : UIRectCornerTopLeft);
}
if (corner & OWSDirectionalRectCornerTopTrailing) {
rectCorner = rectCorner | (isRTL ? UIRectCornerTopLeft : UIRectCornerTopRight);
}
if (corner & OWSDirectionalRectCornerBottomTrailing) {
rectCorner = rectCorner | (isRTL ? UIRectCornerBottomLeft : UIRectCornerBottomRight);
}
if (corner & OWSDirectionalRectCornerBottomLeading) {
rectCorner = rectCorner | (isRTL ? UIRectCornerBottomRight : UIRectCornerBottomLeft);
}
return rectCorner;
}
const CGFloat kOWSMessageCellCornerRadius_Large = 10; // LKValues.messageBubbleCornerRadius
const CGFloat kOWSMessageCellCornerRadius_Small = 4;
@interface OWSBubbleView ()
@property (nonatomic) CAShapeLayer *maskLayer;
@property (nonatomic) CAShapeLayer *shapeLayer;
@property (nonatomic, readonly) NSMutableArray<id<OWSBubbleViewPartner>> *partnerViews;
@end
#pragma mark -
@implementation OWSBubbleView
- (instancetype)init
{
self = [super init];
if (!self) {
return self;
}
self.layoutMargins = UIEdgeInsetsZero;
self.shapeLayer = [CAShapeLayer new];
[self.layer addSublayer:self.shapeLayer];
self.maskLayer = [CAShapeLayer new];
self.layer.mask = self.maskLayer;
_partnerViews = [NSMutableArray new];
return self;
}
- (void)setFrame:(CGRect)frame
{
// We only need to update our layers if the _size_ of this view
// changes since the contents of the layers are in local coordinates.
BOOL didChangeSize = !CGSizeEqualToSize(self.frame.size, frame.size);
[super setFrame:frame];
if (didChangeSize) {
[self updateLayers];
}
// We always need to inform the "bubble stroke view" (if any) if our
// frame/bounds/center changes. Its contents are not in local coordinates.
[self updatePartnerViews];
}
- (void)setBounds:(CGRect)bounds
{
// We only need to update our layers if the _size_ of this view
// changes since the contents of the layers are in local coordinates.
BOOL didChangeSize = !CGSizeEqualToSize(self.bounds.size, bounds.size);
[super setBounds:bounds];
if (didChangeSize) {
[self updateLayers];
}
// We always need to inform the "bubble stroke view" (if any) if our
// frame/bounds/center changes. Its contents are not in local coordinates.
[self updatePartnerViews];
}
- (void)setCenter:(CGPoint)center
{
[super setCenter:center];
// We always need to inform the "bubble stroke view" (if any) if our
// frame/bounds/center changes. Its contents are not in local coordinates.
[self updatePartnerViews];
}
- (void)setBubbleColor:(nullable UIColor *)bubbleColor
{
_bubbleColor = bubbleColor;
[self updateLayers];
// Prevent the shape layer from animating changes.
[CATransaction begin];
[CATransaction setDisableActions:YES];
self.shapeLayer.fillColor = bubbleColor.CGColor;
[CATransaction commit];
}
- (void)setSharpCorners:(OWSDirectionalRectCorner)sharpCorners
{
_sharpCorners = sharpCorners;
[self updateLayers];
}
- (void)updateLayers
{
if (!self.maskLayer) {
return;
}
if (!self.shapeLayer) {
return;
}
UIBezierPath *bezierPath = [self maskPath];
// Prevent the shape layer from animating changes.
[CATransaction begin];
[CATransaction setDisableActions:YES];
self.shapeLayer.fillColor = self.bubbleColor.CGColor;
self.shapeLayer.path = bezierPath.CGPath;
self.maskLayer.path = bezierPath.CGPath;
[CATransaction commit];
}
- (UIBezierPath *)maskPath
{
return [self.class maskPathForSize:self.bounds.size sharpCorners:self.sharpCorners];
}
+ (UIBezierPath *)maskPathForSize:(CGSize)size sharpCorners:(OWSDirectionalRectCorner)sharpCorners
{
CGRect bounds = CGRectZero;
bounds.size = size;
CGFloat bubbleTop = 0.f;
CGFloat bubbleLeft = 0.f;
CGFloat bubbleBottom = size.height;
CGFloat bubbleRight = size.width;
return [OWSBubbleView roundedBezierRectWithBubbleTop:bubbleTop
bubbleLeft:bubbleLeft
bubbleBottom:bubbleBottom
bubbleRight:bubbleRight
sharpCornerRadius:kOWSMessageCellCornerRadius_Small
wideCornerRadius:kOWSMessageCellCornerRadius_Large
sharpCorners:sharpCorners];
}
+ (UIBezierPath *)roundedBezierRectWithBubbleTop:(CGFloat)bubbleTop
bubbleLeft:(CGFloat)bubbleLeft
bubbleBottom:(CGFloat)bubbleBottom
bubbleRight:(CGFloat)bubbleRight
sharpCornerRadius:(CGFloat)sharpCornerRadius
wideCornerRadius:(CGFloat)wideCornerRadius
sharpCorners:(OWSDirectionalRectCorner)sharpCorners
{
UIBezierPath *bezierPath = [UIBezierPath new];
UIRectCorner uiSharpCorners = UIRectCornerForOWSDirectionalRectCorner(sharpCorners);
const CGFloat topLeftRounding = (uiSharpCorners & UIRectCornerTopLeft) ? sharpCornerRadius : wideCornerRadius;
const CGFloat topRightRounding = (uiSharpCorners & UIRectCornerTopRight) ? sharpCornerRadius : wideCornerRadius;
const CGFloat bottomRightRounding
= (uiSharpCorners & UIRectCornerBottomRight) ? sharpCornerRadius : wideCornerRadius;
const CGFloat bottomLeftRounding = (uiSharpCorners & UIRectCornerBottomLeft) ? sharpCornerRadius : wideCornerRadius;
const CGFloat topAngle = 3.0f * M_PI_2;
const CGFloat rightAngle = 0.0f;
const CGFloat bottomAngle = M_PI_2;
const CGFloat leftAngle = M_PI;
// starting just to the right of the top left corner and working clockwise
[bezierPath moveToPoint:CGPointMake(bubbleLeft + topLeftRounding, bubbleTop)];
// top right corner
[bezierPath addArcWithCenter:CGPointMake(bubbleRight - topRightRounding, bubbleTop + topRightRounding)
radius:topRightRounding
startAngle:topAngle
endAngle:rightAngle
clockwise:true];
// bottom right corner
[bezierPath addArcWithCenter:CGPointMake(bubbleRight - bottomRightRounding, bubbleBottom - bottomRightRounding)
radius:bottomRightRounding
startAngle:rightAngle
endAngle:bottomAngle
clockwise:true];
// bottom left corner
[bezierPath addArcWithCenter:CGPointMake(bubbleLeft + bottomLeftRounding, bubbleBottom - bottomLeftRounding)
radius:bottomLeftRounding
startAngle:bottomAngle
endAngle:leftAngle
clockwise:true];
// top left corner
[bezierPath addArcWithCenter:CGPointMake(bubbleLeft + topLeftRounding, bubbleTop + topLeftRounding)
radius:topLeftRounding
startAngle:leftAngle
endAngle:topAngle
clockwise:true];
return bezierPath;
}
#pragma mark - Coordination
- (void)addPartnerView:(id<OWSBubbleViewPartner>)partnerView
{
OWSAssertDebug(self.partnerViews);
[partnerView setBubbleView:self];
[self.partnerViews addObject:partnerView];
}
- (void)clearPartnerViews
{
OWSAssertDebug(self.partnerViews);
[self.partnerViews removeAllObjects];
}
- (void)updatePartnerViews
{
[self layoutIfNeeded];
for (id<OWSBubbleViewPartner> partnerView in self.partnerViews) {
[partnerView updateLayers];
}
}
- (CGFloat)minWidth
{
return (kOWSMessageCellCornerRadius_Large * 2);
}
- (CGFloat)minHeight
{
return (kOWSMessageCellCornerRadius_Large * 2);
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,24 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@class ConversationStyle;
@class TSAttachment;
@protocol ConversationViewItem;
@interface OWSGenericAttachmentView : UIStackView
- (instancetype)initWithAttachment:(TSAttachment *)attachment
isIncoming:(BOOL)isIncoming
viewItem:(id<ConversationViewItem>)viewItem;
- (void)createContentsWithConversationStyle:(ConversationStyle *)conversationStyle;
- (CGSize)measureSizeWithMaxMessageWidth:(CGFloat)maxMessageWidth;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,242 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "OWSGenericAttachmentView.h"
#import "OWSBezierPathView.h"
#import "Session-Swift.h"
#import "UIFont+OWS.h"
#import "UIView+OWS.h"
#import <SignalUtilitiesKit/OWSFormat.h>
#import <SignalUtilitiesKit/UIColor+OWS.h>
#import <SessionUtilitiesKit/MIMETypeUtil.h>
#import <SessionUtilitiesKit/NSString+SSK.h>
#import <SessionMessagingKit/TSAttachmentStream.h>
#import <SignalCoreKit/NSString+OWS.h>
NS_ASSUME_NONNULL_BEGIN
@interface OWSGenericAttachmentView ()
@property (nonatomic) TSAttachment *attachment;
@property (nonatomic, nullable) TSAttachmentStream *attachmentStream;
@property (nonatomic, weak) id<ConversationViewItem> viewItem;
@property (nonatomic) BOOL isIncoming;
@property (nonatomic) UILabel *topLabel;
@property (nonatomic) UILabel *bottomLabel;
@end
#pragma mark -
@implementation OWSGenericAttachmentView
- (instancetype)initWithAttachment:(TSAttachment *)attachment
isIncoming:(BOOL)isIncoming
viewItem:(id<ConversationViewItem>)viewItem
{
self = [super init];
if (self) {
_attachment = attachment;
if ([attachment isKindOfClass:[TSAttachmentStream class]]) {
_attachmentStream = (TSAttachmentStream *)attachment;
}
_isIncoming = isIncoming;
_viewItem = viewItem;
}
return self;
}
#pragma mark -
- (CGFloat)hMargin
{
return 0.f;
}
- (CGFloat)hSpacing
{
return 8.f;
}
- (CGFloat)vMargin
{
return 4.f;
}
- (CGSize)measureSizeWithMaxMessageWidth:(CGFloat)maxMessageWidth
{
CGSize result = CGSizeZero;
CGFloat labelsHeight = ([OWSGenericAttachmentView topLabelFont].lineHeight +
[OWSGenericAttachmentView bottomLabelFont].lineHeight + [OWSGenericAttachmentView labelVSpacing]);
CGFloat contentHeight = MAX(self.iconHeight, labelsHeight);
result.height = contentHeight + self.vMargin * 2;
CGFloat labelsWidth
= MAX([self.topLabel sizeThatFits:CGSizeZero].width, [self.bottomLabel sizeThatFits:CGSizeZero].width);
CGFloat contentWidth = (self.iconWidth + labelsWidth + self.hSpacing);
result.width = MIN(maxMessageWidth, contentWidth + self.hMargin * 2);
return CGSizeCeil(result);
}
- (CGFloat)iconWidth
{
return 36.f;
}
- (CGFloat)iconHeight
{
return 48.0f;
}
- (void)createContentsWithConversationStyle:(ConversationStyle *)conversationStyle
{
OWSAssertDebug(conversationStyle);
self.axis = UILayoutConstraintAxisHorizontal;
self.alignment = UIStackViewAlignmentCenter;
self.spacing = self.hSpacing;
self.layoutMarginsRelativeArrangement = YES;
self.layoutMargins = UIEdgeInsetsMake(self.vMargin, 0, self.vMargin - 4, 0);
// attachment_file
UIImage *image = [UIImage imageNamed:@"generic-attachment"];
OWSAssertDebug(image);
OWSAssertDebug(image.size.width == self.iconWidth);
OWSAssertDebug(image.size.height == self.iconHeight);
UIImageView *imageView = [UIImageView new];
imageView.image = image;
[self addArrangedSubview:imageView];
[imageView setContentHuggingHigh];
NSString *_Nullable filename = self.attachment.sourceFilename;
if (!filename) {
filename = [[self.attachmentStream originalFilePath] lastPathComponent];
}
NSString *fileExtension = filename.pathExtension;
if (fileExtension.length < 1) {
fileExtension = [MIMETypeUtil fileExtensionForMIMEType:self.attachment.contentType];
}
UILabel *fileTypeLabel = [UILabel new];
fileTypeLabel.text = fileExtension.localizedUppercaseString;
fileTypeLabel.textColor = [UIColor ows_gray90Color];
fileTypeLabel.lineBreakMode = NSLineBreakByTruncatingTail;
fileTypeLabel.font = [UIFont ows_dynamicTypeCaption1Font].ows_mediumWeight;
fileTypeLabel.adjustsFontSizeToFitWidth = YES;
fileTypeLabel.textAlignment = NSTextAlignmentCenter;
// Center on icon.
[imageView addSubview:fileTypeLabel];
[fileTypeLabel autoCenterInSuperview];
[fileTypeLabel autoSetDimension:ALDimensionWidth toSize:self.iconWidth - 20.f];
[self replaceIconWithDownloadProgressIfNecessary:imageView];
UIStackView *labelsView = [UIStackView new];
labelsView.axis = UILayoutConstraintAxisVertical;
labelsView.spacing = [OWSGenericAttachmentView labelVSpacing];
labelsView.alignment = UIStackViewAlignmentLeading;
[self addArrangedSubview:labelsView];
NSString *topText = [self.attachment.sourceFilename ows_stripped];
if (topText.length < 1) {
topText = [MIMETypeUtil fileExtensionForMIMEType:self.attachment.contentType].localizedUppercaseString;
}
if (topText.length < 1) {
topText = NSLocalizedString(@"GENERIC_ATTACHMENT_LABEL", @"A label for generic attachments.");
}
UILabel *topLabel = [UILabel new];
self.topLabel = topLabel;
topLabel.text = topText;
topLabel.textColor = [conversationStyle bubbleTextColorWithIsIncoming:self.isIncoming];
topLabel.lineBreakMode = NSLineBreakByTruncatingMiddle;
topLabel.font = [OWSGenericAttachmentView topLabelFont];
[labelsView addArrangedSubview:topLabel];
unsigned long long fileSize = 0;
if (self.attachmentStream) {
NSError *error;
fileSize = [[NSFileManager defaultManager] attributesOfItemAtPath:[self.attachmentStream originalFilePath]
error:&error]
.fileSize;
OWSAssertDebug(!error);
}
// We don't want to show the file size while the attachment is downloading.
// To avoid layout jitter when the download completes, we reserve space in
// the layout using a whitespace string.
NSString *bottomText = @" ";
if (fileSize > 0) {
bottomText = [OWSFormat formatFileSize:fileSize];
}
UILabel *bottomLabel = [UILabel new];
self.bottomLabel = bottomLabel;
bottomLabel.text = bottomText;
bottomLabel.textColor = [conversationStyle bubbleSecondaryTextColorWithIsIncoming:self.isIncoming];
bottomLabel.lineBreakMode = NSLineBreakByTruncatingMiddle;
bottomLabel.font = [OWSGenericAttachmentView bottomLabelFont];
[labelsView addArrangedSubview:bottomLabel];
}
- (void)replaceIconWithDownloadProgressIfNecessary:(UIView *)iconView
{
if (!self.viewItem.attachmentPointer) {
return;
}
switch (self.viewItem.attachmentPointer.state) {
case TSAttachmentPointerStateFailed:
// We don't need to handle the "tap to retry" state here,
// only download progress.
return;
case TSAttachmentPointerStateEnqueued:
case TSAttachmentPointerStateDownloading:
break;
}
switch (self.viewItem.attachmentPointer.pointerType) {
case TSAttachmentPointerTypeRestoring:
// TODO: Show "restoring" indicator and possibly progress.
return;
case TSAttachmentPointerTypeUnknown:
case TSAttachmentPointerTypeIncoming:
break;
}
NSString *_Nullable uniqueId = self.viewItem.attachmentPointer.uniqueId;
if (uniqueId.length < 1) {
OWSFailDebug(@"Missing uniqueId.");
return;
}
CGSize iconViewSize = [iconView sizeThatFits:CGSizeZero];
CGFloat downloadViewSize = MIN(iconViewSize.width, iconViewSize.height);
MediaDownloadView *downloadView =
[[MediaDownloadView alloc] initWithAttachmentId:uniqueId radius:downloadViewSize * 0.5f];
iconView.layer.opacity = 0.01f;
[self addSubview:downloadView];
[downloadView autoSetDimensionsToSize:CGSizeMake(downloadViewSize, downloadViewSize)];
[downloadView autoAlignAxis:ALAxisHorizontal toSameAxisOfView:iconView];
[downloadView autoAlignAxis:ALAxisVertical toSameAxisOfView:iconView];
}
+ (UIFont *)topLabelFont
{
return [UIFont systemFontOfSize:LKValues.mediumFontSize];
}
+ (UIFont *)bottomLabelFont
{
return [UIFont ows_dynamicTypeCaption1Font];
}
+ (CGFloat)labelVSpacing
{
return 2.f;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,11 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@interface OWSLabel : UILabel
@end
NS_ASSUME_NONNULL_END

View File

@ -1,112 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSLabel.h"
NS_ASSUME_NONNULL_BEGIN
@interface OWSLabel ()
@property (nonatomic, nullable) NSValue *cachedSize;
@end
#pragma mark -
@implementation OWSLabel
- (void)setText:(nullable NSString *)text
{
if ([NSObject isNullableObject:text equalTo:self.text]) {
return;
}
[super setText:text];
self.cachedSize = nil;
}
- (void)setAttributedText:(nullable NSAttributedString *)attributedText
{
if ([NSObject isNullableObject:attributedText equalTo:self.attributedText]) {
return;
}
[super setAttributedText:attributedText];
self.cachedSize = nil;
}
- (void)setTextColor:(nullable UIColor *)textColor
{
if ([NSObject isNullableObject:textColor equalTo:self.textColor]) {
return;
}
[super setTextColor:textColor];
// No need to clear cached size here.
}
- (void)setFont:(nullable UIFont *)font
{
if ([NSObject isNullableObject:font equalTo:self.font]) {
return;
}
[super setFont:font];
self.cachedSize = nil;
}
- (void)setLineBreakMode:(NSLineBreakMode)lineBreakMode
{
if (self.lineBreakMode == lineBreakMode) {
return;
}
[super setLineBreakMode:lineBreakMode];
self.cachedSize = nil;
}
- (void)setNumberOfLines:(NSInteger)numberOfLines
{
if (self.numberOfLines == numberOfLines) {
return;
}
[super setNumberOfLines:numberOfLines];
self.cachedSize = nil;
}
- (void)setAdjustsFontSizeToFitWidth:(BOOL)adjustsFontSizeToFitWidth
{
if (self.adjustsFontSizeToFitWidth == adjustsFontSizeToFitWidth) {
return;
}
[super setAdjustsFontSizeToFitWidth:adjustsFontSizeToFitWidth];
self.cachedSize = nil;
}
- (void)setMinimumScaleFactor:(CGFloat)minimumScaleFactor
{
if (self.minimumScaleFactor == minimumScaleFactor) {
return;
}
[super setMinimumScaleFactor:minimumScaleFactor];
self.cachedSize = nil;
}
- (void)setMinimumFontSize:(CGFloat)minimumFontSize
{
if (self.minimumFontSize == minimumFontSize) {
return;
}
[super setMinimumFontSize:minimumFontSize];
self.cachedSize = nil;
}
- (CGSize)sizeThatFits:(CGSize)size
{
if (self.cachedSize) {
return self.cachedSize.CGSizeValue;
}
CGSize result = [super sizeThatFits:size];
self.cachedSize = [NSValue valueWithCGSize:result];
return result;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,101 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@class ContactShareViewModel;
@class ConversationStyle;
@protocol ConversationViewItem;
@class OWSContact;
@class OWSLinkPreview;
@class OWSQuotedReplyModel;
@class TSAttachmentPointer;
@class TSAttachmentStream;
@class TSOutgoingMessage;
typedef NS_ENUM(NSUInteger, OWSMessageGestureLocation) {
// Message text, etc.
OWSMessageGestureLocation_Default,
OWSMessageGestureLocation_OversizeText,
OWSMessageGestureLocation_Media,
OWSMessageGestureLocation_QuotedReply,
OWSMessageGestureLocation_LinkPreview,
};
@protocol OWSMessageBubbleViewDelegate
- (void)didTapImageViewItem:(id<ConversationViewItem>)viewItem
attachmentStream:(TSAttachmentStream *)attachmentStream
imageView:(UIView *)imageView;
- (void)didTapVideoViewItem:(id<ConversationViewItem>)viewItem
attachmentStream:(TSAttachmentStream *)attachmentStream
imageView:(UIView *)imageView;
- (void)didTapAudioViewItem:(id<ConversationViewItem>)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream;
- (void)didPanAudioViewItemToCurrentTime:(NSTimeInterval)currentTime;
- (void)didTapTruncatedTextMessage:(id<ConversationViewItem>)conversationItem;
- (void)didTapFailedIncomingAttachment:(id<ConversationViewItem>)viewItem;
- (void)didTapConversationItem:(id<ConversationViewItem>)viewItem quotedReply:(OWSQuotedReplyModel *)quotedReply;
- (void)didTapConversationItem:(id<ConversationViewItem>)viewItem
quotedReply:(OWSQuotedReplyModel *)quotedReply
failedThumbnailDownloadAttachmentPointer:(TSAttachmentPointer *)attachmentPointer;
- (void)didTapConversationItem:(id<ConversationViewItem>)viewItem linkPreview:(OWSLinkPreview *)linkPreview;
@property (nonatomic, readonly, nullable) NSString *lastSearchedText;
@end
#pragma mark -
@interface OWSMessageBubbleView : UIView
@property (nonatomic, nullable) id<ConversationViewItem> viewItem;
@property (nonatomic) ConversationStyle *conversationStyle;
@property (nonatomic) NSCache *cellMediaCache;
@property (nonatomic, nullable, readonly) UIView *bodyMediaView;
@property (nonatomic, weak) id<OWSMessageBubbleViewDelegate> delegate;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithFrame:(CGRect)frame NS_DESIGNATED_INITIALIZER;
- (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE;
- (void)configureViews;
- (void)loadContent;
- (void)unloadContent;
- (CGSize)measureSize;
- (void)prepareForReuse;
+ (NSDictionary *)senderNamePrimaryAttributes;
+ (NSDictionary *)senderNameSecondaryAttributes;
#pragma mark - Gestures
- (OWSMessageGestureLocation)gestureLocationForLocation:(CGPoint)locationInMessageBubble;
// This only needs to be called when we use the cell _outside_ the context
// of a conversation view message cell.
- (void)addTapGestureHandler;
- (void)handleTapGesture:(UITapGestureRecognizer *)sender;
- (void)handlePanGesture:(UIPanGestureRecognizer *)sender;
@end
NS_ASSUME_NONNULL_END

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "ConversationViewCell.h"
@class OWSMessageBubbleView;
NS_ASSUME_NONNULL_BEGIN
@interface OWSMessageCell : ConversationViewCell
@property (nonatomic, readonly) OWSMessageBubbleView *messageBubbleView;
+ (NSString *)cellReuseIdentifier;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,523 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "OWSMessageCell.h"
#import "OWSMessageBubbleView.h"
#import "OWSMessageHeaderView.h"
#import "Session-Swift.h"
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
NS_ASSUME_NONNULL_BEGIN
@interface OWSMessageCell () <UIGestureRecognizerDelegate>
// The nullable properties are created as needed.
// The non-nullable properties are so frequently used that it's easier
// to always keep one around.
@property (nonatomic) OWSMessageHeaderView *headerView;
@property (nonatomic) OWSMessageBubbleView *messageBubbleView;
@property (nonatomic) NSLayoutConstraint *messageBubbleViewBottomConstraint;
@property (nonatomic) LKProfilePictureView *avatarView;
@property (nonatomic) UIImageView *moderatorIconImageView;
@property (nonatomic, nullable) UIImageView *sendFailureBadgeView;
@property (nonatomic, nullable) NSMutableArray<NSLayoutConstraint *> *viewConstraints;
@property (nonatomic) BOOL isPresentingMenuController;
@end
#pragma mark -
@implementation OWSMessageCell
// `[UIView init]` invokes `[self initWithFrame:...]`.
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
[self commonInit];
}
return self;
}
- (void)commonInit
{
// Ensure only called once.
OWSAssertDebug(!self.messageBubbleView);
self.layoutMargins = UIEdgeInsetsZero;
self.contentView.layoutMargins = UIEdgeInsetsZero;
_viewConstraints = [NSMutableArray new];
self.messageBubbleView = [OWSMessageBubbleView new];
[self.contentView addSubview:self.messageBubbleView];
self.headerView = [OWSMessageHeaderView new];
self.avatarView = [[LKProfilePictureView alloc] init];
[self.avatarView autoSetDimension:ALDimensionWidth toSize:self.avatarSize];
[self.avatarView autoSetDimension:ALDimensionHeight toSize:self.avatarSize];
self.moderatorIconImageView = [[UIImageView alloc] init];
[self.moderatorIconImageView autoSetDimension:ALDimensionWidth toSize:20.f];
[self.moderatorIconImageView autoSetDimension:ALDimensionHeight toSize:20.f];
self.moderatorIconImageView.hidden = YES;
self.messageBubbleViewBottomConstraint = [self.messageBubbleView autoPinBottomToSuperviewMarginWithInset:0];
self.contentView.userInteractionEnabled = YES;
UITapGestureRecognizer *tap =
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTapGesture:)];
[self addGestureRecognizer:tap];
UILongPressGestureRecognizer *longPress =
[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPressGesture:)];
[self.contentView addGestureRecognizer:longPress];
UIPanGestureRecognizer *pan =
[[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePanGesture:)];
pan.delegate = self;
[self.contentView addGestureRecognizer:pan];
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (void)setConversationStyle:(nullable ConversationStyle *)conversationStyle
{
[super setConversationStyle:conversationStyle];
self.messageBubbleView.conversationStyle = conversationStyle;
}
+ (NSString *)cellReuseIdentifier
{
return NSStringFromClass([self class]);
}
#pragma mark - Convenience Accessors
- (OWSMessageCellType)cellType
{
return self.viewItem.messageCellType;
}
- (TSMessage *)message
{
OWSAssertDebug([self.viewItem.interaction isKindOfClass:[TSMessage class]]);
return (TSMessage *)self.viewItem.interaction;
}
- (BOOL)isIncoming
{
return self.viewItem.interaction.interactionType == OWSInteractionType_IncomingMessage;
}
- (BOOL)isOutgoing
{
return self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage;
}
- (BOOL)shouldHaveSendFailureBadge
{
if (![self.viewItem.interaction isKindOfClass:[TSOutgoingMessage class]]) {
return NO;
}
TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction;
return outgoingMessage.messageState == TSOutgoingMessageStateFailed;
}
#pragma mark - Load
- (void)loadForDisplay
{
OWSAssertDebug(self.conversationStyle);
OWSAssertDebug(self.viewItem);
OWSAssertDebug(self.viewItem.interaction);
OWSAssertDebug([self.viewItem.interaction isKindOfClass:[TSMessage class]]);
OWSAssertDebug(self.messageBubbleView);
[self.messageBubbleViewBottomConstraint setActive:YES];
self.messageBubbleView.viewItem = self.viewItem;
self.messageBubbleView.cellMediaCache = self.delegate.cellMediaCache;
[self.messageBubbleView configureViews];
[self.messageBubbleView loadContent];
if (self.viewItem.hasCellHeader) {
CGFloat headerHeight =
[self.headerView measureWithConversationViewItem:self.viewItem conversationStyle:self.conversationStyle]
.height;
[self.headerView loadForDisplayWithViewItem:self.viewItem conversationStyle:self.conversationStyle];
[self.contentView addSubview:self.headerView];
[self.viewConstraints addObjectsFromArray:@[
[self.headerView autoSetDimension:ALDimensionHeight toSize:headerHeight],
[self.headerView autoPinEdgeToSuperviewEdge:ALEdgeLeading],
[self.headerView autoPinEdgeToSuperviewEdge:ALEdgeTrailing],
[self.headerView autoPinEdgeToSuperviewEdge:ALEdgeTop],
[self.messageBubbleView autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:self.headerView],
]];
} else {
[self.viewConstraints addObjectsFromArray:@[
[self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeTop],
]];
}
if (self.isIncoming) {
[self.viewConstraints addObjectsFromArray:@[
[self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeLeading
withInset:self.conversationStyle.gutterLeading],
[self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeTrailing
withInset:self.conversationStyle.gutterTrailing
relation:NSLayoutRelationGreaterThanOrEqual],
]];
} else {
if (self.shouldHaveSendFailureBadge) {
self.sendFailureBadgeView = [UIImageView new];
self.sendFailureBadgeView.image =
[self.sendFailureBadge imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
self.sendFailureBadgeView.tintColor = LKColors.destructive;
[self.contentView addSubview:self.sendFailureBadgeView];
CGFloat sendFailureBadgeBottomMargin
= round(self.conversationStyle.lastTextLineAxis - self.sendFailureBadgeSize * 0.5f);
[self.viewConstraints addObjectsFromArray:@[
[self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeLeading
withInset:self.conversationStyle.gutterLeading
relation:NSLayoutRelationGreaterThanOrEqual],
[self.sendFailureBadgeView autoPinLeadingToTrailingEdgeOfView:self.messageBubbleView
offset:self.sendFailureBadgeSpacing],
// V-align the "send failure" badge with the
// last line of the text (if any, or where it
// would be).
[self.messageBubbleView autoPinEdge:ALEdgeBottom
toEdge:ALEdgeBottom
ofView:self.sendFailureBadgeView
withOffset:sendFailureBadgeBottomMargin],
[self.sendFailureBadgeView autoPinEdgeToSuperviewEdge:ALEdgeTrailing
withInset:self.conversationStyle.errorGutterTrailing],
[self.sendFailureBadgeView autoSetDimension:ALDimensionWidth toSize:self.sendFailureBadgeSize],
[self.sendFailureBadgeView autoSetDimension:ALDimensionHeight toSize:self.sendFailureBadgeSize],
]];
} else {
[self.viewConstraints addObjectsFromArray:@[
[self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeLeading
withInset:self.conversationStyle.gutterLeading
relation:NSLayoutRelationGreaterThanOrEqual],
[self.messageBubbleView autoPinEdgeToSuperviewEdge:ALEdgeTrailing
withInset:self.conversationStyle.gutterTrailing],
]];
}
}
if ([self updateAvatarView]) {
[self.viewConstraints addObjectsFromArray:@[
[self.messageBubbleView autoPinLeadingToTrailingEdgeOfView:self.avatarView offset:12],
[self.messageBubbleView autoPinEdge:ALEdgeTop toEdge:ALEdgeTop ofView:self.avatarView],
]];
[self.viewConstraints addObjectsFromArray:@[
[self.moderatorIconImageView autoPinEdge:ALEdgeTrailing toEdge:ALEdgeTrailing ofView:self.avatarView],
[self.moderatorIconImageView autoPinEdge:ALEdgeBottom toEdge:ALEdgeBottom ofView:self.avatarView withOffset:3.5]
]];
}
}
- (UIImage *)sendFailureBadge
{
UIImage *image = [UIImage imageNamed:@"message_status_failed_large"];
OWSAssertDebug(image);
OWSAssertDebug(image.size.width == self.sendFailureBadgeSize && image.size.height == self.sendFailureBadgeSize);
return image;
}
- (CGFloat)sendFailureBadgeSize
{
return 20.f;
}
- (CGFloat)sendFailureBadgeSpacing
{
return 8.f;
}
// * If cell is visible, lazy-load (expensive) view contents.
// * If cell is not visible, eagerly unload view contents.
- (void)ensureMediaLoadState
{
OWSAssertDebug(self.messageBubbleView);
if (!self.isCellVisible) {
[self.messageBubbleView unloadContent];
} else {
[self.messageBubbleView loadContent];
}
}
#pragma mark - Avatar
// Returns YES IFF the avatar view is appropriate and configured.
- (BOOL)updateAvatarView
{
if (!self.viewItem.shouldShowSenderAvatar) {
return NO;
}
if (!self.viewItem.isGroupThread) {
OWSFailDebug(@"not a group thread.");
return NO;
}
if (self.viewItem.interaction.interactionType != OWSInteractionType_IncomingMessage) {
OWSFailDebug(@"not an incoming message.");
return NO;
}
TSIncomingMessage *incomingMessage = (TSIncomingMessage *)self.viewItem.interaction;
[self.contentView addSubview:self.avatarView];
self.avatarView.size = self.avatarSize;
self.avatarView.hexEncodedPublicKey = incomingMessage.authorId;
[self.avatarView update];
// Loki: Show the moderator icon if needed
if (self.viewItem.isGroupThread) { // FIXME: This logic also shouldn't apply to closed groups
SNOpenGroup *publicChat = [LKStorage.shared getOpenGroupForThreadID:self.viewItem.interaction.uniqueThreadId];
if (publicChat != nil) {
BOOL isModerator = [SNOpenGroupAPI isUserModerator:incomingMessage.authorId forChannel:publicChat.channel onServer:publicChat.server];
UIImage *moderatorIcon = [UIImage imageNamed:@"Crown"];
self.moderatorIconImageView.image = moderatorIcon;
self.moderatorIconImageView.hidden = !isModerator;
}
}
[self.contentView addSubview:self.moderatorIconImageView];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(otherUsersProfileDidChange:)
name:kNSNotificationName_OtherUsersProfileDidChange
object:nil];
return YES;
}
- (CGFloat)avatarSize
{
return LKValues.smallProfilePictureSize;
}
- (void)otherUsersProfileDidChange:(NSNotification *)notification
{
OWSAssertIsOnMainThread();
if (!self.viewItem.shouldShowSenderAvatar) {
return;
}
if (!self.viewItem.isGroupThread) {
OWSFailDebug(@"not a group thread.");
return;
}
if (self.viewItem.interaction.interactionType != OWSInteractionType_IncomingMessage) {
OWSFailDebug(@"not an incoming message.");
return;
}
NSString *recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId];
if (recipientId.length == 0) {
return;
}
TSIncomingMessage *incomingMessage = (TSIncomingMessage *)self.viewItem.interaction;
if (![incomingMessage.authorId isEqualToString:recipientId]) {
return;
}
[self updateAvatarView];
}
#pragma mark - Measurement
- (CGSize)cellSize
{
OWSAssertDebug(self.conversationStyle);
OWSAssertDebug(self.conversationStyle.viewWidth > 0);
OWSAssertDebug(self.viewItem);
OWSAssertDebug([self.viewItem.interaction isKindOfClass:[TSMessage class]]);
OWSAssertDebug(self.messageBubbleView);
self.messageBubbleView.viewItem = self.viewItem;
self.messageBubbleView.cellMediaCache = self.delegate.cellMediaCache;
CGSize messageBubbleSize = [self.messageBubbleView measureSize];
CGSize cellSize = messageBubbleSize;
OWSAssertDebug(cellSize.width > 0 && cellSize.height > 0);
if (self.viewItem.hasCellHeader) {
cellSize.height +=
[self.headerView measureWithConversationViewItem:self.viewItem conversationStyle:self.conversationStyle]
.height;
}
if (self.shouldHaveSendFailureBadge) {
cellSize.width += self.sendFailureBadgeSize + self.sendFailureBadgeSpacing;
}
cellSize = CGSizeCeil(cellSize);
return cellSize;
}
#pragma mark - Reuse
- (void)prepareForReuse
{
[super prepareForReuse];
[NSLayoutConstraint deactivateConstraints:self.viewConstraints];
self.viewConstraints = [NSMutableArray new];
[self.messageBubbleView prepareForReuse];
[self.messageBubbleView unloadContent];
[self.headerView removeFromSuperview];
[self.avatarView removeFromSuperview];
self.moderatorIconImageView.image = nil;
[self.moderatorIconImageView removeFromSuperview];
[self.sendFailureBadgeView removeFromSuperview];
self.sendFailureBadgeView = nil;
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - Notifications
- (void)setIsCellVisible:(BOOL)isCellVisible {
BOOL didChange = self.isCellVisible != isCellVisible;
[super setIsCellVisible:isCellVisible];
if (!didChange) {
return;
}
[self ensureMediaLoadState];
}
#pragma mark - Gesture recognizers
- (void)handleTapGesture:(UITapGestureRecognizer *)sender
{
OWSAssertDebug(self.delegate);
if (sender.state != UIGestureRecognizerStateRecognized) {
OWSLogVerbose(@"Ignoring tap on message: %@", self.viewItem.interaction.debugDescription);
return;
}
if ([self isGestureInCellHeader:sender]) {
return;
}
if (self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) {
TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction;
if (outgoingMessage.messageState == TSOutgoingMessageStateFailed) {
[self.delegate didTapFailedOutgoingMessage:outgoingMessage];
return;
} else if (outgoingMessage.messageState == TSOutgoingMessageStateSending) {
// Ignore taps on outgoing messages being sent.
return;
}
}
[self.messageBubbleView handleTapGesture:sender];
}
- (void)handleLongPressGesture:(UILongPressGestureRecognizer *)sender
{
OWSAssertDebug(self.delegate);
if (sender.state != UIGestureRecognizerStateBegan) {
return;
}
if ([self isGestureInCellHeader:sender]) {
return;
}
BOOL shouldAllowReply = YES;
if (self.viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) {
TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)self.viewItem.interaction;
if (outgoingMessage.messageState == TSOutgoingMessageStateFailed) {
// Don't allow "delete" or "reply" on "failed" outgoing messages.
shouldAllowReply = NO;
} else if (outgoingMessage.messageState == TSOutgoingMessageStateSending) {
// Don't allow "delete" or "reply" on "sending" outgoing messages.
shouldAllowReply = NO;
}
}
CGPoint locationInMessageBubble = [sender locationInView:self.messageBubbleView];
switch ([self.messageBubbleView gestureLocationForLocation:locationInMessageBubble]) {
case OWSMessageGestureLocation_Default:
case OWSMessageGestureLocation_OversizeText:
case OWSMessageGestureLocation_LinkPreview: {
[self.delegate conversationCell:self
shouldAllowReply:shouldAllowReply
didLongpressTextViewItem:self.viewItem];
break;
}
case OWSMessageGestureLocation_Media: {
[self.delegate conversationCell:self
shouldAllowReply:shouldAllowReply
didLongpressMediaViewItem:self.viewItem];
break;
}
case OWSMessageGestureLocation_QuotedReply: {
[self.delegate conversationCell:self
shouldAllowReply:shouldAllowReply
didLongpressQuoteViewItem:self.viewItem];
break;
}
}
}
- (void)handlePanGesture:(UIPanGestureRecognizer *)sender
{
[self.messageBubbleView handlePanGesture:sender];
}
-(BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
LKVoiceMessageView *voiceMessageView = self.viewItem.lastAudioMessageView;
if (![gestureRecognizer isKindOfClass:UIPanGestureRecognizer.class] || voiceMessageView == nil) { return NO; }
UIPanGestureRecognizer *panGestureRecognizer = (UIPanGestureRecognizer *)gestureRecognizer;
CGPoint location = [panGestureRecognizer locationInView:voiceMessageView];
if (!CGRectContainsPoint(voiceMessageView.bounds, location)) { return NO; }
CGPoint velocity = [panGestureRecognizer velocityInView:voiceMessageView];
return fabs(velocity.x) > fabs(velocity.y);
}
- (BOOL)isGestureInCellHeader:(UIGestureRecognizer *)sender
{
OWSAssertDebug(self.viewItem);
if (!self.viewItem.hasCellHeader) {
return NO;
}
CGPoint location = [sender locationInView:self];
CGPoint headerBottom = [self convertPoint:CGPointMake(0, self.headerView.height) fromView:self.headerView];
return location.y <= headerBottom.y;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,24 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@class ConversationStyle;
@protocol ConversationViewItem;
@interface OWSMessageFooterView : UIStackView
- (void)configureWithConversationViewItem:(id<ConversationViewItem>)viewItem
isOverlayingMedia:(BOOL)isOverlayingMedia
conversationStyle:(ConversationStyle *)conversationStyle
isIncoming:(BOOL)isIncoming;
- (CGSize)measureWithConversationViewItem:(id<ConversationViewItem>)viewItem;
- (void)prepareForReuse;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,247 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSMessageFooterView.h"
#import "DateUtil.h"
#import "OWSLabel.h"
#import "OWSMessageTimerView.h"
#import "Session-Swift.h"
#import <QuartzCore/QuartzCore.h>
NS_ASSUME_NONNULL_BEGIN
@interface OWSMessageFooterView ()
@property (nonatomic) UILabel *timestampLabel;
@property (nonatomic) UIImageView *statusIndicatorImageView;
@property (nonatomic) OWSMessageTimerView *messageTimerView;
@end
@implementation OWSMessageFooterView
// `[UIView init]` invokes `[self initWithFrame:...]`.
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
[self commontInit];
}
return self;
}
- (void)commontInit
{
// Ensure only called once.
OWSAssertDebug(!self.timestampLabel);
self.layoutMargins = UIEdgeInsetsZero;
self.axis = UILayoutConstraintAxisHorizontal;
self.alignment = UIStackViewAlignmentCenter;
self.distribution = UIStackViewDistributionEqualSpacing;
UIStackView *leftStackView = [UIStackView new];
leftStackView.axis = UILayoutConstraintAxisHorizontal;
leftStackView.spacing = self.hSpacing;
leftStackView.alignment = UIStackViewAlignmentCenter;
[self addArrangedSubview:leftStackView];
[leftStackView setContentHuggingHigh];
self.timestampLabel = [OWSLabel new];
[leftStackView addArrangedSubview:self.timestampLabel];
self.messageTimerView = [OWSMessageTimerView new];
[self.messageTimerView setContentHuggingHigh];
[leftStackView addArrangedSubview:self.messageTimerView];
self.statusIndicatorImageView = [UIImageView new];
self.userInteractionEnabled = NO;
}
- (void)configureFonts
{
self.timestampLabel.font = [UIFont systemFontOfSize:LKValues.verySmallFontSize];
}
- (CGFloat)hSpacing
{
// TODO: Review constant.
return 8.f;
}
- (CGFloat)maxImageWidth
{
return 18.f;
}
- (CGFloat)imageHeight
{
return 12.f;
}
#pragma mark - Load
- (void)configureWithConversationViewItem:(id<ConversationViewItem>)viewItem
isOverlayingMedia:(BOOL)isOverlayingMedia
conversationStyle:(ConversationStyle *)conversationStyle
isIncoming:(BOOL)isIncoming
{
OWSAssertDebug(viewItem);
OWSAssertDebug(conversationStyle);
[self configureLabelsWithConversationViewItem:viewItem];
UIColor *textColor;
if (isOverlayingMedia) {
textColor = UIColor.whiteColor;
} else {
textColor = [conversationStyle bubbleSecondaryTextColorWithIsIncoming:isIncoming];
}
self.timestampLabel.textColor = textColor;
if (viewItem.isExpiringMessage) {
TSMessage *message = (TSMessage *)viewItem.interaction;
uint64_t expirationTimestamp = message.expiresAt;
uint32_t expiresInSeconds = message.expiresInSeconds;
[self.messageTimerView configureWithExpirationTimestamp:expirationTimestamp
initialDurationSeconds:expiresInSeconds
tintColor:textColor];
self.messageTimerView.hidden = NO;
} else {
self.messageTimerView.hidden = YES;
}
if (viewItem.interaction.interactionType == OWSInteractionType_OutgoingMessage) {
TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)viewItem.interaction;
UIImage *_Nullable statusIndicatorImage = nil;
__block BOOL isNoteToSelf = NO;
[OWSPrimaryStorage.sharedManager.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
TSContactThread *thread = [outgoingMessage.thread as:TSContactThread.class];
if (thread != nil) {
NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey];
isNoteToSelf = ([thread.contactIdentifier isEqual:userPublicKey]);
}
}];
if (statusIndicatorImage && !isNoteToSelf) {
[self showStatusIndicatorWithIcon:statusIndicatorImage textColor:textColor];
} else {
[self hideStatusIndicator];
}
} else {
[self hideStatusIndicator];
}
}
- (void)showStatusIndicatorWithIcon:(UIImage *)icon textColor:(UIColor *)textColor
{
OWSAssertDebug(icon.size.width <= self.maxImageWidth);
self.statusIndicatorImageView.image = [icon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
self.statusIndicatorImageView.tintColor = textColor;
[self.statusIndicatorImageView setContentHuggingHigh];
self.spacing = self.hSpacing;
}
- (void)hideStatusIndicator
{
// If there's no status indicator, we want the other
// footer contents to "cling to the leading edge".
// Instead of hiding the status indicator view,
// we clear its contents and let it stretch to fill
// the available space.
self.statusIndicatorImageView.image = nil;
[self.statusIndicatorImageView setContentHuggingLow];
self.spacing = 0;
}
- (void)animateSpinningIcon
{
CABasicAnimation *animation;
animation = [CABasicAnimation animationWithKeyPath:@"transform.rotation.z"];
animation.toValue = @(M_PI * 2.0);
const CGFloat kPeriodSeconds = 1.f;
animation.duration = kPeriodSeconds;
animation.cumulative = YES;
animation.repeatCount = HUGE_VALF;
[self.statusIndicatorImageView.layer addAnimation:animation forKey:@"animation"];
}
- (BOOL)isFailedOutgoingMessage:(id<ConversationViewItem>)viewItem
{
OWSAssertDebug(viewItem);
if (viewItem.interaction.interactionType != OWSInteractionType_OutgoingMessage) {
return NO;
}
TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)viewItem.interaction;
MessageReceiptStatus messageStatus =
[MessageRecipientStatusUtils recipientStatusWithOutgoingMessage:outgoingMessage];
return messageStatus == MessageReceiptStatusFailed;
}
- (void)configureLabelsWithConversationViewItem:(id<ConversationViewItem>)viewItem
{
OWSAssertDebug(viewItem);
[self configureFonts];
NSString *timestampLabelText;
if ([self isFailedOutgoingMessage:viewItem]) {
timestampLabelText
= NSLocalizedString(@"MESSAGE_STATUS_SEND_FAILED", @"Label indicating that a message failed to send.");
} else {
timestampLabelText = [DateUtil formatMessageTimestamp:viewItem.interaction.timestampForUI];
}
[self.timestampLabel setText:timestampLabelText.localizedUppercaseString];
}
- (CGSize)measureWithConversationViewItem:(id<ConversationViewItem>)viewItem
{
OWSAssertDebug(viewItem);
[self configureLabelsWithConversationViewItem:viewItem];
CGSize result = CGSizeZero;
result.height = MAX(self.timestampLabel.font.lineHeight, self.imageHeight);
// Measure the actual current width, to be safe.
CGFloat timestampLabelWidth = [self.timestampLabel sizeThatFits:CGSizeZero].width;
result.width = timestampLabelWidth;
if (viewItem.isExpiringMessage) {
result.width += ([OWSMessageTimerView measureSize].width + self.hSpacing);
}
return CGSizeCeil(result);
}
- (nullable NSString *)messageStatusTextForConversationViewItem:(id<ConversationViewItem>)viewItem
{
OWSAssertDebug(viewItem);
if (viewItem.interaction.interactionType != OWSInteractionType_OutgoingMessage) {
return nil;
}
TSOutgoingMessage *outgoingMessage = (TSOutgoingMessage *)viewItem.interaction;
NSString *statusMessage = [MessageRecipientStatusUtils receiptMessageWithOutgoingMessage:outgoingMessage];
return statusMessage;
}
- (void)prepareForReuse
{
[self.statusIndicatorImageView.layer removeAllAnimations];
[self.messageTimerView prepareForReuse];
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,23 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
extern const CGFloat OWSMessageHeaderViewDateHeaderVMargin;
@class ConversationStyle;
@protocol ConversationViewItem;
NS_ASSUME_NONNULL_BEGIN
@interface OWSMessageHeaderView : UIStackView
- (void)loadForDisplayWithViewItem:(id<ConversationViewItem>)viewItem
conversationStyle:(ConversationStyle *)conversationStyle;
- (CGSize)measureWithConversationViewItem:(id<ConversationViewItem>)viewItem
conversationStyle:(ConversationStyle *)conversationStyle;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,196 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSMessageHeaderView.h"
#import "ConversationViewItem.h"
#import "Session-Swift.h"
#import <SignalUtilitiesKit/OWSUnreadIndicator.h>
#import <SignalUtilitiesKit/UIColor+OWS.h>
#import <SignalUtilitiesKit/UIFont+OWS.h>
#import <SessionUtilitiesKit/UIView+OWS.h>
NS_ASSUME_NONNULL_BEGIN
const CGFloat OWSMessageHeaderViewDateHeaderVMargin = 16; // Values.mediumSpacing
@interface OWSMessageHeaderView ()
@property (nonatomic) UILabel *titleLabel;
@property (nonatomic) UILabel *subtitleLabel;
@property (nonatomic) UIView *strokeView;
@property (nonatomic) NSArray<NSLayoutConstraint *> *layoutConstraints;
@property (nonatomic) UIStackView *stackView;
@end
#pragma mark -
@implementation OWSMessageHeaderView
// `[UIView init]` invokes `[self initWithFrame:...]`.
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
[self commontInit];
}
return self;
}
- (void)commontInit
{
OWSAssertDebug(!self.titleLabel);
self.layoutMargins = UIEdgeInsetsZero;
self.layoutConstraints = @[];
// Intercept touches.
// Date breaks and unread indicators are not interactive.
self.userInteractionEnabled = YES;
self.strokeView = [UIView new];
[self.strokeView setContentHuggingHigh];
self.titleLabel = [UILabel new];
self.titleLabel.textAlignment = NSTextAlignmentCenter;
self.titleLabel.lineBreakMode = NSLineBreakByTruncatingTail;
self.titleLabel.textColor = [LKColors.text colorWithAlphaComponent:0.8];
self.subtitleLabel = [UILabel new];
// The subtitle may wrap to a second line.
self.subtitleLabel.numberOfLines = 0;
self.subtitleLabel.lineBreakMode = NSLineBreakByWordWrapping;
self.subtitleLabel.textAlignment = NSTextAlignmentCenter;
self.stackView = [[UIStackView alloc] initWithArrangedSubviews:@[
self.strokeView,
self.titleLabel,
self.subtitleLabel,
]];
self.stackView.axis = NSTextLayoutOrientationVertical;
self.stackView.spacing = 2;
[self addSubview:self.stackView];
}
- (void)loadForDisplayWithViewItem:(id<ConversationViewItem>)viewItem
conversationStyle:(ConversationStyle *)conversationStyle
{
OWSAssertDebug(viewItem);
OWSAssertDebug(conversationStyle);
OWSAssertDebug(viewItem.unreadIndicator || viewItem.shouldShowDate);
self.titleLabel.textColor = [LKColors.text colorWithAlphaComponent:0.8];
self.subtitleLabel.textColor = [LKColors.text colorWithAlphaComponent:0.8];
[self configureLabelsWithViewItem:viewItem];
CGFloat strokeThickness = [self strokeThicknessWithViewItem:viewItem];
self.strokeView.layer.cornerRadius = strokeThickness * 0.5f;
self.strokeView.backgroundColor = [self strokeColorWithViewItem:viewItem];
self.strokeView.hidden = viewItem.unreadIndicator == nil;
self.subtitleLabel.hidden = self.subtitleLabel.text.length < 1;
[NSLayoutConstraint deactivateConstraints:self.layoutConstraints];
self.layoutConstraints = @[
[self.strokeView autoSetDimension:ALDimensionHeight toSize:strokeThickness],
[self.stackView autoPinEdgeToSuperviewEdge:ALEdgeTop],
[self.stackView autoPinEdgeToSuperviewEdge:ALEdgeLeading withInset:conversationStyle.headerGutterLeading],
[self.stackView autoPinEdgeToSuperviewEdge:ALEdgeTrailing withInset:conversationStyle.headerGutterTrailing]
];
}
- (CGFloat)strokeThicknessWithViewItem:(id<ConversationViewItem>)viewItem
{
OWSAssertDebug(viewItem);
if (viewItem.unreadIndicator) {
return 1.f;
} else {
return 0.f;
}
}
- (UIColor *)strokeColorWithViewItem:(id<ConversationViewItem>)viewItem
{
OWSAssertDebug(viewItem);
if (viewItem.unreadIndicator) {
return Theme.secondaryColor;
} else {
return Theme.hairlineColor;
}
}
- (void)configureLabelsWithViewItem:(id<ConversationViewItem>)viewItem
{
OWSAssertDebug(viewItem);
NSDate *date = viewItem.interaction.receivedAtDate;
NSString *dateString = [DateUtil formatDateForConversationDateBreaks:date];
// Update cell to reflect changes in dynamic text.
if (viewItem.unreadIndicator) {
self.titleLabel.font = [UIFont boldSystemFontOfSize:LKValues.verySmallFontSize];
NSString *title = NSLocalizedString(
@"MESSAGES_VIEW_UNREAD_INDICATOR", @"Indicator that separates read from unread messages.");
if (viewItem.shouldShowDate) {
title = [[dateString rtlSafeAppend:@" \u00B7 "] rtlSafeAppend:title];
}
self.titleLabel.text = title;
if (!viewItem.unreadIndicator.hasMoreUnseenMessages) {
self.subtitleLabel.text = nil;
} else {
self.subtitleLabel.text = (viewItem.unreadIndicator.missingUnseenSafetyNumberChangeCount > 0
? NSLocalizedString(@"MESSAGES_VIEW_UNREAD_INDICATOR_HAS_MORE_UNSEEN_MESSAGES",
@"Messages that indicates that there are more unseen messages.")
: NSLocalizedString(
@"MESSAGES_VIEW_UNREAD_INDICATOR_HAS_MORE_UNSEEN_MESSAGES_AND_SAFETY_NUMBER_CHANGES",
@"Messages that indicates that there are more unseen messages including safety number "
@"changes."));
}
} else {
self.titleLabel.font = [UIFont boldSystemFontOfSize:LKValues.verySmallFontSize];
self.titleLabel.text = dateString;
self.subtitleLabel.text = nil;
}
}
- (CGSize)measureWithConversationViewItem:(id<ConversationViewItem>)viewItem
conversationStyle:(ConversationStyle *)conversationStyle
{
OWSAssertDebug(viewItem);
OWSAssertDebug(conversationStyle);
OWSAssertDebug(viewItem.unreadIndicator || viewItem.shouldShowDate);
[self configureLabelsWithViewItem:viewItem];
CGSize result = CGSizeMake(conversationStyle.viewWidth, 0);
CGFloat strokeThickness = [self strokeThicknessWithViewItem:viewItem];
result.height += strokeThickness;
if (strokeThickness != 0) {
result.height += self.stackView.spacing;
}
CGFloat maxTextWidth = conversationStyle.headerViewContentWidth;
CGSize titleSize = [self.titleLabel sizeThatFits:CGSizeMake(maxTextWidth, CGFLOAT_MAX)];
result.height += titleSize.height;
if (self.subtitleLabel.text.length > 0) {
CGSize subtitleSize = [self.subtitleLabel sizeThatFits:CGSizeMake(maxTextWidth, CGFLOAT_MAX)];
result.height += self.stackView.spacing + subtitleSize.height;
}
result.height += OWSMessageHeaderViewDateHeaderVMargin;
return CGSizeCeil(result);
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,15 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import <SignalUtilitiesKit/OWSTextView.h>
NS_ASSUME_NONNULL_BEGIN
@interface OWSMessageTextView : OWSTextView
@property (nonatomic) BOOL shouldIgnoreEvents;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,129 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSMessageTextView.h"
#import <SessionUtilitiesKit/UIView+OWS.h>
NS_ASSUME_NONNULL_BEGIN
@interface OWSMessageTextView ()
@property (nonatomic, nullable) NSValue *cachedSize;
@end
#pragma mark -
@implementation OWSMessageTextView
// Our message text views are never used for editing;
// suppress their ability to become first responder
// so that tapping on them doesn't hide keyboard.
- (BOOL)canBecomeFirstResponder
{
return NO;
}
// Ignore interactions with the text view _except_ taps on links.
//
// We want to disable "partial" selection of text in the message
// and we want to enable "tap to resend" by tapping on a message.
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *_Nullable)event
{
if (self.shouldIgnoreEvents) {
// We ignore all events for failed messages so that users
// can tap-to-resend even "all link" messages.
return NO;
}
// Find the nearest text position to the event.
UITextPosition *_Nullable position = [self closestPositionToPoint:point];
if (!position) {
return NO;
}
// Find the range of the character in the text which contains the event.
//
// Try every layout direction (this might not be necessary).
UITextRange *_Nullable range = nil;
for (NSNumber *textLayoutDirection in @[
@(UITextLayoutDirectionLeft),
@(UITextLayoutDirectionRight),
@(UITextLayoutDirectionUp),
@(UITextLayoutDirectionDown),
]) {
range = [self.tokenizer rangeEnclosingPosition:position
withGranularity:UITextGranularityCharacter
inDirection:(UITextDirection)textLayoutDirection.intValue];
if (range) {
break;
}
}
if (!range) {
return NO;
}
// Ignore the event unless it occurred inside a link.
NSInteger startIndex = [self offsetFromPosition:self.beginningOfDocument toPosition:range.start];
BOOL result =
[self.attributedText attribute:NSLinkAttributeName atIndex:(NSUInteger)startIndex effectiveRange:nil] != nil;
return result;
}
- (void)setText:(nullable NSString *)text
{
if ([NSObject isNullableObject:text equalTo:self.text]) {
return;
}
[super setText:text];
self.cachedSize = nil;
}
- (void)setAttributedText:(nullable NSAttributedString *)attributedText
{
if ([NSObject isNullableObject:attributedText equalTo:self.attributedText]) {
return;
}
[super setAttributedText:attributedText];
self.cachedSize = nil;
}
- (void)setTextColor:(nullable UIColor *)textColor
{
if ([NSObject isNullableObject:textColor equalTo:self.textColor]) {
return;
}
[super setTextColor:textColor];
// No need to clear cached size here.
}
- (void)setFont:(nullable UIFont *)font
{
if ([NSObject isNullableObject:font equalTo:self.font]) {
return;
}
[super setFont:font];
self.cachedSize = nil;
}
- (void)setLinkTextAttributes:(nullable NSDictionary<NSString *, id> *)linkTextAttributes
{
if ([NSObject isNullableObject:linkTextAttributes equalTo:self.linkTextAttributes]) {
return;
}
[super setLinkTextAttributes:linkTextAttributes];
self.cachedSize = nil;
}
- (CGSize)sizeThatFits:(CGSize)size
{
if (self.cachedSize) {
return self.cachedSize.CGSizeValue;
}
CGSize result = [super sizeThatFits:size];
self.cachedSize = [NSValue valueWithCGSize:result];
return result;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,50 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "OWSBubbleView.h"
NS_ASSUME_NONNULL_BEGIN
@class ConversationStyle;
@class DisplayableText;
@class OWSBubbleShapeView;
@class OWSQuotedReplyModel;
@class TSAttachmentPointer;
@class TSQuotedMessage;
@protocol OWSQuotedMessageViewDelegate
- (void)didTapQuotedReply:(OWSQuotedReplyModel *)quotedReply
failedThumbnailDownloadAttachmentPointer:(TSAttachmentPointer *)attachmentPointer;
- (void)didCancelQuotedReply;
@end
@interface OWSQuotedMessageView : UIView
@property (nonatomic, nullable, weak) id<OWSQuotedMessageViewDelegate> delegate;
- (instancetype)init NS_UNAVAILABLE;
// Only needs to be called if we're going to render this instance.
- (void)createContents;
// Measurement
- (CGSize)sizeForMaxWidth:(CGFloat)maxWidth;
// Factory method for "message bubble" views.
+ (OWSQuotedMessageView *)quotedMessageViewForConversation:(OWSQuotedReplyModel *)quotedMessage
displayableQuotedText:(nullable DisplayableText *)displayableQuotedText
conversationStyle:(ConversationStyle *)conversationStyle
isOutgoing:(BOOL)isOutgoing
sharpCorners:(OWSDirectionalRectCorner)sharpCorners;
// Factory method for "message compose" views.
+ (OWSQuotedMessageView *)quotedMessageViewForPreview:(OWSQuotedReplyModel *)quotedMessage
conversationStyle:(ConversationStyle *)conversationStyle;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,692 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "OWSQuotedMessageView.h"
#import "ConversationViewItem.h"
#import "Environment.h"
#import "OWSBubbleView.h"
#import "Session-Swift.h"
#import <SignalCoreKit/NSString+OWS.h>
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
#import <SignalUtilitiesKit/UIColor+OWS.h>
#import <SessionUtilitiesKit/UIView+OWS.h>
#import <SessionMessagingKit/TSAttachmentStream.h>
#import <SessionMessagingKit/TSMessage.h>
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
NS_ASSUME_NONNULL_BEGIN
const CGFloat kRemotelySourcedContentGlyphLength = 16;
const CGFloat kRemotelySourcedContentRowMargin = 4;
const CGFloat kRemotelySourcedContentRowSpacing = 4;
@interface OWSQuotedMessageView ()
@property (nonatomic, readonly) OWSQuotedReplyModel *quotedMessage;
@property (nonatomic, nullable, readonly) DisplayableText *displayableQuotedText;
@property (nonatomic, readonly) ConversationStyle *conversationStyle;
@property (nonatomic, readonly) BOOL isForPreview;
@property (nonatomic, readonly) BOOL isOutgoing;
@property (nonatomic, readonly) OWSDirectionalRectCorner sharpCorners;
@property (nonatomic, readonly) UILabel *quotedAuthorLabel;
@property (nonatomic, readonly) UILabel *quotedTextLabel;
@property (nonatomic, readonly) UILabel *quoteContentSourceLabel;
@end
#pragma mark -
@implementation OWSQuotedMessageView
+ (OWSQuotedMessageView *)quotedMessageViewForConversation:(OWSQuotedReplyModel *)quotedMessage
displayableQuotedText:(nullable DisplayableText *)displayableQuotedText
conversationStyle:(ConversationStyle *)conversationStyle
isOutgoing:(BOOL)isOutgoing
sharpCorners:(OWSDirectionalRectCorner)sharpCorners
{
OWSAssertDebug(quotedMessage);
return [[OWSQuotedMessageView alloc] initWithQuotedMessage:quotedMessage
displayableQuotedText:displayableQuotedText
conversationStyle:conversationStyle
isForPreview:NO
isOutgoing:isOutgoing
sharpCorners:sharpCorners];
}
+ (OWSQuotedMessageView *)quotedMessageViewForPreview:(OWSQuotedReplyModel *)quotedMessage
conversationStyle:(ConversationStyle *)conversationStyle
{
OWSAssertDebug(quotedMessage);
DisplayableText *_Nullable displayableQuotedText = nil;
if (quotedMessage.body.length > 0) {
displayableQuotedText = [DisplayableText displayableText:quotedMessage.body];
}
OWSQuotedMessageView *instance = [[OWSQuotedMessageView alloc]
initWithQuotedMessage:quotedMessage
displayableQuotedText:displayableQuotedText
conversationStyle:conversationStyle
isForPreview:YES
isOutgoing:YES
sharpCorners:(OWSDirectionalRectCornerBottomLeading | OWSDirectionalRectCornerBottomTrailing)];
[instance createContents];
return instance;
}
- (instancetype)initWithQuotedMessage:(OWSQuotedReplyModel *)quotedMessage
displayableQuotedText:(nullable DisplayableText *)displayableQuotedText
conversationStyle:(ConversationStyle *)conversationStyle
isForPreview:(BOOL)isForPreview
isOutgoing:(BOOL)isOutgoing
sharpCorners:(OWSDirectionalRectCorner)sharpCorners
{
self = [super init];
if (!self) {
return self;
}
OWSAssertDebug(quotedMessage);
_quotedMessage = quotedMessage;
_displayableQuotedText = displayableQuotedText;
_isForPreview = isForPreview;
_conversationStyle = conversationStyle;
_isOutgoing = isOutgoing;
_sharpCorners = sharpCorners;
_quotedAuthorLabel = [UILabel new];
_quotedTextLabel = [UILabel new];
_quoteContentSourceLabel = [UILabel new];
return self;
}
- (BOOL)hasQuotedAttachment
{
return (self.quotedMessage.contentType.length > 0
&& ![OWSMimeTypeOversizeTextMessage isEqualToString:self.quotedMessage.contentType]);
}
- (BOOL)hasQuotedAttachmentThumbnailImage
{
return (self.quotedMessage.contentType.length > 0
&& ![OWSMimeTypeOversizeTextMessage isEqualToString:self.quotedMessage.contentType] &&
[TSAttachmentStream hasThumbnailForMimeType:self.quotedMessage.contentType]);
}
- (UIColor *)highlightColor
{
BOOL isQuotingSelf = [NSObject isNullableObject:self.quotedMessage.authorId equalTo:TSAccountManager.localNumber];
return (isQuotingSelf ? [self.conversationStyle bubbleColorWithIsIncoming:NO]
: [self.conversationStyle quotingSelfHighlightColor]);
}
#pragma mark -
- (CGFloat)bubbleHMargin
{
return (self.isForPreview ? 0.f : 20.f);
}
- (CGFloat)hSpacing
{
return 8.f;
}
- (CGFloat)vSpacing
{
return 4.f;
}
- (CGFloat)stripeThickness
{
return LKValues.accentLineThickness;
}
- (UIColor *)quoteBubbleBackgroundColor
{
return [self.conversationStyle quotedReplyBubbleColorWithIsIncoming:!self.isOutgoing];
}
- (void)createContents
{
// Ensure only called once.
OWSAssertDebug(self.subviews.count < 1);
self.userInteractionEnabled = YES;
self.layoutMargins = UIEdgeInsetsZero;
self.clipsToBounds = YES;
CAShapeLayer *maskLayer = [CAShapeLayer new];
OWSDirectionalRectCorner sharpCorners = self.sharpCorners;
OWSLayerView *innerBubbleView = [[OWSLayerView alloc]
initWithFrame:CGRectZero
layoutCallback:^(UIView *layerView) {
CGRect layerFrame = layerView.bounds;
const CGFloat bubbleLeft = 0.f;
const CGFloat bubbleRight = layerFrame.size.width;
const CGFloat bubbleTop = 0.f;
const CGFloat bubbleBottom = layerFrame.size.height;
const CGFloat sharpCornerRadius = 2;
const CGFloat wideCornerRadius = self.isForPreview ? 14 : 4;
UIBezierPath *bezierPath = [OWSBubbleView roundedBezierRectWithBubbleTop:bubbleTop
bubbleLeft:bubbleLeft
bubbleBottom:bubbleBottom
bubbleRight:bubbleRight
sharpCornerRadius:sharpCornerRadius
wideCornerRadius:wideCornerRadius
sharpCorners:sharpCorners];
maskLayer.path = bezierPath.CGPath;
}];
innerBubbleView.layer.mask = maskLayer;
if (self.isForPreview) {
NSString *userHexEncodedPublicKey = [SNGeneralUtilities getUserPublicKey];
BOOL wasSentByUser = [self.quotedMessage.authorId isEqual:userHexEncodedPublicKey];
innerBubbleView.backgroundColor = [self.conversationStyle quotedReplyBubbleColorWithIsIncoming:wasSentByUser];
} else {
innerBubbleView.backgroundColor = self.quoteBubbleBackgroundColor;
}
[self addSubview:innerBubbleView];
[innerBubbleView autoPinLeadingToSuperviewMarginWithInset:self.bubbleHMargin];
[innerBubbleView autoPinTrailingToSuperviewMarginWithInset:self.bubbleHMargin];
[innerBubbleView autoPinTopToSuperviewMargin];
[innerBubbleView autoPinBottomToSuperviewMargin];
[innerBubbleView setContentHuggingHorizontalLow];
[innerBubbleView setCompressionResistanceHorizontalLow];
UIStackView *hStackView = [UIStackView new];
hStackView.axis = UILayoutConstraintAxisHorizontal;
hStackView.spacing = self.hSpacing;
UIView *stripeView = [UIView new];
if (self.isForPreview) {
stripeView.backgroundColor = LKColors.accent;
} else {
stripeView.backgroundColor = [self.conversationStyle quotedReplyStripeColorWithIsIncoming:!self.isOutgoing];
}
[stripeView autoSetDimension:ALDimensionWidth toSize:self.stripeThickness];
[stripeView setContentHuggingHigh];
[stripeView setCompressionResistanceHigh];
[hStackView addArrangedSubview:stripeView];
UIStackView *vStackView = [UIStackView new];
vStackView.axis = UILayoutConstraintAxisVertical;
vStackView.layoutMargins = UIEdgeInsetsMake(self.textVMargin, 0, self.textVMargin, 0);
vStackView.layoutMarginsRelativeArrangement = YES;
vStackView.spacing = self.vSpacing;
[vStackView setContentHuggingHorizontalLow];
[vStackView setCompressionResistanceHorizontalLow];
[hStackView addArrangedSubview:vStackView];
UILabel *quotedAuthorLabel = [self configureQuotedAuthorLabel];
[vStackView addArrangedSubview:quotedAuthorLabel];
[quotedAuthorLabel autoSetDimension:ALDimensionHeight toSize:self.quotedAuthorHeight];
[quotedAuthorLabel setContentHuggingVerticalHigh];
[quotedAuthorLabel setContentHuggingHorizontalLow];
[quotedAuthorLabel setCompressionResistanceHorizontalLow];
UILabel *quotedTextLabel = [self configureQuotedTextLabel];
[vStackView addArrangedSubview:quotedTextLabel];
[quotedTextLabel setContentHuggingHorizontalLow];
[quotedTextLabel setCompressionResistanceHorizontalLow];
[quotedTextLabel setCompressionResistanceVerticalHigh];
if (self.hasQuotedAttachment) {
UIView *_Nullable quotedAttachmentView = nil;
UIImage *_Nullable thumbnailImage = [self tryToLoadThumbnailImage];
if (thumbnailImage) {
quotedAttachmentView = [self imageViewForImage:thumbnailImage];
quotedAttachmentView.clipsToBounds = YES;
quotedAttachmentView.backgroundColor = [UIColor whiteColor];
if (self.isVideoAttachment) {
UIImage *contentIcon = [UIImage imageNamed:@"Play"];
contentIcon = [contentIcon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
UIImageView *contentImageView = [self imageViewForImage:contentIcon];
contentImageView.tintColor = LKColors.text;
[quotedAttachmentView addSubview:contentImageView];
[contentImageView autoSetDimension:ALDimensionWidth toSize:16];
[contentImageView autoSetDimension:ALDimensionHeight toSize:16];
[contentImageView autoCenterInSuperview];
}
} else if (self.quotedMessage.thumbnailDownloadFailed) {
// TODO design review icon and color
UIImage *contentIcon =
[[UIImage imageNamed:@"btnRefresh--white"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
UIImageView *contentImageView = [self imageViewForImage:contentIcon];
contentImageView.contentMode = UIViewContentModeScaleAspectFit;
contentImageView.tintColor = UIColor.whiteColor;
quotedAttachmentView = [UIView containerView];
[quotedAttachmentView addSubview:contentImageView];
quotedAttachmentView.backgroundColor = self.highlightColor;
[contentImageView autoCenterInSuperview];
[contentImageView
autoSetDimensionsToSize:CGSizeMake(self.quotedAttachmentSize * 0.5f, self.quotedAttachmentSize * 0.5f)];
UITapGestureRecognizer *tapGesture =
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didTapFailedThumbnailDownload:)];
[quotedAttachmentView addGestureRecognizer:tapGesture];
quotedAttachmentView.userInteractionEnabled = YES;
} else {
UIImage *contentIcon = [UIImage imageNamed:@"generic-attachment"];
UIImageView *contentImageView = [self imageViewForImage:contentIcon];
contentImageView.contentMode = UIViewContentModeScaleAspectFit;
UIView *wrapper = [UIView containerView];
[wrapper addSubview:contentImageView];
[contentImageView autoCenterInSuperview];
[contentImageView autoSetDimension:ALDimensionWidth toSize:self.quotedAttachmentSize * 0.5f];
quotedAttachmentView = wrapper;
}
[quotedAttachmentView autoPinToSquareAspectRatio];
[quotedAttachmentView setContentHuggingHigh];
[quotedAttachmentView setCompressionResistanceHigh];
[hStackView addArrangedSubview:quotedAttachmentView];
} else {
// If there's no attachment, add an empty view so that
// the stack view's spacing serves as a margin between
// the text views and the trailing edge.
UIView *emptyView = [UIView containerView];
[hStackView addArrangedSubview:emptyView];
[emptyView setContentHuggingHigh];
[emptyView autoSetDimension:ALDimensionWidth toSize:0.f];
}
UIView *contentView = hStackView;
[contentView setContentHuggingHorizontalLow];
[contentView setCompressionResistanceHorizontalLow];
if (self.quotedMessage.isRemotelySourced) {
UIStackView *quoteSourceWrapper = [[UIStackView alloc] initWithArrangedSubviews:@[
contentView,
[self buildRemoteContentSourceView],
]];
quoteSourceWrapper.axis = UILayoutConstraintAxisVertical;
contentView = quoteSourceWrapper;
[contentView setContentHuggingHorizontalLow];
[contentView setCompressionResistanceHorizontalLow];
}
if (self.isForPreview) {
UIButton *cancelButton = [UIButton buttonWithType:UIButtonTypeCustom];
UIColor *tintColor = [LKAppModeUtilities isLightMode] ? UIColor.blackColor : UIColor.whiteColor;
UIImage *cancelIcon = [[UIImage imageNamed:@"X"] asTintedImageWithColor:tintColor];
[cancelButton setImage:cancelIcon forState:UIControlStateNormal];
cancelButton.contentMode = UIViewContentModeScaleAspectFit;
[cancelButton addTarget:self action:@selector(didTapCancel) forControlEvents:UIControlEventTouchUpInside];
[cancelButton autoSetDimension:ALDimensionWidth toSize:14.f];
[cancelButton autoSetDimension:ALDimensionHeight toSize:14.f];
UIStackView *cancelStack = [[UIStackView alloc] initWithArrangedSubviews:@[ cancelButton ]];
cancelStack.axis = UILayoutConstraintAxisHorizontal;
cancelStack.alignment = UIStackViewAlignmentTop;
cancelStack.layoutMarginsRelativeArrangement = YES;
CGFloat hMarginLeading = 8;
CGFloat hMarginTrailing = 8;
cancelStack.layoutMargins = UIEdgeInsetsMake(8,
CurrentAppContext().isRTL ? hMarginTrailing : hMarginLeading,
0,
CurrentAppContext().isRTL ? hMarginLeading : hMarginTrailing);
UIStackView *cancelWrapper = [[UIStackView alloc] initWithArrangedSubviews:@[
contentView,
cancelStack,
]];
cancelWrapper.axis = UILayoutConstraintAxisHorizontal;
contentView = cancelWrapper;
[contentView setContentHuggingHorizontalLow];
[contentView setCompressionResistanceHorizontalLow];
}
[innerBubbleView addSubview:contentView];
[contentView ows_autoPinToSuperviewEdges];
}
- (UIView *)buildRemoteContentSourceView
{
UIImage *glyphImage =
[[UIImage imageNamed:@"ic_broken_link"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
OWSAssertDebug(glyphImage);
OWSAssertDebug(CGSizeEqualToSize(
CGSizeMake(kRemotelySourcedContentGlyphLength, kRemotelySourcedContentGlyphLength), glyphImage.size));
UIImageView *glyphView = [[UIImageView alloc] initWithImage:glyphImage];
glyphView.tintColor = LKColors.text;
[glyphView
autoSetDimensionsToSize:CGSizeMake(kRemotelySourcedContentGlyphLength, kRemotelySourcedContentGlyphLength)];
UILabel *label = [self configureQuoteContentSourceLabel];
UIStackView *sourceRow = [[UIStackView alloc] initWithArrangedSubviews:@[ glyphView, label ]];
sourceRow.axis = UILayoutConstraintAxisHorizontal;
sourceRow.alignment = UIStackViewAlignmentCenter;
// TODO verify spacing w/ design
sourceRow.spacing = kRemotelySourcedContentRowSpacing;
sourceRow.layoutMarginsRelativeArrangement = YES;
const CGFloat leftMargin = 4;
sourceRow.layoutMargins = UIEdgeInsetsMake(kRemotelySourcedContentRowMargin,
leftMargin,
kRemotelySourcedContentRowMargin,
kRemotelySourcedContentRowMargin);
UIColor *backgroundColor = LKAppModeUtilities.isLightMode ? [UIColor.whiteColor colorWithAlphaComponent:LKValues.unimportantElementOpacity] : [LKColors.text colorWithAlphaComponent:LKValues.unimportantElementOpacity];
[sourceRow addBackgroundViewWithBackgroundColor:backgroundColor];
return sourceRow;
}
- (void)didTapFailedThumbnailDownload:(UITapGestureRecognizer *)gestureRecognizer
{
OWSLogDebug(@"in didTapFailedThumbnailDownload");
if (!self.quotedMessage.thumbnailDownloadFailed) {
OWSFailDebug(@"thumbnailDownloadFailed was unexpectedly false");
return;
}
if (!self.quotedMessage.thumbnailAttachmentPointer) {
OWSFailDebug(@"thumbnailAttachmentPointer was unexpectedly nil");
return;
}
[self.delegate didTapQuotedReply:self.quotedMessage
failedThumbnailDownloadAttachmentPointer:self.quotedMessage.thumbnailAttachmentPointer];
}
- (nullable UIImage *)tryToLoadThumbnailImage
{
if (!self.hasQuotedAttachmentThumbnailImage) {
return nil;
}
// TODO: Possibly ignore data that is too large.
UIImage *_Nullable image = self.quotedMessage.thumbnailImage;
// TODO: Possibly ignore images that are too large.
return image;
}
- (UIImageView *)imageViewForImage:(UIImage *)image
{
OWSAssertDebug(image);
UIImageView *imageView = [UIImageView new];
imageView.image = image;
// We need to specify a contentMode since the size of the image
// might not match the aspect ratio of the view.
imageView.contentMode = UIViewContentModeScaleAspectFill;
// Use trilinear filters for better scaling quality at
// some performance cost.
imageView.layer.minificationFilter = kCAFilterTrilinear;
imageView.layer.magnificationFilter = kCAFilterTrilinear;
return imageView;
}
- (UILabel *)configureQuotedTextLabel
{
OWSAssertDebug(self.quotedTextLabel);
UIColor *textColor = self.quotedTextColor;
SUPPRESS_DEADSTORE_WARNING(textColor);
UIFont *font = self.quotedTextFont;
SUPPRESS_DEADSTORE_WARNING(font);
NSString *text = @"";
NSString *_Nullable fileTypeForSnippet = [self fileTypeForSnippet];
NSString *_Nullable sourceFilename = [self.quotedMessage.sourceFilename filterStringForDisplay];
if (self.displayableQuotedText.displayText.length > 0) {
text = self.displayableQuotedText.displayText;
textColor = self.quotedTextColor;
font = self.quotedTextFont;
} else if (fileTypeForSnippet) {
text = fileTypeForSnippet;
textColor = self.fileTypeTextColor;
font = self.fileTypeFont;
} else if (sourceFilename) {
text = sourceFilename;
textColor = self.filenameTextColor;
font = self.filenameFont;
} else {
text = NSLocalizedString(
@"QUOTED_REPLY_TYPE_ATTACHMENT", @"Indicates this message is a quoted reply to an attachment of unknown type.");
textColor = self.fileTypeTextColor;
font = self.fileTypeFont;
}
self.quotedTextLabel.numberOfLines = self.isForPreview ? 1 : 2;
self.quotedTextLabel.lineBreakMode = NSLineBreakByTruncatingTail;
self.quotedTextLabel.text = text;
self.quotedTextLabel.textColor = textColor;
self.quotedTextLabel.font = font;
return self.quotedTextLabel;
}
- (UILabel *)configureQuoteContentSourceLabel
{
OWSAssertDebug(self.quoteContentSourceLabel);
self.quoteContentSourceLabel.font = [UIFont systemFontOfSize:LKValues.smallFontSize];
self.quoteContentSourceLabel.textColor = LKColors.text;
self.quoteContentSourceLabel.text = NSLocalizedString(@"QUOTED_REPLY_CONTENT_FROM_REMOTE_SOURCE",
@"Footer label that appears below quoted messages when the quoted content was not derived locally. When the "
@"local user doesn't have a copy of the message being quoted, e.g. if it had since been deleted, we instead "
@"show the content specified by the sender.");
return self.quoteContentSourceLabel;
}
- (nullable NSString *)fileTypeForSnippet
{
// TODO: Are we going to use the filename? For all mimetypes?
NSString *_Nullable contentType = self.quotedMessage.contentType;
if (contentType.length < 1) {
return nil;
}
if ([MIMETypeUtil isAudio:contentType]) {
return NSLocalizedString(
@"QUOTED_REPLY_TYPE_AUDIO", @"Indicates this message is a quoted reply to an audio file.");
} else if ([MIMETypeUtil isVideo:contentType]) {
return NSLocalizedString(
@"QUOTED_REPLY_TYPE_VIDEO", @"Indicates this message is a quoted reply to a video file.");
} else if ([MIMETypeUtil isImage:contentType]) {
return NSLocalizedString(
@"QUOTED_REPLY_TYPE_IMAGE", @"Indicates this message is a quoted reply to an image file.");
} else if ([MIMETypeUtil isAnimated:contentType]) {
return NSLocalizedString(
@"QUOTED_REPLY_TYPE_GIF", @"Indicates this message is a quoted reply to animated GIF file.");
}
return nil;
}
- (BOOL)isAudioAttachment
{
// TODO: Are we going to use the filename? For all mimetypes?
NSString *_Nullable contentType = self.quotedMessage.contentType;
if (contentType.length < 1) {
return NO;
}
return [MIMETypeUtil isAudio:contentType];
}
- (BOOL)isVideoAttachment
{
// TODO: Are we going to use the filename? For all mimetypes?
NSString *_Nullable contentType = self.quotedMessage.contentType;
if (contentType.length < 1) {
return NO;
}
return [MIMETypeUtil isVideo:contentType];
}
- (UILabel *)configureQuotedAuthorLabel
{
OWSAssertDebug(self.quotedAuthorLabel);
NSString *_Nullable localNumber = [TSAccountManager localNumber];
NSString *quotedAuthorText;
if ([localNumber isEqualToString:self.quotedMessage.authorId]) {
quotedAuthorText = NSLocalizedString(@"You", @"");
} else {
__block NSString *quotedAuthor = [SSKEnvironment.shared.profileManager profileNameForRecipientWithID:self.quotedMessage.authorId] ?: self.quotedMessage.authorId;
if (quotedAuthor == self.quotedMessage.authorId) {
SNOpenGroup *openGroup = [LKStorage.shared getOpenGroupForThreadID:self.quotedMessage.threadId];
[OWSPrimaryStorage.sharedManager.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
if (openGroup != nil) {
quotedAuthor = [LKUserDisplayNameUtilities getPublicChatDisplayNameFor:self.quotedMessage.authorId in:openGroup.channel on:openGroup.server using:transaction];
}
}];
}
quotedAuthorText = quotedAuthor;
}
self.quotedAuthorLabel.text = quotedAuthorText;
self.quotedAuthorLabel.font = self.quotedAuthorFont;
// TODO:
self.quotedAuthorLabel.textColor = [self quotedAuthorColor];
self.quotedAuthorLabel.lineBreakMode = NSLineBreakByTruncatingTail;
self.quotedAuthorLabel.numberOfLines = 1;
return self.quotedAuthorLabel;
}
#pragma mark - Measurement
- (CGFloat)textVMargin
{
return 7.f;
}
- (CGSize)sizeForMaxWidth:(CGFloat)maxWidth
{
CGSize result = CGSizeZero;
result.width += self.bubbleHMargin * 2 + self.stripeThickness + self.hSpacing * 2;
CGFloat thumbnailHeight = 0.f;
if (self.hasQuotedAttachment) {
result.width += self.quotedAttachmentSize;
thumbnailHeight += self.quotedAttachmentSize;
}
// Quoted Author
CGFloat textWidth = 0.f;
CGFloat maxTextWidth = maxWidth - result.width;
CGFloat textHeight = self.textVMargin * 2 + self.quotedAuthorHeight + self.vSpacing;
{
UILabel *quotedAuthorLabel = [self configureQuotedAuthorLabel];
CGSize quotedAuthorSize = CGSizeCeil([quotedAuthorLabel sizeThatFits:CGSizeMake(maxTextWidth, CGFLOAT_MAX)]);
textWidth = quotedAuthorSize.width;
}
{
UILabel *quotedTextLabel = [self configureQuotedTextLabel];
CGSize textSize = CGSizeCeil([quotedTextLabel sizeThatFits:CGSizeMake(maxTextWidth, CGFLOAT_MAX)]);
textWidth = MAX(textWidth, textSize.width);
textHeight += textSize.height;
}
if (self.quotedMessage.isRemotelySourced) {
UILabel *quoteContentSourceLabel = [self configureQuoteContentSourceLabel];
CGSize textSize = CGSizeCeil([quoteContentSourceLabel sizeThatFits:CGSizeMake(maxTextWidth, CGFLOAT_MAX)]);
CGFloat sourceStackViewHeight = MAX(kRemotelySourcedContentGlyphLength, textSize.height);
textWidth
= MAX(textWidth, textSize.width + kRemotelySourcedContentGlyphLength + kRemotelySourcedContentRowSpacing);
result.height += kRemotelySourcedContentRowMargin * 2 + sourceStackViewHeight;
}
textWidth = MIN(textWidth, maxTextWidth);
result.width += textWidth;
result.height += MAX(textHeight, thumbnailHeight);
return CGSizeCeil(result);
}
- (UIFont *)quotedAuthorFont
{
return [UIFont boldSystemFontOfSize:LKValues.smallFontSize];
}
- (UIColor *)quotedAuthorColor
{
return [self.conversationStyle quotedReplyAuthorColor];
}
- (UIColor *)quotedTextColor
{
return [self.conversationStyle quotedReplyTextColor];
}
- (UIFont *)quotedTextFont
{
return [UIFont systemFontOfSize:LKValues.smallFontSize];
}
- (UIColor *)fileTypeTextColor
{
return [self.conversationStyle quotedReplyAttachmentColor];
}
- (UIFont *)fileTypeFont
{
return self.quotedTextFont;
}
- (UIColor *)filenameTextColor
{
return [self.conversationStyle quotedReplyAttachmentColor];
}
- (UIFont *)filenameFont
{
return self.quotedTextFont;
}
- (CGFloat)quotedAuthorHeight
{
return (CGFloat)ceil([self quotedAuthorFont].lineHeight * 1.f);
}
- (CGFloat)quotedAttachmentSize
{
return 54.f;
}
#pragma mark -
- (CGSize)sizeThatFits:(CGSize)size
{
return [self sizeForMaxWidth:CGFLOAT_MAX];
}
- (void)didTapCancel
{
[self.delegate didCancelQuotedReply];
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,15 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "ConversationViewCell.h"
NS_ASSUME_NONNULL_BEGIN
@interface OWSSystemMessageCell : ConversationViewCell
+ (NSString *)cellReuseIdentifier;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,510 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSSystemMessageCell.h"
#import "ConversationViewItem.h"
#import "OWSMessageHeaderView.h"
#import "Session-Swift.h"
#import "UIColor+OWS.h"
#import "UIFont+OWS.h"
#import "UIView+OWS.h"
#import <SessionMessagingKit/OWSDisappearingConfigurationUpdateInfoMessage.h>
#import <SessionMessagingKit/Environment.h>
#import <SessionMessagingKit/TSErrorMessage.h>
#import <SessionMessagingKit/TSInfoMessage.h>
NS_ASSUME_NONNULL_BEGIN
typedef void (^SystemMessageActionBlock)(void);
@interface SystemMessageAction : NSObject
@property (nonatomic) NSString *title;
@property (nonatomic) SystemMessageActionBlock block;
@end
#pragma mark -
@implementation SystemMessageAction
+ (SystemMessageAction *)actionWithTitle:(NSString *)title block:(SystemMessageActionBlock)block
{
SystemMessageAction *action = [SystemMessageAction new];
action.title = title;
action.block = block;
return action;
}
@end
#pragma mark -
@interface OWSSystemMessageCell ()
@property (nonatomic) UIImageView *iconView;
@property (nonatomic) UILabel *titleLabel;
@property (nonatomic) UIButton *button;
@property (nonatomic) UIStackView *vStackView;
@property (nonatomic) UIView *cellBackgroundView;
@property (nonatomic) OWSMessageHeaderView *headerView;
@property (nonatomic) NSLayoutConstraint *headerViewHeightConstraint;
@property (nonatomic) NSArray<NSLayoutConstraint *> *layoutConstraints;
@property (nonatomic, nullable) SystemMessageAction *action;
@end
#pragma mark -
@implementation OWSSystemMessageCell
// `[UIView init]` invokes `[self initWithFrame:...]`.
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
[self commonInit];
}
return self;
}
- (void)commonInit
{
OWSAssertDebug(!self.iconView);
self.layoutMargins = UIEdgeInsetsZero;
self.contentView.layoutMargins = UIEdgeInsetsZero;
self.layoutConstraints = @[];
self.headerView = [OWSMessageHeaderView new];
self.headerViewHeightConstraint = [self.headerView autoSetDimension:ALDimensionHeight toSize:0];
self.iconView = [UIImageView new];
[self.iconView autoSetDimension:ALDimensionWidth toSize:self.iconSize];
[self.iconView autoSetDimension:ALDimensionHeight toSize:self.iconSize];
[self.iconView setContentHuggingHigh];
self.titleLabel = [UILabel new];
self.titleLabel.numberOfLines = 0;
self.titleLabel.lineBreakMode = NSLineBreakByWordWrapping;
self.titleLabel.textAlignment = NSTextAlignmentCenter;
UIStackView *contentStackView = [[UIStackView alloc] initWithArrangedSubviews:@[
self.iconView,
self.titleLabel,
]];
contentStackView.axis = UILayoutConstraintAxisVertical;
contentStackView.spacing = self.iconVSpacing;
contentStackView.alignment = UIStackViewAlignmentCenter;
self.button = [UIButton new];
[self.button setTitleColor:[UIColor ows_darkSkyBlueColor] forState:UIControlStateNormal];
self.button.titleLabel.textAlignment = NSTextAlignmentCenter;
self.button.layer.cornerRadius = LKValues.modalButtonCornerRadius;
self.button.backgroundColor = LKColors.buttonBackground;
self.button.titleLabel.font = [UIFont systemFontOfSize:LKValues.smallFontSize];
[self.button addTarget:self action:@selector(buttonWasPressed:) forControlEvents:UIControlEventTouchUpInside];
[self.button autoSetDimension:ALDimensionHeight toSize:self.buttonHeight];
self.vStackView = [[UIStackView alloc] initWithArrangedSubviews:@[
contentStackView,
self.button,
]];
self.vStackView.axis = UILayoutConstraintAxisVertical;
self.vStackView.spacing = self.buttonVSpacing;
self.vStackView.alignment = UIStackViewAlignmentCenter;
self.vStackView.layoutMarginsRelativeArrangement = YES;
self.cellBackgroundView = [UIView new];
self.cellBackgroundView.layer.cornerRadius = 5.f;
[self.contentView addSubview:self.cellBackgroundView];
UIStackView *cellStackView = [[UIStackView alloc] initWithArrangedSubviews:@[ self.headerView, self.vStackView ]];
cellStackView.axis = UILayoutConstraintAxisVertical;
[self.contentView addSubview:cellStackView];
[cellStackView autoPinEdgesToSuperviewEdges];
UILongPressGestureRecognizer *longPress =
[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPressGesture:)];
[self addGestureRecognizer:longPress];
}
- (CGFloat)buttonVSpacing
{
return 12.f;
}
- (CGFloat)iconVSpacing
{
return LKValues.smallSpacing;
}
- (CGFloat)buttonHeight
{
return LKValues.mediumButtonHeight;
}
- (CGFloat)buttonHPadding
{
return 20.f;
}
- (void)configureFonts
{
// Update cell to reflect changes in dynamic text.
self.titleLabel.font = [UIFont systemFontOfSize:LKValues.smallFontSize];
}
+ (NSString *)cellReuseIdentifier
{
return NSStringFromClass([self class]);
}
- (void)loadForDisplay
{
OWSAssertDebug(self.conversationStyle);
OWSAssertDebug(self.viewItem);
self.cellBackgroundView.backgroundColor = UIColor.clearColor;
[self.button setBackgroundColor:LKColors.buttonBackground];
TSInteraction *interaction = self.viewItem.interaction;
self.action = [self actionForInteraction:interaction];
UIImage *_Nullable icon = [self iconForInteraction:interaction];
if (icon) {
self.iconView.image = [icon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
self.iconView.hidden = NO;
self.iconView.tintColor = LKColors.text;
} else {
self.iconView.hidden = YES;
}
self.titleLabel.textColor = [self textColor];
[self applyTitleForInteraction:interaction label:self.titleLabel];
CGSize titleSize = [self titleSize];
if (self.action) {
[self.button setTitle:self.action.title forState:UIControlStateNormal];
UIFont *buttonFont = [UIFont systemFontOfSize:LKValues.smallFontSize];
self.button.titleLabel.font = buttonFont;
[self.button setTitleColor:LKColors.text forState:UIControlStateNormal];
self.button.hidden = NO;
} else {
self.button.hidden = YES;
}
CGSize buttonSize = [self.button sizeThatFits:CGSizeZero];
[NSLayoutConstraint deactivateConstraints:self.layoutConstraints];
if (self.viewItem.hasCellHeader) {
self.headerView.hidden = NO;
CGFloat headerHeight =
[self.headerView measureWithConversationViewItem:self.viewItem conversationStyle:self.conversationStyle]
.height;
[self.headerView loadForDisplayWithViewItem:self.viewItem conversationStyle:self.conversationStyle];
self.headerViewHeightConstraint.constant = headerHeight;
} else {
self.headerView.hidden = YES;
}
self.vStackView.layoutMargins = UIEdgeInsetsMake(self.topVMargin,
self.conversationStyle.fullWidthGutterLeading,
self.bottomVMargin,
self.conversationStyle.fullWidthGutterLeading);
self.layoutConstraints = @[
[self.titleLabel autoSetDimension:ALDimensionWidth toSize:titleSize.width],
[self.button autoSetDimension:ALDimensionWidth toSize:buttonSize.width + self.buttonHPadding * 2.f],
[self.cellBackgroundView autoPinEdge:ALEdgeTop toEdge:ALEdgeTop ofView:self.vStackView],
[self.cellBackgroundView autoPinEdge:ALEdgeBottom toEdge:ALEdgeBottom ofView:self.vStackView],
// Text in vStackView might flow right up to the edges, so only use half the gutter.
[self.cellBackgroundView autoPinEdgeToSuperviewEdge:ALEdgeLeading
withInset:self.conversationStyle.fullWidthGutterLeading * 0.5f],
[self.cellBackgroundView autoPinEdgeToSuperviewEdge:ALEdgeTrailing
withInset:self.conversationStyle.fullWidthGutterTrailing * 0.5f],
];
}
- (UIColor *)textColor
{
return LKColors.text;
}
- (UIColor *)iconColorForInteraction:(TSInteraction *)interaction
{
return LKColors.text;
}
- (nullable UIImage *)iconForInteraction:(TSInteraction *)interaction
{
UIImage *result = nil;
if ([interaction isKindOfClass:[TSErrorMessage class]]) {
switch (((TSErrorMessage *)interaction).errorType) {
case TSErrorMessageNonBlockingIdentityChange:
case TSErrorMessageWrongTrustedIdentityKey:
result = [UIImage imageNamed:@"system_message_security"];
break;
case TSErrorMessageInvalidKeyException:
case TSErrorMessageMissingKeyId:
case TSErrorMessageNoSession:
case TSErrorMessageInvalidMessage:
case TSErrorMessageDuplicateMessage:
case TSErrorMessageInvalidVersion:
case TSErrorMessageUnknownContactBlockOffer:
case TSErrorMessageGroupCreationFailed:
return nil;
}
} else if ([interaction isKindOfClass:[TSInfoMessage class]]) {
switch (((TSInfoMessage *)interaction).messageType) {
case TSInfoMessageUserNotRegistered:
case TSInfoMessageTypeSessionDidEnd:
case TSInfoMessageTypeUnsupportedMessage:
case TSInfoMessageAddToContactsOffer:
case TSInfoMessageAddUserToProfileWhitelistOffer:
case TSInfoMessageAddGroupToProfileWhitelistOffer:
case TSInfoMessageTypeGroupUpdate:
case TSInfoMessageTypeGroupQuit:
return nil;
case TSInfoMessageTypeDisappearingMessagesUpdate: {
BOOL areDisappearingMessagesEnabled = YES;
if ([interaction isKindOfClass:[OWSDisappearingConfigurationUpdateInfoMessage class]]) {
areDisappearingMessagesEnabled
= ((OWSDisappearingConfigurationUpdateInfoMessage *)interaction).configurationIsEnabled;
} else {
OWSFailDebug(@"unexpected interaction type: %@", interaction.class);
}
result = (areDisappearingMessagesEnabled
? [UIImage imageNamed:@"system_message_disappearing_messages"]
: [UIImage imageNamed:@"system_message_disappearing_messages_disabled"]);
break;
}
}
} else {
OWSFailDebug(@"Unknown interaction type: %@", [interaction class]);
return nil;
}
OWSAssertDebug(result);
return result;
}
- (void)applyTitleForInteraction:(TSInteraction *)interaction
label:(UILabel *)label
{
OWSAssertDebug(interaction);
OWSAssertDebug(label);
OWSAssertDebug(self.viewItem.systemMessageText.length > 0);
[self configureFonts];
label.text = self.viewItem.systemMessageText;
}
- (CGFloat)topVMargin
{
return LKValues.smallSpacing;
}
- (CGFloat)bottomVMargin
{
return LKValues.smallSpacing;
}
- (CGFloat)hSpacing
{
return LKValues.mediumSpacing;
}
- (CGFloat)iconSize
{
return 20.f;
}
- (CGSize)titleSize
{
OWSAssertDebug(self.conversationStyle);
OWSAssertDebug(self.viewItem);
CGFloat maxTitleWidth = (CGFloat)floor(self.conversationStyle.fullWidthContentWidth);
return [self.titleLabel sizeThatFits:CGSizeMake(maxTitleWidth, CGFLOAT_MAX)];
}
- (CGSize)cellSize
{
OWSAssertDebug(self.conversationStyle);
OWSAssertDebug(self.viewItem);
TSInteraction *interaction = self.viewItem.interaction;
CGSize result = CGSizeMake(self.conversationStyle.viewWidth, 0);
if (self.viewItem.hasCellHeader) {
result.height +=
[self.headerView measureWithConversationViewItem:self.viewItem conversationStyle:self.conversationStyle]
.height;
}
UIImage *_Nullable icon = [self iconForInteraction:interaction];
if (icon) {
result.height += self.iconSize + self.iconVSpacing;
}
[self applyTitleForInteraction:interaction label:self.titleLabel];
CGSize titleSize = [self titleSize];
result.height += titleSize.height;
SystemMessageAction *_Nullable action = [self actionForInteraction:interaction];
if (action) {
result.height += self.buttonHeight + self.buttonVSpacing;
}
result.height += self.topVMargin + self.bottomVMargin;
return result;
}
#pragma mark - Actions
- (nullable SystemMessageAction *)actionForInteraction:(TSInteraction *)interaction
{
OWSAssertIsOnMainThread();
OWSAssertDebug(interaction);
if ([interaction isKindOfClass:[TSErrorMessage class]]) {
return [self actionForErrorMessage:(TSErrorMessage *)interaction];
} else if ([interaction isKindOfClass:[TSInfoMessage class]]) {
return [self actionForInfoMessage:(TSInfoMessage *)interaction];
} else {
OWSFailDebug(@"Tap for system messages of unknown type: %@", [interaction class]);
return nil;
}
}
- (nullable SystemMessageAction *)actionForErrorMessage:(TSErrorMessage *)message
{
OWSAssertDebug(message);
__weak OWSSystemMessageCell *weakSelf = self;
switch (message.errorType) {
case TSErrorMessageInvalidKeyException:
return nil;
case TSErrorMessageMissingKeyId:
case TSErrorMessageNoSession:
case TSErrorMessageInvalidMessage:
return nil;
case TSErrorMessageDuplicateMessage:
case TSErrorMessageInvalidVersion:
return nil;
case TSErrorMessageUnknownContactBlockOffer:
OWSFailDebug(@"TSErrorMessageUnknownContactBlockOffer");
return nil;
case TSErrorMessageGroupCreationFailed:
return [SystemMessageAction actionWithTitle:CommonStrings.retryButton
block:^{
[weakSelf.delegate resendGroupUpdateForErrorMessage:message];
}];
}
OWSLogWarn(@"Unhandled tap for error message:%@", message);
return nil;
}
- (nullable SystemMessageAction *)actionForInfoMessage:(TSInfoMessage *)message
{
OWSAssertDebug(message);
__weak OWSSystemMessageCell *weakSelf = self;
switch (message.messageType) {
case TSInfoMessageUserNotRegistered:
case TSInfoMessageTypeSessionDidEnd:
return nil;
case TSInfoMessageTypeUnsupportedMessage:
// Unused.
return nil;
case TSInfoMessageAddToContactsOffer:
// Unused.
OWSFailDebug(@"TSInfoMessageAddToContactsOffer");
return nil;
case TSInfoMessageAddUserToProfileWhitelistOffer:
// Unused.
OWSFailDebug(@"TSInfoMessageAddUserToProfileWhitelistOffer");
return nil;
case TSInfoMessageAddGroupToProfileWhitelistOffer:
// Unused.
OWSFailDebug(@"TSInfoMessageAddGroupToProfileWhitelistOffer");
return nil;
case TSInfoMessageTypeGroupUpdate:
return nil;
case TSInfoMessageTypeGroupQuit:
return nil;
case TSInfoMessageTypeDisappearingMessagesUpdate:
return [SystemMessageAction actionWithTitle:NSLocalizedString(@"CONVERSATION_SETTINGS_TAP_TO_CHANGE",
@"Label for button that opens conversation settings.")
block:^{
[weakSelf.delegate showConversationSettings];
}];
}
OWSLogInfo(@"Unhandled tap for info message: %@", message);
return nil;
}
#pragma mark - Events
- (void)handleLongPressGesture:(UILongPressGestureRecognizer *)longPress
{
OWSAssertDebug(self.delegate);
if ([self isGestureInCellHeader:longPress]) {
return;
}
TSInteraction *interaction = self.viewItem.interaction;
OWSAssertDebug(interaction);
if (longPress.state == UIGestureRecognizerStateBegan) {
[self.delegate conversationCell:self didLongpressSystemMessageViewItem:self.viewItem];
}
}
- (BOOL)isGestureInCellHeader:(UIGestureRecognizer *)sender
{
OWSAssertDebug(self.viewItem);
if (!self.viewItem.hasCellHeader) {
return NO;
}
CGPoint location = [sender locationInView:self];
CGPoint headerBottom = [self convertPoint:CGPointMake(0, self.headerView.height) fromView:self.headerView];
return location.y <= headerBottom.y;
}
- (void)buttonWasPressed:(id)sender
{
if (!self.action.block) {
OWSFailDebug(@"Missing action");
} else {
self.action.block();
}
}
#pragma mark - Reuse
- (void)prepareForReuse
{
[super prepareForReuse];
self.action = nil;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -1,98 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
@objc
protocol QuotedReplyPreviewDelegate: class {
func quotedReplyPreviewDidPressCancel(_ preview: QuotedReplyPreview)
}
@objc
class QuotedReplyPreview: UIView, OWSQuotedMessageViewDelegate {
@objc
public weak var delegate: QuotedReplyPreviewDelegate?
private let quotedReply: OWSQuotedReplyModel
private let conversationStyle: ConversationStyle
private var quotedMessageView: OWSQuotedMessageView?
private var heightConstraint: NSLayoutConstraint!
@available(*, unavailable, message:"use other constructor instead.")
required init(coder aDecoder: NSCoder) {
notImplemented()
}
@available(*, unavailable, message:"use other constructor instead.")
override init(frame: CGRect) {
notImplemented()
}
@objc
init(quotedReply: OWSQuotedReplyModel, conversationStyle: ConversationStyle) {
self.quotedReply = quotedReply
self.conversationStyle = conversationStyle
super.init(frame: .zero)
self.heightConstraint = self.autoSetDimension(.height, toSize: 0)
updateContents()
NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil)
}
private let draftMarginTop: CGFloat = 6
func updateContents() {
subviews.forEach { $0.removeFromSuperview() }
let hMargin: CGFloat = 6
self.layoutMargins = UIEdgeInsets(top: draftMarginTop,
left: hMargin,
bottom: 0,
right: hMargin)
// We instantiate quotedMessageView late to ensure that it is updated
// every time contentSizeCategoryDidChange (i.e. when dynamic type
// sizes changes).
let quotedMessageView = OWSQuotedMessageView(forPreview: quotedReply, conversationStyle: conversationStyle)
quotedMessageView.delegate = self
self.quotedMessageView = quotedMessageView
quotedMessageView.setContentHuggingHorizontalLow()
quotedMessageView.setCompressionResistanceHorizontalLow()
quotedMessageView.backgroundColor = .clear
self.addSubview(quotedMessageView)
quotedMessageView.ows_autoPinToSuperviewMargins()
updateHeight()
}
// MARK: Sizing
func updateHeight() {
guard let quotedMessageView = quotedMessageView else {
owsFailDebug("missing quotedMessageView")
return
}
let size = quotedMessageView.size(forMaxWidth: CGFloat.infinity)
self.heightConstraint.constant = size.height + draftMarginTop
}
@objc func contentSizeCategoryDidChange(_ notification: Notification) {
Logger.debug("")
updateContents()
}
// MARK: - OWSQuotedMessageViewDelegate
@objc public func didTapQuotedReply(_ quotedReply: OWSQuotedReplyModel, failedThumbnailDownloadAttachmentPointer attachmentPointer: TSAttachmentPointer) {
// Do nothing.
}
@objc public func didCancelQuotedReply() {
self.delegate?.quotedReplyPreviewDidPressCancel(self)
}
}

View File

@ -1,149 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
import Foundation
@objc(OWSTypingIndicatorCell)
public class TypingIndicatorCell: ConversationViewCell {
@objc
public static let cellReuseIdentifier = "TypingIndicatorCell"
@available(*, unavailable, message:"use other constructor instead.")
@objc
public required init(coder aDecoder: NSCoder) {
notImplemented()
}
private let kAvatarSize: CGFloat = 36
private let kAvatarHSpacing: CGFloat = 8
// private let avatarView = AvatarImageView()
private let bubbleView = OWSBubbleView()
private let typingIndicatorView = TypingIndicatorView()
private var viewConstraints = [NSLayoutConstraint]()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
private func commonInit() {
self.layoutMargins = .zero
self.contentView.layoutMargins = .zero
bubbleView.layoutMargins = .zero
bubbleView.addSubview(typingIndicatorView)
contentView.addSubview(bubbleView)
// avatarView.autoSetDimension(.width, toSize: kAvatarSize)
// avatarView.autoSetDimension(.height, toSize: kAvatarSize)
}
@objc
public override func loadForDisplay() {
guard let conversationStyle = self.conversationStyle else {
owsFailDebug("Missing conversationStyle")
return
}
bubbleView.bubbleColor = conversationStyle.bubbleColor(isIncoming: true)
typingIndicatorView.startAnimation()
viewConstraints.append(contentsOf: [
bubbleView.autoPinEdge(toSuperviewEdge: .leading, withInset: conversationStyle.gutterLeading),
bubbleView.autoPinEdge(toSuperviewEdge: .trailing, withInset: conversationStyle.gutterTrailing, relation: .greaterThanOrEqual),
bubbleView.autoPinTopToSuperviewMargin(withInset: 0),
bubbleView.autoPinBottomToSuperviewMargin(withInset: 0),
typingIndicatorView.autoPinEdge(toSuperviewEdge: .leading, withInset: conversationStyle.textInsetHorizontal),
typingIndicatorView.autoPinEdge(toSuperviewEdge: .trailing, withInset: conversationStyle.textInsetHorizontal),
typingIndicatorView.autoPinTopToSuperviewMargin(withInset: conversationStyle.textInsetTop),
typingIndicatorView.autoPinBottomToSuperviewMargin(withInset: conversationStyle.textInsetBottom)
])
// if let avatarView = configureAvatarView() {
// contentView.addSubview(avatarView)
// viewConstraints.append(contentsOf: [
// bubbleView.autoPinLeading(toTrailingEdgeOf: avatarView, offset: kAvatarHSpacing),
// bubbleView.autoAlignAxis(.horizontal, toSameAxisOf: avatarView)
// ])
//
// } else {
// avatarView.removeFromSuperview()
// }
}
private func configureAvatarView() -> UIView? {
guard let viewItem = self.viewItem else {
owsFailDebug("Missing viewItem")
return nil
}
guard let typingIndicators = viewItem.interaction as? TypingIndicatorInteraction else {
owsFailDebug("Missing typingIndicators")
return nil
}
guard shouldShowAvatar() else {
return nil
}
guard let colorName = viewItem.authorConversationColorName else {
owsFailDebug("Missing authorConversationColorName")
return nil
}
// guard let authorAvatarImage =
// OWSContactAvatarBuilder(signalId: typingIndicators.recipientId,
// colorName: ConversationColorName(rawValue: colorName),
// diameter: UInt(kAvatarSize)).build() else {
// owsFailDebug("Could build avatar image")
// return nil
// }
// avatarView.image = authorAvatarImage
// return avatarView
return UIView()
}
private func shouldShowAvatar() -> Bool {
guard let viewItem = self.viewItem else {
owsFailDebug("Missing viewItem")
return false
}
return viewItem.isGroupThread
}
@objc
public override func cellSize() -> CGSize {
guard let conversationStyle = self.conversationStyle else {
owsFailDebug("Missing conversationStyle")
return .zero
}
let insetsSize = CGSize(width: conversationStyle.textInsetHorizontal * 2,
height: conversationStyle.textInsetTop + conversationStyle.textInsetBottom)
let typingIndicatorSize = typingIndicatorView.sizeThatFits(.zero)
let bubbleSize = CGSizeAdd(insetsSize, typingIndicatorSize)
if shouldShowAvatar() {
return CGSizeCeil(CGSize(width: kAvatarSize + kAvatarHSpacing + bubbleSize.width,
height: max(kAvatarSize, bubbleSize.height)))
} else {
return CGSizeCeil(CGSize(width: bubbleSize.width,
height: max(kAvatarSize, bubbleSize.height)))
}
}
@objc
public override func prepareForReuse() {
super.prepareForReuse()
NSLayoutConstraint.deactivate(viewConstraints)
viewConstraints = [NSLayoutConstraint]()
// avatarView.image = nil
// avatarView.removeFromSuperview()
typingIndicatorView.stopAnimation()
}
}

View File

@ -1,214 +0,0 @@
import Accelerate
import NVActivityIndicatorView
@objc(LKVoiceMessageView)
final class VoiceMessageView : UIView {
private let voiceMessage: TSAttachment
private let isOutgoing: Bool
private var isLoading = false
private var isForcedAnimation = false
private var volumeSamples: [Float] = [] { didSet { updateShapeLayers() } }
@objc var progress: CGFloat = 0 { didSet { updateShapeLayers() } }
@objc var duration: Int = 0 { didSet { updateDurationLabel() } }
@objc var isPlaying = false { didSet { updateToggleImageView() } }
// MARK: Components
private lazy var toggleImageView = UIImageView(image: #imageLiteral(resourceName: "Play"))
private lazy var spinner = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: .black, padding: nil)
private lazy var durationLabel: UILabel = {
let result = UILabel()
result.textColor = Colors.text
result.font = .systemFont(ofSize: Values.mediumFontSize)
return result
}()
private lazy var backgroundShapeLayer: CAShapeLayer = {
let result = CAShapeLayer()
result.fillColor = Colors.text.cgColor
return result
}()
private lazy var foregroundShapeLayer: CAShapeLayer = {
let result = CAShapeLayer()
result.fillColor = (isLightMode && isOutgoing) ? UIColor.white.cgColor : Colors.accent.cgColor
return result
}()
// MARK: Settings
private let leadingInset: CGFloat = 0
private let sampleSpacing: CGFloat = 1
private let targetSampleCount = 48
private let toggleContainerSize: CGFloat = 32
private let vMargin: CGFloat = 0
@objc public static let contentHeight: CGFloat = 40
// MARK: Initialization
@objc(initWithVoiceMessage:isOutgoing:)
init(voiceMessage: TSAttachment, isOutgoing: Bool) {
self.voiceMessage = voiceMessage
self.isOutgoing = isOutgoing
super.init(frame: CGRect.zero)
}
override init(frame: CGRect) {
preconditionFailure("Use init(voiceMessage:associatedWith:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(voiceMessage:associatedWith:) instead.")
}
@objc func initialize() {
setUpViewHierarchy()
if voiceMessage.isDownloaded {
guard let url = (voiceMessage as? TSAttachmentStream)?.originalMediaURL else {
return SNLog("Couldn't get URL for voice message.")
}
if let cachedVolumeSamples = Storage.shared.getVolumeSamples(for: voiceMessage.uniqueId!), cachedVolumeSamples.count == targetSampleCount {
self.hideLoader()
self.volumeSamples = cachedVolumeSamples
} else {
let voiceMessageID = voiceMessage.uniqueId!
AudioUtilities.getVolumeSamples(for: url, targetSampleCount: targetSampleCount).done(on: DispatchQueue.main) { [weak self] volumeSamples in
guard let self = self else { return }
self.hideLoader()
self.isForcedAnimation = true
self.volumeSamples = volumeSamples
Storage.write { transaction in
Storage.shared.setVolumeSamples(for: voiceMessageID, to: volumeSamples, using: transaction)
}
}.catch(on: DispatchQueue.main) { error in
SNLog("Couldn't sample audio file due to error: \(error).")
}
}
} else {
showLoader()
}
}
private func setUpViewHierarchy() {
set(.width, to: 200)
set(.height, to: VoiceMessageView.contentHeight)
layer.insertSublayer(backgroundShapeLayer, at: 0)
layer.insertSublayer(foregroundShapeLayer, at: 1)
let toggleContainer = UIView()
toggleContainer.clipsToBounds = false
toggleContainer.addSubview(toggleImageView)
toggleImageView.set(.width, to: 12)
toggleImageView.set(.height, to: 12)
toggleImageView.center(in: toggleContainer)
toggleContainer.addSubview(spinner)
spinner.set(.width, to: 24)
spinner.set(.height, to: 24)
spinner.center(in: toggleContainer)
toggleContainer.set(.width, to: toggleContainerSize)
toggleContainer.set(.height, to: toggleContainerSize)
toggleContainer.layer.cornerRadius = toggleContainerSize / 2
toggleContainer.backgroundColor = UIColor.white
let glowRadius: CGFloat = isLightMode ? 1 : 2
let glowColor = isLightMode ? UIColor.black.withAlphaComponent(0.4) : UIColor.black
let glowConfiguration = UIView.CircularGlowConfiguration(size: toggleContainerSize, color: glowColor, radius: glowRadius)
toggleContainer.setCircularGlow(with: glowConfiguration)
addSubview(toggleContainer)
toggleContainer.center(.vertical, in: self)
toggleContainer.pin(.leading, to: .leading, of: self, withInset: leadingInset)
addSubview(durationLabel)
durationLabel.center(.vertical, in: self)
durationLabel.pin(.trailing, to: .trailing, of: self)
}
// MARK: UI & Updating
private func showLoader() {
isLoading = true
toggleImageView.isHidden = true
spinner.startAnimating()
spinner.isHidden = false
Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] timer in
guard let self = self else { return timer.invalidate() }
if self.isLoading {
self.updateFakeVolumeSamples()
} else {
timer.invalidate()
}
}
updateFakeVolumeSamples()
}
private func updateFakeVolumeSamples() {
let fakeVolumeSamples = (0..<targetSampleCount).map { _ in Float.random(in: 0...1) }
volumeSamples = fakeVolumeSamples
}
private func hideLoader() {
isLoading = false
toggleImageView.isHidden = false
spinner.stopAnimating()
spinner.isHidden = true
}
override func layoutSubviews() {
super.layoutSubviews()
updateShapeLayers()
}
private func updateShapeLayers() {
clipsToBounds = false // Bit of a hack to do this here, but the containing stack view turns this off all the time
guard !volumeSamples.isEmpty else { return }
let sMin = CGFloat(volumeSamples.min()!)
let sMax = CGFloat(volumeSamples.max()!)
let w = width() - leadingInset - toggleContainerSize - durationLabel.width() - 2 * Values.smallSpacing
let h = height() - 2 * vMargin
let sW = (w - sampleSpacing * CGFloat(volumeSamples.count - 1)) / CGFloat(volumeSamples.count)
let backgroundPath = UIBezierPath()
let foregroundPath = UIBezierPath()
for (i, value) in volumeSamples.enumerated() {
let x = leadingInset + toggleContainerSize + Values.smallSpacing + CGFloat(i) * (sW + sampleSpacing)
let fraction = (CGFloat(value) - sMin) / (sMax - sMin)
let sH = max(8, h * fraction)
let y = vMargin + (h - sH) / 2
let subPath = UIBezierPath(roundedRect: CGRect(x: x, y: y, width: sW, height: sH), cornerRadius: sW / 2)
backgroundPath.append(subPath)
if progress > CGFloat(i) / CGFloat(volumeSamples.count) { foregroundPath.append(subPath) }
}
backgroundPath.close()
foregroundPath.close()
if isLoading || isForcedAnimation {
let animation = CABasicAnimation(keyPath: "path")
animation.duration = 0.25
animation.toValue = backgroundPath
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
backgroundShapeLayer.add(animation, forKey: "path")
backgroundShapeLayer.path = backgroundPath.cgPath
} else {
backgroundShapeLayer.path = backgroundPath.cgPath
}
foregroundShapeLayer.path = foregroundPath.cgPath
isForcedAnimation = false
}
private func updateDurationLabel() {
durationLabel.text = OWSFormat.formatDurationSeconds(duration)
updateShapeLayers()
}
private func updateToggleImageView() {
toggleImageView.image = isPlaying ? #imageLiteral(resourceName: "Pause") : #imageLiteral(resourceName: "Play")
}
// MARK: Interaction
@objc(getCurrentTime:)
func getCurrentTime(for panGestureRecognizer: UIPanGestureRecognizer) -> TimeInterval {
guard voiceMessage.isDownloaded else { return 0 }
let locationInSelf = panGestureRecognizer.location(in: self)
let waveformFrameOrigin = CGPoint(x: leadingInset + toggleContainerSize + Values.smallSpacing, y: vMargin)
let waveformFrameSize = CGSize(width: width() - leadingInset - toggleContainerSize - durationLabel.width() - 2 * Values.smallSpacing,
height: height() - 2 * vMargin)
let waveformFrame = CGRect(origin: waveformFrameOrigin, size: waveformFrameSize)
guard waveformFrame.contains(locationInSelf) else { return 0 }
let fraction = (locationInSelf.x - waveformFrame.minX) / (waveformFrame.maxX - waveformFrame.minX)
return Double(fraction) * Double(duration)
}
}

View File

@ -1,80 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
@objc
public class VoiceMemoLockView: UIView {
private var offsetConstraint: NSLayoutConstraint!
private let offsetFromToolbar: CGFloat = 40
private let backgroundViewInitialHeight: CGFloat = 80
private var chevronTravel: CGFloat {
return -1 * (backgroundViewInitialHeight - 50)
}
@objc
public override init(frame: CGRect) {
super.init(frame: frame)
addSubview(backgroundView)
backgroundView.addSubview(lockIconView)
backgroundView.addSubview(chevronView)
layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: offsetFromToolbar, trailing: 0)
backgroundView.autoPinEdges(toSuperviewMarginsExcludingEdge: .bottom)
self.offsetConstraint = backgroundView.autoPinEdge(toSuperviewMargin: .bottom)
// we anchor the top so that the bottom "slides up" to meet it as the user slides the lock
backgroundView.autoPinEdge(.top, to: .bottom, of: self, withOffset: -offsetFromToolbar - backgroundViewInitialHeight)
backgroundView.layoutMargins = UIEdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6)
lockIconView.autoPinEdges(toSuperviewMarginsExcludingEdge: .bottom)
chevronView.autoPinEdges(toSuperviewMarginsExcludingEdge: .top)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: -
@objc
public func update(ratioComplete: CGFloat) {
offsetConstraint.constant = CGFloatLerp(0, chevronTravel, ratioComplete)
}
// MARK: - Subviews
private lazy var lockIconView: UIImageView = {
let imageTemplate = #imageLiteral(resourceName: "ic_lock_outline").withRenderingMode(.alwaysTemplate)
let imageView = UIImageView(image: imageTemplate)
imageView.tintColor = Colors.destructive
imageView.autoSetDimensions(to: CGSize(width: 24, height: 24))
return imageView
}()
private lazy var chevronView: UIView = {
let label = UILabel()
label.text = "\u{2303}"
label.textColor = Colors.destructive
label.textAlignment = .center
return label
}()
private lazy var backgroundView: UIView = {
let view = UIView()
let width: CGFloat = 36
view.autoSetDimension(.width, toSize: width)
view.backgroundColor = Colors.composeViewBackground
view.layer.cornerRadius = width / 2
view.layer.borderColor = Colors.text.withAlphaComponent(Values.composeViewTextFieldBorderOpacity).cgColor
view.layer.borderWidth = Values.composeViewTextFieldBorderThickness
return view
}()
}

View File

@ -0,0 +1,69 @@
final class BlockedModal : Modal {
private let publicKey: String
// MARK: Lifecycle
init(publicKey: String) {
self.publicKey = publicKey
super.init(nibName: nil, bundle: nil)
}
override init(nibName: String?, bundle: Bundle?) {
preconditionFailure("Use init(publicKey:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(publicKey:) instead.")
}
override func populateContentView() {
// Name
let name = OWSProfileManager.shared().profileNameForRecipient(withID: publicKey, avoidingWriteTransaction: true) ?? publicKey
// Title
let titleLabel = UILabel()
titleLabel.textColor = Colors.text
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
titleLabel.text = "Unblock \(name)?"
titleLabel.textAlignment = .center
// Message
let messageLabel = UILabel()
messageLabel.textColor = Colors.text
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
let message = "Are you sure you want to unblock \(name)?"
let attributedMessage = NSMutableAttributedString(string: message)
attributedMessage.addAttributes([ .font : UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], range: (message as NSString).range(of: name))
messageLabel.attributedText = attributedMessage
messageLabel.numberOfLines = 0
messageLabel.lineBreakMode = .byWordWrapping
messageLabel.textAlignment = .center
// Unblock button
let unblockButton = UIButton()
unblockButton.set(.height, to: Values.mediumButtonHeight)
unblockButton.layer.cornerRadius = Modal.buttonCornerRadius
unblockButton.backgroundColor = Colors.buttonBackground
unblockButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
unblockButton.setTitleColor(Colors.text, for: UIControl.State.normal)
unblockButton.setTitle("Unblock", for: UIControl.State.normal)
unblockButton.addTarget(self, action: #selector(unblock), for: UIControl.Event.touchUpInside)
// Button stack view
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, unblockButton ])
buttonStackView.axis = .horizontal
buttonStackView.spacing = Values.mediumSpacing
buttonStackView.distribution = .fillEqually
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
mainStackView.axis = .vertical
mainStackView.spacing = Values.largeSpacing
contentView.addSubview(mainStackView)
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
}
// MARK: Interaction
@objc private func unblock() {
OWSBlockingManager.shared().removeBlockedPhoneNumber(publicKey)
presentingViewController?.dismiss(animated: true, completion: nil)
}
}

View File

@ -0,0 +1,49 @@
// Requirements:
// Links should show up properly and be tappable.
// Text should * not * be selectable.
// The long press interaction that shows the context menu should still work.
final class BodyTextView : UITextView {
private let snDelegate: BodyTextViewDelegate
override var selectedTextRange: UITextRange? {
get { return nil }
set { }
}
init(snDelegate: BodyTextViewDelegate) {
self.snDelegate = snDelegate
super.init(frame: CGRect.zero, textContainer: nil)
setUpGestureRecognizers()
}
override init(frame: CGRect, textContainer: NSTextContainer?) {
preconditionFailure("Use init(snDelegate:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(snDelegate:) instead.")
}
private func setUpGestureRecognizers() {
let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
addGestureRecognizer(longPressGestureRecognizer)
let doubleTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap))
doubleTapGestureRecognizer.numberOfTapsRequired = 2
addGestureRecognizer(doubleTapGestureRecognizer)
}
@objc private func handleLongPress() {
snDelegate.handleLongPress()
}
@objc private func handleDoubleTap() {
// Do nothing
}
}
protocol BodyTextViewDelegate {
func handleLongPress()
}

View File

@ -0,0 +1,109 @@
final class ConversationTitleView : UIView {
private let thread: TSThread
override var intrinsicContentSize: CGSize {
return UIView.layoutFittingExpandedSize
}
// MARK: UI Components
private lazy var titleLabel: UILabel = {
let result = UILabel()
result.textColor = Colors.text
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
result.lineBreakMode = .byTruncatingTail
return result
}()
private lazy var subtitleLabel: UILabel = {
let result = UILabel()
result.textColor = Colors.text
result.font = .systemFont(ofSize: 13)
result.lineBreakMode = .byTruncatingTail
return result
}()
// MARK: Lifecycle
init(thread: TSThread) {
self.thread = thread
super.init(frame: CGRect.zero)
initialize()
}
override init(frame: CGRect) {
preconditionFailure("Use init(thread:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(coder:) instead.")
}
private func initialize() {
let stackView = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ])
stackView.axis = .vertical
stackView.alignment = .center
stackView.isLayoutMarginsRelativeArrangement = true
stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0)
addSubview(stackView)
stackView.pin(to: self)
NotificationCenter.default.addObserver(self, selector: #selector(update), name: Notification.Name.groupThreadUpdated, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(update), name: Notification.Name.muteSettingUpdated, object: nil)
update()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: Updating
@objc private func update() {
titleLabel.text = getTitle()
let subtitle = getSubtitle()
subtitleLabel.attributedText = subtitle
let titleFontSize = (subtitle != nil) ? Values.mediumFontSize : Values.veryLargeFontSize
titleLabel.font = .boldSystemFont(ofSize: titleFontSize)
}
// MARK: General
private func getTitle() -> String {
if let thread = thread as? TSGroupThread {
return thread.groupModel.groupName!
} else if thread.isNoteToSelf() {
return "Note to Self"
} else {
let sessionID = thread.contactIdentifier()!
var result = sessionID
Storage.read { transaction in
result = Storage.shared.getContact(with: sessionID)?.displayName ?? "Anonymous"
}
return result
}
}
private func getSubtitle() -> NSAttributedString? {
if let muteEndDate = thread.mutedUntilDate, thread.isMuted {
let result = NSMutableAttributedString()
result.append(NSAttributedString(string: "\u{e067} ", attributes: [ .font : UIFont.ows_elegantIconsFont(10), .foregroundColor : Colors.text ]))
let formatter = DateFormatter()
formatter.locale = Locale.current
formatter.timeStyle = .medium
formatter.dateStyle = .medium
result.append(NSAttributedString(string: "Muted until " + formatter.string(from: muteEndDate)))
return result
} else if let thread = self.thread as? TSGroupThread {
var userCount: Int?
switch thread.groupModel.groupType {
case .closedGroup: userCount = thread.groupModel.groupMemberIds.count
case .openGroup:
if let openGroup = Storage.shared.getOpenGroup(for: self.thread.uniqueId!) {
userCount = Storage.shared.getUserCount(forOpenGroupWithID: openGroup.id)
}
default: break
}
if let userCount = userCount {
return NSAttributedString(string: "\(userCount) members")
}
}
return nil
}
}

View File

@ -0,0 +1,33 @@
final class InfoBanner : UIView {
private let message: String
private let snBackgroundColor: UIColor
init(message: String, backgroundColor: UIColor) {
self.message = message
self.snBackgroundColor = backgroundColor
super.init(frame: CGRect.zero)
setUpViewHierarchy()
}
override init(frame: CGRect) {
preconditionFailure("Use init(message:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(coder:) instead.")
}
private func setUpViewHierarchy() {
backgroundColor = snBackgroundColor
let label = UILabel()
label.text = message
label.font = .boldSystemFont(ofSize: Values.smallFontSize)
label.textColor = .white
label.numberOfLines = 0
label.textAlignment = .center
label.lineBreakMode = .byWordWrapping
addSubview(label)
label.pin(to: self, withInset: Values.mediumSpacing)
}
}

Some files were not shown because too many files have changed in this diff Show More