Signal-iOS/Signal/src/ViewControllers/GroupInviteLinksUI.swift
2022-07-12 10:23:12 -07:00

438 lines
20 KiB
Swift

//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
import UIKit
import SignalUI
@objc
public class GroupInviteLinksUI: UIView {
@available(*, unavailable, message: "Do not instantiate this class.")
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc
public static func openGroupInviteLink(_ url: URL,
fromViewController: UIViewController) {
AssertIsOnMainThread()
let showInvalidInviteLinkAlert = {
OWSActionSheets.showActionSheet(title: NSLocalizedString("GROUP_LINK_INVALID_GROUP_INVITE_LINK_ERROR_TITLE",
comment: "Title for the 'invalid group invite link' alert."),
message: NSLocalizedString("GROUP_LINK_INVALID_GROUP_INVITE_LINK_ERROR_MESSAGE",
comment: "Message for the 'invalid group invite link' alert."))
}
guard let groupInviteLinkInfo = GroupManager.parseGroupInviteLink(url) else {
owsFailDebug("Invalid group invite link.")
showInvalidInviteLinkAlert()
return
}
let groupV2ContextInfo: GroupV2ContextInfo
do {
groupV2ContextInfo = try self.groupsV2.groupV2ContextInfo(forMasterKeyData: groupInviteLinkInfo.masterKey)
} catch {
owsFailDebug("Error: \(error)")
showInvalidInviteLinkAlert()
return
}
// If the group already exists in the database, open it.
if let existingGroupThread = (databaseStorage.read { transaction in
TSGroupThread.fetch(groupId: groupV2ContextInfo.groupId, transaction: transaction)
}), existingGroupThread.isLocalUserFullMember || existingGroupThread.isLocalUserRequestingMember {
SignalApp.shared().presentConversation(for: existingGroupThread, animated: true)
return
}
let actionSheet = GroupInviteLinksActionSheet(groupInviteLinkInfo: groupInviteLinkInfo,
groupV2ContextInfo: groupV2ContextInfo)
fromViewController.presentActionSheet(actionSheet)
}
}
// MARK: -
private class GroupInviteLinksActionSheet: ActionSheetController {
private let groupInviteLinkInfo: GroupInviteLinkInfo
private let groupV2ContextInfo: GroupV2ContextInfo
private let avatarView = AvatarImageView()
private let groupTitleLabel = UILabel()
private let groupSubtitleLabel = UILabel()
private let groupDescriptionPreview = GroupDescriptionPreviewView()
private var groupInviteLinkPreview: GroupInviteLinkPreview?
private var avatarData: Data?
init(groupInviteLinkInfo: GroupInviteLinkInfo, groupV2ContextInfo: GroupV2ContextInfo) {
self.groupInviteLinkInfo = groupInviteLinkInfo
self.groupV2ContextInfo = groupV2ContextInfo
super.init(theme: .default)
isCancelable = true
createContents()
loadDefaultContent()
loadLinkPreview()
}
private static let avatarSize: UInt = 80
private let messageLabel = UILabel()
private var cancelButton: UIView?
private var joinButton: UIView?
private var invalidOkayButton: UIView?
private func createContents() {
let header = UIView()
header.layoutMargins = UIEdgeInsets(hMargin: 32, vMargin: 32)
header.backgroundColor = Theme.actionSheetBackgroundColor
self.customHeader = header
avatarView.autoSetDimension(.width, toSize: CGFloat(Self.avatarSize))
groupTitleLabel.font = UIFont.ows_semiboldFont(
withSize: UIFont.ows_dynamicTypeTitle1Clamped.pointSize * (13/14)
)
groupTitleLabel.textColor = Theme.primaryTextColor
groupSubtitleLabel.font = UIFont.ows_dynamicTypeSubheadline
groupSubtitleLabel.textColor = Theme.secondaryTextAndIconColor
groupDescriptionPreview.font = .ows_dynamicTypeSubheadline
groupDescriptionPreview.textColor = Theme.secondaryTextAndIconColor
groupDescriptionPreview.numberOfLines = 2
groupDescriptionPreview.textAlignment = .center
groupDescriptionPreview.isHidden = true
let headerStack = UIStackView(arrangedSubviews: [
avatarView,
groupTitleLabel,
groupSubtitleLabel,
.spacer(withHeight: 4),
groupDescriptionPreview
])
headerStack.spacing = 8
headerStack.axis = .vertical
headerStack.alignment = .center
messageLabel.text = NSLocalizedString("GROUP_LINK_ACTION_SHEET_VIEW_MESSAGE",
comment: "Message text for the 'group invite link' action sheet.")
messageLabel.font = .ows_dynamicTypeFootnote
messageLabel.textColor = Theme.secondaryTextAndIconColor
messageLabel.numberOfLines = 0
messageLabel.lineBreakMode = .byWordWrapping
messageLabel.textAlignment = .center
messageLabel.setContentHuggingVerticalHigh()
let buttonColor: UIColor = Theme.isDarkThemeEnabled ? .ows_gray65 : .ows_gray05
let cancelButton = OWSFlatButton.button(title: CommonStrings.cancelButton,
font: UIFont.ows_dynamicTypeBody.ows_semibold,
titleColor: Theme.secondaryTextAndIconColor,
backgroundColor: buttonColor,
target: self,
selector: #selector(didTapCancel))
cancelButton.autoSetHeightUsingFont()
cancelButton.cornerRadius = 14
self.cancelButton = cancelButton
let joinButton = OWSFlatButton.button(title: NSLocalizedString("GROUP_LINK_ACTION_SHEET_VIEW_JOIN_BUTTON",
comment: "Label for the 'join' button in the 'group invite link' action sheet."),
font: UIFont.ows_dynamicTypeBody.ows_semibold,
titleColor: .ows_accentBlue,
backgroundColor: buttonColor,
target: self,
selector: #selector(didTapJoin))
joinButton.autoSetHeightUsingFont()
joinButton.cornerRadius = 14
self.joinButton = joinButton
let invalidOkayButton = OWSFlatButton.button(title: CommonStrings.okayButton,
font: UIFont.ows_dynamicTypeBody.ows_semibold,
titleColor: Theme.primaryTextColor,
backgroundColor: buttonColor,
target: self,
selector: #selector(didTapInvalidOkay))
invalidOkayButton.autoSetHeightUsingFont()
invalidOkayButton.cornerRadius = 14
invalidOkayButton.isHidden = true
self.invalidOkayButton = invalidOkayButton
let buttonStack = UIStackView(arrangedSubviews: [
cancelButton,
joinButton,
invalidOkayButton
])
buttonStack.axis = .horizontal
buttonStack.alignment = .fill
buttonStack.distribution = .fillEqually
buttonStack.spacing = 10
let divider = UIView()
divider.autoSetDimension(.height, toSize: CGHairlineWidth())
divider.backgroundColor = buttonColor
let stackView = UIStackView(arrangedSubviews: [
headerStack,
UIView.spacer(withHeight: 32),
divider,
UIView.spacer(withHeight: 16),
messageLabel,
UIView.spacer(withHeight: 16),
buttonStack
])
stackView.axis = .vertical
stackView.alignment = .fill
header.addSubview(stackView)
stackView.autoPinEdgesToSuperviewMargins()
headerStack.setContentHuggingVerticalHigh()
stackView.setContentHuggingVerticalHigh()
}
private func loadDefaultContent() {
avatarView.image = Self.avatarBuilder.avatarImage(forGroupId: groupV2ContextInfo.groupId,
diameterPoints: Self.avatarSize)
groupTitleLabel.text = NSLocalizedString("GROUP_LINK_ACTION_SHEET_VIEW_LOADING_TITLE",
comment: "Label indicating that the group info is being loaded in the 'group invite link' action sheet.")
groupSubtitleLabel.text = ""
groupDescriptionPreview.descriptionText = ""
}
private func loadLinkPreview() {
firstly(on: .global()) {
self.groupsV2Impl.fetchGroupInviteLinkPreview(inviteLinkPassword: self.groupInviteLinkInfo.inviteLinkPassword,
groupSecretParamsData: self.groupV2ContextInfo.groupSecretParamsData,
allowCached: false)
}.done { [weak self] (groupInviteLinkPreview: GroupInviteLinkPreview) in
self?.applyGroupInviteLinkPreview(groupInviteLinkPreview)
if let avatarUrlPath = groupInviteLinkPreview.avatarUrlPath {
self?.loadGroupAvatar(avatarUrlPath: avatarUrlPath)
}
}.catch { [weak self] error in
switch error {
case GroupsV2Error.expiredGroupInviteLink:
self?.applyExpiredGroupInviteLink()
case GroupsV2Error.localUserBlockedFromJoining:
Logger.warn("User blocked: \(error)")
self?.dismiss(animated: true, completion: {
OWSActionSheets.showActionSheet(
title: NSLocalizedString(
"GROUP_LINK_ACTION_SHEET_VIEW_EXPIRED_LINK_TITLE",
comment: "Title indicating that the group invite link has expired in the 'group invite link' action sheet."),
message: NSLocalizedString(
"GROUP_LINK_ACTION_SHEET_VIEW_BLOCKED_FROM_JOINING_SUBTITLE",
comment: "Subtitle indicating that the local user has been blocked from joining the group"))
})
default:
// TODO: Retry errors?
owsFailDebugUnlessNetworkFailure(error)
}
}
}
private func loadGroupAvatar(avatarUrlPath: String) {
firstly(on: .global()) {
self.groupsV2Impl.fetchGroupInviteLinkAvatar(avatarUrlPath: avatarUrlPath,
groupSecretParamsData: self.groupV2ContextInfo.groupSecretParamsData)
}.done { [weak self] (groupAvatar: Data) in
self?.applyGroupAvatar(groupAvatar)
}.catch { error in
// TODO: Add retry?
owsFailDebugUnlessNetworkFailure(error)
}
}
private func applyGroupInviteLinkPreview(_ groupInviteLinkPreview: GroupInviteLinkPreview) {
AssertIsOnMainThread()
self.groupInviteLinkPreview = groupInviteLinkPreview
let groupName = groupInviteLinkPreview.title.filterForDisplay?.nilIfEmpty
?? TSGroupThread.defaultGroupName
groupTitleLabel.text = groupName
groupSubtitleLabel.text = GroupViewUtils.formatGroupMembersLabel(
memberCount: Int(groupInviteLinkPreview.memberCount)
)
if let descriptionText = groupInviteLinkPreview.descriptionText?.filterForDisplay?.nilIfEmpty {
groupDescriptionPreview.descriptionText = descriptionText
groupDescriptionPreview.groupName = groupName
groupDescriptionPreview.isHidden = false
}
}
private func applyGroupAvatar(_ groupAvatar: Data) {
AssertIsOnMainThread()
guard groupAvatar.ows_isValidImage else {
owsFailDebug("Invalid group avatar.")
return
}
guard let image = UIImage(data: groupAvatar) else {
owsFailDebug("Could not load group avatar.")
return
}
avatarView.image = image
self.avatarData = groupAvatar
}
private func applyExpiredGroupInviteLink() {
AssertIsOnMainThread()
self.groupInviteLinkPreview = nil
groupTitleLabel.text = NSLocalizedString("GROUP_LINK_ACTION_SHEET_VIEW_EXPIRED_LINK_TITLE",
comment: "Title indicating that the group invite link has expired in the 'group invite link' action sheet.")
groupSubtitleLabel.text = NSLocalizedString("GROUP_LINK_ACTION_SHEET_VIEW_EXPIRED_LINK_SUBTITLE",
comment: "Subtitle indicating that the group invite link has expired in the 'group invite link' action sheet.")
messageLabel.textColor = Theme.backgroundColor
cancelButton?.isHidden = true
joinButton?.isHidden = true
invalidOkayButton?.isHidden = false
}
@objc
func didTapCancel(_ sender: UIButton) {
dismiss(animated: true)
}
@objc
func didTapInvalidOkay(_ sender: UIButton) {
dismiss(animated: true)
}
private func showActionSheet(title: String?,
message: String? = nil,
buttonTitle: String? = nil,
buttonAction: ActionSheetAction.Handler? = nil) {
OWSActionSheets.showActionSheet(title: title,
message: message,
buttonTitle: buttonTitle,
buttonAction: buttonAction,
fromViewController: self)
}
@objc
func didTapJoin(_ sender: UIButton) {
AssertIsOnMainThread()
Logger.info("")
guard doesLocalUserSupportGroupsV2 else {
Logger.warn("Local user does not support groups v2.")
showActionSheet(title: CommonStrings.errorAlertTitle,
message: NSLocalizedString("GROUP_LINK_LOCAL_USER_DOES_NOT_SUPPORT_GROUPS_V2_ERROR_MESSAGE",
comment: "Error message indicating that the local user does not support groups v2."))
return
}
// These values may not be filled in yet.
// They may be being downloaded now or their downloads may have failed.
let existingGroupInviteLinkPreview = self.groupInviteLinkPreview
let existingAvatarData = self.avatarData
ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { modalActivityIndicator in
firstly(on: .global()) { () -> Promise<GroupInviteLinkPreview> in
if let existingGroupInviteLinkPreview = existingGroupInviteLinkPreview {
// View has already downloaded the preview.
return Promise.value(existingGroupInviteLinkPreview)
}
// Kick off a fresh attempt to download the link preview.
// We cannot join the group without the preview.
return self.groupsV2Impl.fetchGroupInviteLinkPreview(inviteLinkPassword: self.groupInviteLinkInfo.inviteLinkPassword,
groupSecretParamsData: self.groupV2ContextInfo.groupSecretParamsData,
allowCached: false)
}.then(on: .global()) { (groupInviteLinkPreview: GroupInviteLinkPreview) -> Promise<(GroupInviteLinkPreview, Data?)> in
guard let avatarUrlPath = groupInviteLinkPreview.avatarUrlPath else {
// Group has no avatar.
return Promise.value((groupInviteLinkPreview, nil))
}
if let existingAvatarData = existingAvatarData {
// View has already downloaded the avatar.
return Promise.value((groupInviteLinkPreview, existingAvatarData))
}
return firstly(on: .global()) {
self.groupsV2Impl.fetchGroupInviteLinkAvatar(avatarUrlPath: avatarUrlPath,
groupSecretParamsData: self.groupV2ContextInfo.groupSecretParamsData)
}.map(on: .global()) { (groupAvatar: Data) in
(groupInviteLinkPreview, groupAvatar)
}.recover(on: .global()) { error -> Promise<(GroupInviteLinkPreview, Data?)> in
Logger.warn("Error: \(error)")
// We made a best effort to fill in the avatar.
// Don't block joining the group on downloading
// the avatar. It will only be used in a
// placeholder model if at all.
return Promise.value((groupInviteLinkPreview, nil))
}
}.then(on: .global()) { (groupInviteLinkPreview: GroupInviteLinkPreview, avatarData: Data?) in
GroupManager.joinGroupViaInviteLink(groupId: self.groupV2ContextInfo.groupId,
groupSecretParamsData: self.groupV2ContextInfo.groupSecretParamsData,
inviteLinkPassword: self.groupInviteLinkInfo.inviteLinkPassword,
groupInviteLinkPreview: groupInviteLinkPreview,
avatarData: avatarData)
}.done { [weak self] (groupThread: TSGroupThread) in
modalActivityIndicator.dismiss {
AssertIsOnMainThread()
self?.dismiss(animated: true) {
AssertIsOnMainThread()
SignalApp.shared().presentConversation(for: groupThread, animated: true)
}
}
}.catch { error in
Logger.warn("Error: \(error)")
modalActivityIndicator.dismiss {
AssertIsOnMainThread()
self.showActionSheet(
title: NSLocalizedString(
"GROUP_LINK_ACTION_SHEET_VIEW_EXPIRED_LINK_TITLE",
comment: "Title indicating that the group invite link has expired in the 'group invite link' action sheet."),
message: {
switch error {
case GroupsV2Error.expiredGroupInviteLink:
return NSLocalizedString(
"GROUP_LINK_ACTION_SHEET_VIEW_EXPIRED_LINK_SUBTITLE",
comment: "Subtitle indicating that the group invite link has expired in the 'group invite link' action sheet.")
case GroupsV2Error.localUserBlockedFromJoining:
return NSLocalizedString(
"GROUP_LINK_ACTION_SHEET_VIEW_BLOCKED_FROM_JOINING_SUBTITLE",
comment: "Subtitle indicating that the local user has been blocked from joining the group")
case _ where error.isNetworkConnectivityFailure:
return NSLocalizedString(
"GROUP_LINK_COULD_NOT_REQUEST_TO_JOIN_GROUP_DUE_TO_NETWORK_ERROR_MESSAGE",
comment: "Error message the attempt to request to join the group failed due to network connectivity.")
default:
return NSLocalizedString(
"GROUP_LINK_COULD_NOT_REQUEST_TO_JOIN_GROUP_ERROR_MESSAGE",
comment: "Error message the attempt to request to join the group failed.")
}
}()
)
}
}
}
}
private var doesLocalUserSupportGroupsV2: Bool {
guard let localAddress = tsAccountManager.localAddress else {
owsFailDebug("missing local address")
return false
}
return GroupManager.doesUserSupportGroupsV2(address: localAddress)
}
}