mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-14 07:10:19 +00:00
Correctness. The one-time link picker's profile list was keyed on the active user as well as the count. That defeats the "don't change order after a user was selected" behaviour its own comment protects - changeActiveUser_ sets currentUser first and only then reloads chats, so the list visibly re-sorted and stayed re-sorted for the whole of getUserChatData - and it was redundant: the count alone covers a profile being created from the picker. iOS, compose picker: the old-core fallback relied on the user switch tearing this view down, which only happens when the switch succeeded. If it threw, the form was left open over a profile that already existed, and the only way out was to swipe it away. It is now dismissed unconditionally. iOS: alerts raised right after a sheet is dismissed are presented on a controller that is going away and are dropped. The 0.5s wait was applied only to the old-core path, while the ordinary reassignment failures - the ones that actually happen - went unguarded. alertAfterDismissal now covers all of them, in one place. iOS: dropped the chatId write that Kotlin removed for the same reason two commits ago. It is a no-op on the happy path, but not if something legitimately closed the chat meanwhile - SimpleX lock re-auth clears it, and this would reopen the chat behind the lock screen. iOS: the create-profile row is now gated on profileChangeProhibited, as every other row on both platforms already was. Surface. apiCreateActiveUser takes keepActiveUser as a defaulted argument, exactly as it already takes pastTimestamp, instead of a public wrapper plus a private helper. Two symbols fewer on each platform, and every existing call site passes its arguments by name, so none of them changes. Reverted the activeOrder sort flip in the compose picker: it changes the order of an existing screen for every user, is not needed for this feature, and with active_order 0 would push a newly created profile to the bottom. It deserves its own commit if wanted. Also: apps/ios/spec/api.md tracks the command enum and had gone stale; the plan records the two limits of the flag the review surfaced - it is ignored when there is no active user, and active_order 0 ties rather than sorts last on migrated databases.
468 lines
19 KiB
Swift
468 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)
|
|
}
|
|
}
|
|
|
|
/// An alert raised while a sheet is dismissing is presented on a controller that is going
|
|
/// away, and is dropped - getTopViewController() keeps returning it until the transition
|
|
/// ends. Use this when the caller has just dismissed something.
|
|
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)
|
|
}
|
|
}
|