mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-29 09:49:04 +00:00
The Swift side had been minimised least: 57 of its added lines were comments, several of them restating what the line below does or narrating what an earlier attempt got wrong. Kept the ones that record a non-obvious why - why the sheet hangs off the Group, why the check-and-set is atomic, why the resync is not rethrown, why the alert is deferred - and cut the rest. Also tried making changeProfile async so the create flow could await it and the existing creatingProfile flag would cover the reassignment, removing changingProfile entirely. It reads better but it takes changeProfile out of its Task and re-indents the whole function, which costs more diff than the flag it saves. Not done.
467 lines
19 KiB
Swift
467 lines
19 KiB
Swift
//
|
|
// ShareSheet.swift
|
|
// SimpleX
|
|
//
|
|
// Created by Evgeny Poberezkin on 30/01/2022.
|
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SimpleXChat
|
|
|
|
func getTopViewController() -> UIViewController? {
|
|
let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
|
|
if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first,
|
|
let rootViewController = keyWindow.rootViewController {
|
|
// Find the top-most presented view controller
|
|
var topController = rootViewController
|
|
while let presentedViewController = topController.presentedViewController {
|
|
topController = presentedViewController
|
|
}
|
|
return topController
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
|
|
if let topController = getTopViewController() {
|
|
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
|
if let completed = completed {
|
|
activityViewController.completionWithItemsHandler = { _, _, _, _ in
|
|
completed()
|
|
}
|
|
}
|
|
topController.present(activityViewController, animated: true)
|
|
}
|
|
}
|
|
|
|
func showAlert(
|
|
title: String,
|
|
message: String? = nil,
|
|
buttonTitle: String,
|
|
buttonAction: @escaping () -> Void,
|
|
cancelButton: Bool
|
|
) -> Void {
|
|
if let topController = getTopViewController() {
|
|
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
|
alert.addAction(UIAlertAction(title: buttonTitle, style: .default) { _ in
|
|
buttonAction()
|
|
})
|
|
if cancelButton {
|
|
alert.addAction(cancelAlertAction)
|
|
}
|
|
topController.present(alert, animated: true)
|
|
}
|
|
}
|
|
|
|
func showAlert(_ a: (title: String, message: String?)) {
|
|
showAlert(a.title, message: a.message)
|
|
}
|
|
|
|
func showAlert(
|
|
_ title: String,
|
|
message: String? = nil,
|
|
actions: () -> [UIAlertAction] = { [okAlertAction] }
|
|
) {
|
|
if let topController = getTopViewController() {
|
|
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
|
for action in actions() { alert.addAction(action) }
|
|
topController.present(alert, animated: true)
|
|
}
|
|
}
|
|
|
|
/// getTopViewController() keeps returning a sheet until its dismissal transition ends, so
|
|
/// an alert raised right after dismissing one is presented on it and dropped.
|
|
func alertAfterDismissal(_ title: String, _ message: String? = nil) {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
showAlert(title, message: message)
|
|
}
|
|
}
|
|
|
|
func showSheet(
|
|
_ title: String?,
|
|
message: String? = nil,
|
|
actions: () -> [UIAlertAction] = { [okAlertAction] },
|
|
sourceView: UIView? = nil // For iPad support
|
|
) {
|
|
if let topController = getTopViewController() {
|
|
let sheet = UIAlertController(title: title, message: message, preferredStyle: .actionSheet)
|
|
for action in actions() { sheet.addAction(action) }
|
|
|
|
// Required for iPad: Configure popover presentation
|
|
if let popover = sheet.popoverPresentationController {
|
|
popover.sourceView = sourceView ?? topController.view
|
|
popover.sourceRect = sourceView?.bounds ?? CGRect(x: topController.view.bounds.midX, y: topController.view.bounds.midY, width: 0, height: 0)
|
|
popover.permittedArrowDirections = []
|
|
}
|
|
|
|
topController.present(sheet, animated: true)
|
|
}
|
|
}
|
|
|
|
func openExternalLink(_ url: URL) {
|
|
let s = url.absoluteString
|
|
if s.starts(with: "https://simplex.chat/contact#") || (s.starts(with: "https://smp") && s.contains(".simplex.im/a#")) {
|
|
ChatModel.shared.appOpenUrl = url
|
|
} else {
|
|
showAlert(
|
|
title: NSLocalizedString("Open external link?", comment: "alert title"),
|
|
message: s,
|
|
buttonTitle: NSLocalizedString("Open", comment: "alert button"),
|
|
buttonAction: { UIApplication.shared.open(url) },
|
|
cancelButton: true
|
|
)
|
|
}
|
|
}
|
|
|
|
struct ExternalLink<Label: View>: View {
|
|
let destination: URL
|
|
let label: Label
|
|
|
|
init(destination: URL, @ViewBuilder label: () -> Label) {
|
|
self.destination = destination
|
|
self.label = label()
|
|
}
|
|
|
|
init(_ titleKey: LocalizedStringKey, destination: URL) where Label == Text {
|
|
self.destination = destination
|
|
self.label = Text(titleKey)
|
|
}
|
|
|
|
var body: some View {
|
|
Button { openExternalLink(destination) } label: { label }
|
|
}
|
|
}
|
|
|
|
let okAlertAction = UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default)
|
|
|
|
let cancelAlertAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel)
|
|
|
|
let alertProfileImageSize: CGFloat = 103
|
|
|
|
let alertWidth: CGFloat = 270
|
|
|
|
let alertButtonHeight: CGFloat = 44
|
|
|
|
class OpenChatAlertViewController: UIViewController {
|
|
private let profileName: String
|
|
private let profileFullName: String
|
|
private let profileImage: UIView
|
|
private let profileBadge: LocalBadge?
|
|
private let subtitle: String?
|
|
private let information: String?
|
|
private let cancelTitle: String
|
|
private let confirmTitle: String?
|
|
private let secondTitle: String?
|
|
private let onCancel: () -> Void
|
|
private let onConfirm: (() -> Void)?
|
|
private let onSecond: (() -> Void)?
|
|
|
|
init(
|
|
profileName: String,
|
|
profileFullName: String,
|
|
profileImage: UIView,
|
|
profileBadge: LocalBadge? = nil,
|
|
subtitle: String? = nil,
|
|
information: String? = nil,
|
|
cancelTitle: String = "Cancel",
|
|
confirmTitle: String? = "Open",
|
|
secondTitle: String? = nil,
|
|
onCancel: @escaping () -> Void = {},
|
|
onConfirm: (() -> Void)? = nil,
|
|
onSecond: (() -> Void)? = nil
|
|
) {
|
|
self.profileName = profileName
|
|
self.profileFullName = profileFullName
|
|
self.profileImage = profileImage
|
|
self.profileBadge = profileBadge
|
|
self.subtitle = subtitle
|
|
self.information = information
|
|
self.cancelTitle = cancelTitle
|
|
self.confirmTitle = confirmTitle
|
|
self.secondTitle = secondTitle
|
|
self.onCancel = onCancel
|
|
self.onConfirm = onConfirm
|
|
self.onSecond = onSecond
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
|
modalPresentationStyle = .overFullScreen
|
|
modalTransitionStyle = .crossDissolve
|
|
}
|
|
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
view.backgroundColor = UIColor.black.withAlphaComponent(0.3)
|
|
|
|
// Container view
|
|
let containerView = UIView()
|
|
containerView.backgroundColor = .systemBackground
|
|
containerView.layer.cornerRadius = 12
|
|
containerView.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(containerView)
|
|
|
|
// Profile image sizing
|
|
profileImage.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
profileImage.widthAnchor.constraint(equalToConstant: alertProfileImageSize),
|
|
profileImage.heightAnchor.constraint(equalToConstant: alertProfileImageSize)
|
|
])
|
|
|
|
// Name label
|
|
let nameLabel = UILabel()
|
|
nameLabel.font = UIFont.preferredFont(forTextStyle: .headline)
|
|
nameLabel.textColor = .label
|
|
nameLabel.numberOfLines = 2
|
|
nameLabel.textAlignment = .center
|
|
nameLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
if let badge = nameBadgeAttachment(profileBadge, font: nameLabel.font) {
|
|
let s = NSMutableAttributedString(string: profileName)
|
|
s.append(badge)
|
|
nameLabel.attributedText = s
|
|
} else {
|
|
nameLabel.text = profileName
|
|
}
|
|
|
|
var profileViews = [profileImage, nameLabel]
|
|
|
|
// Full name label
|
|
if !profileFullName.isEmpty && profileFullName != profileName {
|
|
let fullNameLabel = UILabel()
|
|
fullNameLabel.text = profileFullName
|
|
fullNameLabel.font = UIFont.preferredFont(forTextStyle: .subheadline)
|
|
fullNameLabel.textColor = .label
|
|
fullNameLabel.numberOfLines = 2
|
|
fullNameLabel.textAlignment = .center
|
|
fullNameLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
profileViews.append(fullNameLabel)
|
|
}
|
|
|
|
// Subtitle label (e.g. subscriber count)
|
|
if let subtitle {
|
|
let subtitleLabel = UILabel()
|
|
subtitleLabel.text = subtitle
|
|
subtitleLabel.font = UIFont.preferredFont(forTextStyle: .footnote)
|
|
subtitleLabel.textColor = .secondaryLabel
|
|
subtitleLabel.numberOfLines = 3
|
|
subtitleLabel.textAlignment = .center
|
|
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
profileViews.append(subtitleLabel)
|
|
}
|
|
|
|
// Information label (e.g. owner verification)
|
|
if let information {
|
|
let infoLabel = UILabel()
|
|
infoLabel.text = information
|
|
infoLabel.font = UIFont.preferredFont(forTextStyle: .footnote)
|
|
infoLabel.textColor = .label
|
|
infoLabel.numberOfLines = 3
|
|
infoLabel.textAlignment = .center
|
|
infoLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
profileViews.append(infoLabel)
|
|
}
|
|
|
|
// Horizontal stack for image + name
|
|
let stack = UIStackView(arrangedSubviews: profileViews)
|
|
stack.axis = .vertical
|
|
stack.spacing = 12
|
|
stack.alignment = .center
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let topRowContainer = UIView()
|
|
topRowContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
topRowContainer.addSubview(stack)
|
|
|
|
NSLayoutConstraint.activate([
|
|
stack.topAnchor.constraint(equalTo: topRowContainer.topAnchor),
|
|
stack.bottomAnchor.constraint(equalTo: topRowContainer.bottomAnchor),
|
|
stack.leadingAnchor.constraint(equalTo: topRowContainer.leadingAnchor, constant: 20),
|
|
stack.trailingAnchor.constraint(equalTo: topRowContainer.trailingAnchor, constant: -20)
|
|
])
|
|
|
|
// Buttons
|
|
let cancelButton = UIButton(type: .system)
|
|
cancelButton.setTitle(cancelTitle, for: .normal)
|
|
let bodyDescr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
|
|
cancelButton.titleLabel?.font = UIFont(descriptor: bodyDescr.withSymbolicTraits(.traitBold) ?? bodyDescr, size: 0)
|
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
|
|
|
let buttonStack: UIStackView
|
|
var buttonDividerConstraints: [NSLayoutConstraint] = []
|
|
|
|
if let confirmTitle, let secondTitle {
|
|
// Three buttons (a sibling action is present) — always vertical
|
|
let confirmButton = UIButton(type: .system)
|
|
confirmButton.setTitle(confirmTitle, for: .normal)
|
|
confirmButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
|
|
|
let secondButton = UIButton(type: .system)
|
|
secondButton.setTitle(secondTitle, for: .normal)
|
|
secondButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
|
secondButton.addTarget(self, action: #selector(secondTapped), for: .touchUpInside)
|
|
|
|
buttonStack = UIStackView(arrangedSubviews: [confirmButton, secondButton, cancelButton])
|
|
buttonStack.axis = .vertical
|
|
buttonStack.distribution = .fillEqually
|
|
buttonStack.spacing = 0
|
|
buttonStack.translatesAutoresizingMaskIntoConstraints = false
|
|
buttonStack.heightAnchor.constraint(greaterThanOrEqualToConstant: alertButtonHeight * 3).isActive = true
|
|
|
|
for button in [secondButton, cancelButton] {
|
|
let divider = UIView()
|
|
divider.backgroundColor = UIColor.separator
|
|
divider.translatesAutoresizingMaskIntoConstraints = false
|
|
buttonStack.addSubview(divider)
|
|
buttonDividerConstraints += [
|
|
divider.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
|
divider.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
|
divider.bottomAnchor.constraint(equalTo: button.topAnchor),
|
|
divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
|
|
]
|
|
}
|
|
} else if let confirmTitle {
|
|
let confirmButton = UIButton(type: .system)
|
|
confirmButton.setTitle(confirmTitle, for: .normal)
|
|
confirmButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
|
|
|
let verticalButtons = cancelButton.intrinsicContentSize.width + 20 >= alertWidth / 2 || confirmButton.intrinsicContentSize.width + 20 >= alertWidth / 2
|
|
|
|
// Button stack with equal width buttons
|
|
buttonStack = UIStackView(arrangedSubviews: verticalButtons ? [confirmButton, cancelButton] : [cancelButton, confirmButton])
|
|
buttonStack.axis = verticalButtons ? .vertical : .horizontal
|
|
buttonStack.distribution = .fillEqually
|
|
buttonStack.spacing = 0 // no spacing, use divider instead
|
|
buttonStack.translatesAutoresizingMaskIntoConstraints = false
|
|
buttonStack.heightAnchor.constraint(greaterThanOrEqualToConstant: alertButtonHeight * (verticalButtons ? 2 : 1)).isActive = true
|
|
|
|
// Add divider between buttons
|
|
let buttonDivider = UIView()
|
|
buttonDivider.backgroundColor = UIColor.separator
|
|
buttonDivider.translatesAutoresizingMaskIntoConstraints = false
|
|
buttonStack.addSubview(buttonDivider)
|
|
|
|
buttonDividerConstraints = if verticalButtons {
|
|
[
|
|
buttonDivider.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
|
buttonDivider.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
|
buttonDivider.centerYAnchor.constraint(equalTo: buttonStack.centerYAnchor),
|
|
buttonDivider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
|
|
]
|
|
} else {
|
|
[
|
|
buttonDivider.topAnchor.constraint(equalTo: buttonStack.topAnchor),
|
|
buttonDivider.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
|
buttonDivider.centerXAnchor.constraint(equalTo: buttonStack.centerXAnchor),
|
|
buttonDivider.widthAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
|
|
]
|
|
}
|
|
} else {
|
|
// Single button
|
|
buttonStack = UIStackView(arrangedSubviews: [cancelButton])
|
|
buttonStack.axis = .horizontal
|
|
buttonStack.distribution = .fillEqually
|
|
buttonStack.translatesAutoresizingMaskIntoConstraints = false
|
|
buttonStack.heightAnchor.constraint(greaterThanOrEqualToConstant: alertButtonHeight).isActive = true
|
|
}
|
|
|
|
// Vertical stack containing hStack and buttonStack
|
|
let vStack = UIStackView(arrangedSubviews: [topRowContainer, buttonStack])
|
|
vStack.axis = .vertical
|
|
vStack.spacing = 16
|
|
vStack.alignment = .fill // important: buttons stretch full width
|
|
vStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
containerView.addSubview(vStack)
|
|
|
|
// Add horizontal divider above buttons
|
|
let horizontalDivider = UIView()
|
|
horizontalDivider.backgroundColor = UIColor.separator
|
|
horizontalDivider.translatesAutoresizingMaskIntoConstraints = false
|
|
containerView.addSubview(horizontalDivider)
|
|
|
|
NSLayoutConstraint.activate([
|
|
// Container view centering and fixed width
|
|
containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
|
containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
containerView.widthAnchor.constraint(equalToConstant: alertWidth),
|
|
|
|
// Vertical stack padding inside containerView
|
|
vStack.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 20),
|
|
vStack.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 0),
|
|
vStack.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 0),
|
|
vStack.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 0),
|
|
|
|
// Center hStack horizontally inside vStack's padded width
|
|
stack.centerXAnchor.constraint(equalTo: vStack.centerXAnchor),
|
|
|
|
// Horizontal divider above buttons
|
|
horizontalDivider.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
|
horizontalDivider.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
|
horizontalDivider.bottomAnchor.constraint(equalTo: buttonStack.topAnchor),
|
|
horizontalDivider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
|
|
] + buttonDividerConstraints)
|
|
}
|
|
|
|
@objc private func cancelTapped() {
|
|
dismiss(animated: true) {
|
|
self.onCancel()
|
|
}
|
|
}
|
|
|
|
@objc private func confirmTapped() {
|
|
dismiss(animated: true) {
|
|
self.onConfirm?()
|
|
}
|
|
}
|
|
|
|
@objc private func secondTapped() {
|
|
dismiss(animated: true) {
|
|
self.onSecond?()
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
func showOpenChatAlert<Content: View>(
|
|
profileName: String,
|
|
profileFullName: String,
|
|
profileImage: Content,
|
|
profileBadge: LocalBadge? = nil,
|
|
theme: AppTheme,
|
|
subtitle: String? = nil,
|
|
information: String? = nil,
|
|
cancelTitle: String = "Cancel",
|
|
confirmTitle: String? = "Open",
|
|
secondTitle: String? = nil,
|
|
onCancel: @escaping () -> Void = {},
|
|
onConfirm: (() -> Void)? = nil,
|
|
onSecond: (() -> Void)? = nil
|
|
) {
|
|
let themedView = profileImage.environmentObject(theme)
|
|
let hostingController = UIHostingController(rootView: themedView)
|
|
let hostedView = hostingController.view!
|
|
hostedView.backgroundColor = .clear
|
|
|
|
if let topVC = getTopViewController() {
|
|
let alertVC = OpenChatAlertViewController(
|
|
profileName: profileName,
|
|
profileFullName: profileFullName,
|
|
profileImage: hostedView,
|
|
profileBadge: profileBadge,
|
|
subtitle: subtitle,
|
|
information: information,
|
|
cancelTitle: cancelTitle,
|
|
confirmTitle: confirmTitle,
|
|
secondTitle: secondTitle,
|
|
onCancel: onCancel,
|
|
onConfirm: onConfirm,
|
|
onSecond: onSecond
|
|
)
|
|
topVC.present(alertVC, animated: true)
|
|
}
|
|
}
|