// // 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 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) } }