Merge branch 'dev' into multi-device
This commit is contained in:
commit
d532badd09
11
Podfile
11
Podfile
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "SelectRecipientViewController.h"
|
||||
|
||||
@interface AddToBlockListViewController : SelectRecipientViewController
|
||||
|
||||
@end
|
|
@ -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
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
@import Foundation;
|
||||
|
||||
typedef NS_ENUM(NSUInteger, ConversationViewAction) {
|
||||
ConversationViewActionNone,
|
||||
ConversationViewActionCompose,
|
||||
ConversationViewActionAudioCall,
|
||||
ConversationViewActionVideoCall,
|
||||
};
|
|
@ -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
|
@ -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
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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 -
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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) { }
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 ]
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -9,7 +9,6 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
|
||||
@protocol OWSConversationSettingsViewDelegate <NSObject>
|
||||
|
||||
- (void)conversationColorWasUpdated;
|
||||
- (void)groupWasUpdated:(TSGroupModel *)groupModel;
|
||||
- (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController;
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
//
|
||||
|
||||
#import "OWSMessageTimerView.h"
|
||||
#import "ConversationViewController.h"
|
||||
#import "OWSMath.h"
|
||||
#import "UIColor+OWS.h"
|
||||
#import "UIView+OWS.h"
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
*/
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}()
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue