393 lines
14 KiB
Swift
393 lines
14 KiB
Swift
//
|
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import PromiseKit
|
|
import UIKit
|
|
|
|
protocol GroupAttributesEditorHelperDelegate: AnyObject {
|
|
func groupAttributesEditorContentsDidChange()
|
|
func groupAttributesEditorSelectionDidChange()
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
// A helper class used to DRY up the common views/logic
|
|
// used when editing group names & avatars in the
|
|
// "create new group" and "edit group" views.
|
|
class GroupAttributesEditorHelper: NSObject {
|
|
|
|
public enum EditAction {
|
|
case none
|
|
case name
|
|
case avatar
|
|
}
|
|
|
|
weak var delegate: GroupAttributesEditorHelperDelegate?
|
|
|
|
private let groupModelOriginal: TSGroupModel?
|
|
|
|
private let groupId: Data
|
|
|
|
private let avatarViewHelper = AvatarViewHelper()
|
|
|
|
private var avatarView: UIImageView?
|
|
|
|
let avatarWrapper = UIView.container()
|
|
|
|
private let avatarImageView = UIImageView()
|
|
|
|
private let iconViewSize: UInt
|
|
|
|
private let cameraButton = GroupAttributesEditorHelper.buildCameraButtonForCenter()
|
|
private let cameraCornerButton = GroupAttributesEditorHelper.buildCameraButtonForCorner()
|
|
|
|
let nameTextField = UITextField()
|
|
|
|
var groupNameOriginal: String?
|
|
|
|
var groupNameCurrent: String? {
|
|
get { nameTextField.text?.nilIfEmpty?.filterStringForDisplay() }
|
|
set { nameTextField.text = newValue?.nilIfEmpty?.filterStringForDisplay() }
|
|
}
|
|
|
|
let descriptionTextView = TextViewWithPlaceholder()
|
|
|
|
var groupDescriptionOriginal: String?
|
|
|
|
var groupDescriptionCurrent: String? {
|
|
get { descriptionTextView.text?.nilIfEmpty?.filterStringForDisplay() }
|
|
set { descriptionTextView.text = newValue?.nilIfEmpty?.filterStringForDisplay() }
|
|
}
|
|
|
|
private var avatarOriginal: GroupAvatar?
|
|
|
|
var avatarCurrent: GroupAvatar?
|
|
|
|
var hasUnsavedChanges: Bool {
|
|
return (groupNameOriginal != groupNameCurrent ||
|
|
groupDescriptionOriginal != groupDescriptionCurrent ||
|
|
avatarOriginal?.imageData != avatarCurrent?.imageData)
|
|
}
|
|
|
|
public convenience init(
|
|
groupModel: TSGroupModel,
|
|
iconViewSize: UInt = AvatarBuilder.largeAvatarSizePoints
|
|
) {
|
|
self.init(
|
|
groupModelOriginal: groupModel,
|
|
groupId: groupModel.groupId,
|
|
groupNameOriginal: groupModel.groupName,
|
|
groupDescriptionOriginal: (groupModel as? TSGroupModelV2)?.descriptionText,
|
|
avatarOriginalData: groupModel.groupAvatarData,
|
|
iconViewSize: iconViewSize
|
|
)
|
|
}
|
|
|
|
public required init(groupModelOriginal: TSGroupModel? = nil,
|
|
groupId: Data,
|
|
groupNameOriginal: String?,
|
|
groupDescriptionOriginal: String? = nil,
|
|
avatarOriginalData: Data?,
|
|
iconViewSize: UInt) {
|
|
|
|
self.groupModelOriginal = groupModelOriginal
|
|
self.groupId = groupId
|
|
self.groupNameOriginal = groupNameOriginal?.nilIfEmpty?.filterStringForDisplay()
|
|
self.groupDescriptionOriginal = groupDescriptionOriginal?.nilIfEmpty?.filterStringForDisplay()
|
|
self.avatarOriginal = GroupAvatar.build(imageData: avatarOriginalData)
|
|
self.avatarCurrent = avatarOriginal
|
|
self.iconViewSize = iconViewSize
|
|
|
|
super.init()
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
func buildContents(avatarViewHelperDelegate: AvatarViewHelperDelegate? = nil) {
|
|
|
|
avatarViewHelper.delegate = avatarViewHelperDelegate
|
|
|
|
// We need to specify a contentMode since the size of the image
|
|
// might not match the aspect ratio of the view.
|
|
avatarImageView.contentMode = .scaleAspectFill
|
|
// Use trilinear filters for better scaling quality at
|
|
// some performance cost.
|
|
avatarImageView.layer.minificationFilter = .trilinear
|
|
avatarImageView.layer.magnificationFilter = .trilinear
|
|
updateAvatarView(groupAvatar: avatarCurrent)
|
|
avatarImageView.layer.cornerRadius = CGFloat(iconViewSize) * 0.5
|
|
avatarImageView.clipsToBounds = true
|
|
avatarImageView.autoSetDimensions(to: CGSize(square: CGFloat(iconViewSize)))
|
|
avatarImageView.isUserInteractionEnabled = true
|
|
avatarImageView.addGestureRecognizer(UITapGestureRecognizer(target: self,
|
|
action: #selector(didTapAvatarView)))
|
|
|
|
avatarWrapper.addSubview(avatarImageView)
|
|
avatarImageView.autoPinEdgesToSuperviewEdges()
|
|
|
|
avatarWrapper.addSubview(cameraButton)
|
|
cameraButton.autoCenterInSuperview()
|
|
|
|
avatarWrapper.addSubview(cameraCornerButton)
|
|
cameraCornerButton.autoPinEdge(toSuperviewEdge: .trailing)
|
|
cameraCornerButton.autoPinEdge(toSuperviewEdge: .bottom)
|
|
|
|
nameTextField.text = groupNameOriginal
|
|
nameTextField.font = .ows_dynamicTypeBody
|
|
nameTextField.backgroundColor = .clear
|
|
nameTextField.textColor = Theme.primaryTextColor
|
|
nameTextField.delegate = self
|
|
nameTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
|
|
nameTextField.placeholder = NSLocalizedString("GROUP_NAME_PLACEHOLDER",
|
|
comment: "Placeholder text for 'group name' field.")
|
|
|
|
descriptionTextView.text = groupDescriptionOriginal
|
|
descriptionTextView.delegate = self
|
|
descriptionTextView.placeholderText = NSLocalizedString("GROUP_DESCRIPTION_PLACEHOLDER",
|
|
comment: "Placeholder text for 'group description' field.")
|
|
}
|
|
|
|
public static func buildCameraButtonForCorner() -> UIView {
|
|
let cameraImageContainer = UIView()
|
|
cameraImageContainer.autoSetDimensions(to: CGSize.square(32))
|
|
cameraImageContainer.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray15 : UIColor(rgbHex: 0xf8f9f9)
|
|
cameraImageContainer.layer.cornerRadius = 16
|
|
|
|
cameraImageContainer.layer.shadowColor = UIColor.black.cgColor
|
|
cameraImageContainer.layer.shadowOpacity = 0.2
|
|
cameraImageContainer.layer.shadowRadius = 4
|
|
cameraImageContainer.layer.shadowOffset = CGSize(width: 0, height: 2)
|
|
|
|
let secondaryShadowView = UIView()
|
|
secondaryShadowView.layer.shadowColor = UIColor.black.cgColor
|
|
secondaryShadowView.layer.shadowOpacity = 0.12
|
|
secondaryShadowView.layer.shadowRadius = 16
|
|
secondaryShadowView.layer.shadowOffset = CGSize(width: 0, height: 4)
|
|
|
|
cameraImageContainer.addSubview(secondaryShadowView)
|
|
secondaryShadowView.autoPinEdgesToSuperviewEdges()
|
|
|
|
let cameraImageView = UIImageView.withTemplateImageName("camera-outline-32", tintColor: Theme.isDarkThemeEnabled ? .ows_gray80 : .ows_black)
|
|
cameraImageView.autoSetDimensions(to: CGSize.square(20))
|
|
cameraImageView.contentMode = .scaleAspectFit
|
|
|
|
cameraImageContainer.addSubview(cameraImageView)
|
|
cameraImageView.autoCenterInSuperview()
|
|
|
|
return cameraImageContainer
|
|
}
|
|
|
|
public static func buildCameraButtonForCenter() -> UIView {
|
|
let cameraImageView = UIImageView()
|
|
cameraImageView.setTemplateImageName("camera-outline-24", tintColor: Theme.primaryIconColor)
|
|
let iconSize: CGFloat = 32
|
|
cameraImageView.autoSetDimensions(to: CGSize(square: iconSize))
|
|
return cameraImageView
|
|
}
|
|
|
|
private func updateAvatarView(groupAvatar: GroupAvatar?) {
|
|
if let groupAvatar = groupAvatar {
|
|
avatarImageView.image = groupAvatar.image
|
|
avatarImageView.layer.borderWidth = 0
|
|
avatarImageView.layer.borderColor = nil
|
|
cameraButton.isHidden = true
|
|
cameraCornerButton.isHidden = false
|
|
} else {
|
|
avatarImageView.image = nil
|
|
avatarImageView.layer.borderWidth = 2
|
|
avatarImageView.layer.borderColor = Theme.outlineColor.cgColor
|
|
cameraButton.isHidden = false
|
|
cameraCornerButton.isHidden = true
|
|
}
|
|
}
|
|
|
|
func setAvatarImage(_ image: UIImage?) {
|
|
let groupAvatar: GroupAvatar? = {
|
|
guard let image = image else {
|
|
return nil
|
|
}
|
|
guard let groupAvatar = GroupAvatar.build(image: image) else {
|
|
OWSActionSheets.showErrorAlert(message: NSLocalizedString("EDIT_GROUP_ERROR_INVALID_AVATAR",
|
|
comment: "Error message indicating that an avatar image is invalid and cannot be used."))
|
|
owsFailDebug("Invalid image.")
|
|
return nil
|
|
}
|
|
return groupAvatar
|
|
}()
|
|
avatarCurrent = groupAvatar
|
|
updateAvatarView(groupAvatar: avatarCurrent)
|
|
|
|
delegate?.groupAttributesEditorContentsDidChange()
|
|
}
|
|
|
|
// MARK: - Events
|
|
|
|
@objc
|
|
func textFieldDidChange(_ textField: UITextField) {
|
|
delegate?.groupAttributesEditorContentsDidChange()
|
|
}
|
|
|
|
@objc
|
|
func didTapAvatarView() {
|
|
showAvatarUI()
|
|
}
|
|
|
|
func showAvatarUI() {
|
|
nameTextField.resignFirstResponder()
|
|
descriptionTextView.resignFirstResponder()
|
|
avatarViewHelper.showChangeAvatarUI()
|
|
}
|
|
|
|
// MARK: - update
|
|
|
|
func updateGroupIfNecessary(fromViewController: UIViewController, completion: @escaping () -> Void) {
|
|
nameTextField.acceptAutocorrectSuggestion()
|
|
descriptionTextView.acceptAutocorrectSuggestion()
|
|
|
|
guard !groupNameCurrent.isEmptyOrNil else {
|
|
NewGroupConfirmViewController.showMissingGroupNameAlert()
|
|
return
|
|
}
|
|
|
|
guard hasUnsavedChanges else {
|
|
owsFailDebug("!hasUnsavedChanges.")
|
|
return completion()
|
|
}
|
|
|
|
guard let newGroupModel = buildNewGroupModel() else {
|
|
let error = OWSAssertionError("Couldn't build group model.")
|
|
GroupViewUtils.showUpdateErrorUI(error: error)
|
|
return
|
|
}
|
|
GroupViewUtils.updateGroupWithActivityIndicator(
|
|
fromViewController: fromViewController,
|
|
updatePromiseBlock: {
|
|
self.updateGroupThreadPromise(newGroupModel: newGroupModel)
|
|
},
|
|
completion: { _ in
|
|
completion()
|
|
}
|
|
)
|
|
}
|
|
|
|
private func buildNewGroupModel() -> TSGroupModel? {
|
|
guard let groupModelOriginal = groupModelOriginal else { return nil }
|
|
|
|
do {
|
|
return try databaseStorage.read { transaction in
|
|
var builder = groupModelOriginal.asBuilder
|
|
builder.name = self.groupNameCurrent
|
|
builder.descriptionText = self.groupDescriptionCurrent
|
|
builder.avatarData = self.avatarCurrent?.imageData
|
|
return try builder.build(transaction: transaction)
|
|
}
|
|
} catch {
|
|
owsFailDebug("Error: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func updateGroupThreadPromise(newGroupModel: TSGroupModel) -> Promise<Void> {
|
|
guard let localAddress = tsAccountManager.localAddress else {
|
|
return Promise(error: OWSAssertionError("Missing localAddress."))
|
|
}
|
|
|
|
guard let oldGroupModel = groupModelOriginal else {
|
|
return Promise(error: OWSAssertionError("Missing groupModelOriginal."))
|
|
}
|
|
|
|
return firstly { () -> Promise<Void> in
|
|
return GroupManager.messageProcessingPromise(
|
|
for: oldGroupModel,
|
|
description: self.logTag
|
|
)
|
|
}.then(on: .global()) { _ in
|
|
// dmConfiguration: nil means don't change disappearing messages configuration.
|
|
GroupManager.localUpdateExistingGroup(
|
|
oldGroupModel: oldGroupModel,
|
|
newGroupModel: newGroupModel,
|
|
dmConfiguration: nil,
|
|
groupUpdateSourceAddress: localAddress
|
|
)
|
|
}.asVoid()
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
struct GroupAvatar {
|
|
let imageData: Data
|
|
let image: UIImage
|
|
|
|
static func build(imageData: Data?) -> GroupAvatar? {
|
|
guard let imageData = imageData else {
|
|
return nil
|
|
}
|
|
guard imageData.ows_isValidImage else {
|
|
owsFailDebug("Invalid image data.")
|
|
return nil
|
|
}
|
|
guard TSGroupModel.isValidGroupAvatarData(imageData) else {
|
|
owsFailDebug("Invalid group avatar.")
|
|
return nil
|
|
}
|
|
guard let image = UIImage(data: imageData) else {
|
|
owsFailDebug("Could not load image.")
|
|
return nil
|
|
}
|
|
return GroupAvatar(imageData: imageData, image: image)
|
|
}
|
|
|
|
static func build(image: UIImage?) -> GroupAvatar? {
|
|
guard let image = image else {
|
|
return nil
|
|
}
|
|
guard let imageData = TSGroupModel.data(forGroupAvatar: image) else {
|
|
owsFailDebug("Invalid image.")
|
|
return nil
|
|
}
|
|
return build(imageData: imageData)
|
|
}
|
|
}
|
|
|
|
// MARK:
|
|
|
|
extension GroupAttributesEditorHelper: UITextFieldDelegate {
|
|
public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString: String) -> Bool {
|
|
// Truncate the replacement to fit.
|
|
return TextFieldHelper.textField(
|
|
textField,
|
|
shouldChangeCharactersInRange: range,
|
|
replacementString: replacementString.withoutBidiControlCharacters,
|
|
maxGlyphCount: GroupManager.maxGroupNameGlyphCount
|
|
)
|
|
}
|
|
}
|
|
|
|
extension GroupAttributesEditorHelper: TextViewWithPlaceholderDelegate {
|
|
func textViewDidUpdateText(_ textView: TextViewWithPlaceholder) {
|
|
delegate?.groupAttributesEditorContentsDidChange()
|
|
}
|
|
|
|
func textViewDidUpdateSelection(_ textView: TextViewWithPlaceholder) {
|
|
delegate?.groupAttributesEditorSelectionDidChange()
|
|
}
|
|
|
|
func textView(
|
|
_ textView: TextViewWithPlaceholder,
|
|
uiTextView: UITextView,
|
|
shouldChangeTextIn range: NSRange,
|
|
replacementText text: String
|
|
) -> Bool {
|
|
// Truncate the replacement to fit.
|
|
return TextViewHelper.textView(
|
|
uiTextView,
|
|
shouldChangeTextIn: range,
|
|
replacementText: text,
|
|
maxGlyphCount: GroupManager.maxGroupDescriptionGlyphCount
|
|
)
|
|
}
|
|
}
|