passepartout-apple/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift

684 lines
23 KiB
Swift
Raw Normal View History

2018-10-11 07:13:19 +00:00
//
// OrganizerViewController.swift
// Passepartout-iOS
//
// Created by Davide De Rosa on 9/2/18.
2019-03-09 10:44:44 +00:00
// Copyright (c) 2019 Davide De Rosa. All rights reserved.
2018-10-11 07:13:19 +00:00
//
2018-11-03 21:33:30 +00:00
// https://github.com/passepartoutvpn
2018-10-11 07:13:19 +00:00
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import UIKit
import StoreKit
import MessageUI
import PassepartoutCore
2018-10-11 07:13:19 +00:00
// XXX: convoluted due to the separation of provider/host profiles
class OrganizerViewController: UITableViewController, TableModelHost {
private let service = TransientStore.shared.service
private var providers: [String] = []
2018-10-11 07:13:19 +00:00
private var hosts: [String] = []
2018-10-11 07:13:19 +00:00
private var availableProviderNames: [Infrastructure.Name]?
2018-10-18 07:52:23 +00:00
private var didShowSubreddit = false
2018-10-11 07:13:19 +00:00
// MARK: TableModelHost
let model: TableModel<SectionType, RowType> = {
let model: TableModel<SectionType, RowType> = TableModel()
2019-04-10 14:38:28 +00:00
model.add(.vpn)
2018-10-11 07:13:19 +00:00
model.add(.providers)
model.add(.hosts)
2019-03-18 19:14:59 +00:00
if #available(iOS 12, *) {
model.add(.siri)
}
model.add(.support)
2019-04-15 12:29:31 +00:00
model.add(.feedback)
2018-10-11 07:13:19 +00:00
model.add(.about)
model.add(.destruction)
model.setHeader(L10n.App.Service.Sections.Vpn.header, for: .vpn)
model.setHeader(L10n.Core.Organizer.Sections.Providers.header, for: .providers)
model.setHeader(L10n.Core.Organizer.Sections.Hosts.header, for: .hosts)
model.setFooter(L10n.Core.Organizer.Sections.Providers.footer, for: .providers)
model.setFooter(L10n.Core.Organizer.Sections.Hosts.footer, for: .hosts)
2019-03-18 19:14:59 +00:00
if #available(iOS 12, *) {
model.setHeader(L10n.Core.Organizer.Sections.Siri.header, for: .siri)
model.setFooter(L10n.Core.Organizer.Sections.Siri.footer, for: .siri)
2019-03-18 19:14:59 +00:00
model.set([.siriShortcuts], in: .siri)
}
model.setHeader(L10n.Core.Organizer.Sections.Support.header, for: .support)
model.setHeader(L10n.Core.Organizer.Sections.Feedback.header, for: .feedback)
2019-04-10 14:38:28 +00:00
model.set([.connectionStatus], in: .vpn)
model.set([.donate, .patreon, .translate], in: .support)
2019-04-15 12:29:31 +00:00
model.set([.joinCommunity, .writeReview], in: .feedback)
2018-10-11 07:13:19 +00:00
model.set([.openAbout], in: .about)
model.set([.uninstall], in: .destruction)
if AppConstants.Flags.isBeta {
model.add(.test)
model.setHeader("Beta", for: .test)
2019-03-07 21:59:46 +00:00
model.set([.testDisplayLog, .testTermination], in: .test)
}
2018-10-11 07:13:19 +00:00
return model
}()
func reloadModel() {
providers = service.ids(forContext: .provider).sorted()
2018-10-27 14:48:38 +00:00
hosts = service.ids(forContext: .host).sortedCaseInsensitive()
2018-10-11 07:13:19 +00:00
var providerRows = [RowType](repeating: .profile, count: providers.count)
var hostRows = [RowType](repeating: .profile, count: hosts.count)
providerRows.append(.addProvider)
hostRows.append(.addHost)
2018-10-11 07:13:19 +00:00
model.set(providerRows, in: .providers)
model.set(hostRows, in: .hosts)
2018-10-11 07:13:19 +00:00
}
// MARK: UIViewController
2019-04-10 14:38:28 +00:00
deinit {
NotificationCenter.default.removeObserver(self)
}
2018-10-11 07:13:19 +00:00
override func awakeFromNib() {
super.awakeFromNib()
applyMasterTitle(Theme.current)
}
override func viewDidLoad() {
super.viewDidLoad()
title = GroupConstants.App.title
navigationItem.rightBarButtonItem = editButtonItem
Cells.destructive.register(with: tableView)
reloadModel()
tableView.reloadData()
service.delegate = self
2019-04-10 14:38:28 +00:00
NotificationCenter.default.addObserver(self, selector: #selector(vpnDidUpdate), name: .VPNDidChangeStatus, object: nil)
2018-10-11 07:13:19 +00:00
}
2018-10-18 07:52:23 +00:00
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !didShowSubreddit && !TransientStore.didHandleSubreddit {
2018-10-18 07:52:23 +00:00
didShowSubreddit = true
let alert = Macros.alert(L10n.Core.Reddit.title, L10n.Core.Reddit.message)
alert.addDefaultAction(L10n.Core.Reddit.Buttons.subscribe) {
TransientStore.didHandleSubreddit = true
2018-10-18 07:52:23 +00:00
self.subscribeSubreddit()
}
alert.addAction(L10n.Core.Reddit.Buttons.never) {
TransientStore.didHandleSubreddit = true
2018-10-18 07:52:23 +00:00
}
alert.addCancelAction(L10n.Core.Reddit.Buttons.remind)
2018-10-18 07:52:23 +00:00
present(alert, animated: true, completion: nil)
}
}
2018-10-11 07:13:19 +00:00
override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool {
if let cell = sender as? UITableViewCell, let indexPath = tableView.indexPath(for: cell) {
return model.row(at: indexPath) == .profile
}
// fall back to active profile if no selection
return service.hasActiveProfile()
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
let destination = (segue.destination as? UINavigationController)?.topViewController
if let vc = destination as? ServiceViewController {
var selectedProfile: ConnectionProfile?
// XXX: sender can be a cell or a profile
selectedProfile = sender as? ConnectionProfile
if selectedProfile == nil, let cell = sender as? UITableViewCell, let indexPath = tableView.indexPath(for: cell) {
selectedProfile = profile(at: indexPath)
}
guard selectedProfile != nil else {
assertionFailure("No selected profile")
return
}
2018-10-11 07:13:19 +00:00
vc.setProfile(selectedProfile)
} else if let providerVC = destination as? WizardProviderViewController {
providerVC.availableNames = availableProviderNames ?? []
2018-10-11 07:13:19 +00:00
}
}
// MARK: Actions
private func enterProfile(_ profile: ConnectionProfile) {
perform(segue: StoryboardSegue.Organizer.selectProfileSegueIdentifier, sender: profile)
}
private func enterActiveProfile() {
guard let activeProfile = service.activeProfile else {
return
}
enterProfile(activeProfile)
}
2018-10-11 07:13:19 +00:00
private func addNewProvider() {
2019-06-28 17:10:42 +00:00
let names = service.availableProviderNames()
2018-10-11 07:13:19 +00:00
guard !names.isEmpty else {
let alert = Macros.alert(
L10n.Core.Organizer.Sections.Providers.header,
L10n.Core.Organizer.Alerts.ExhaustedProviders.message
2018-10-11 07:13:19 +00:00
)
alert.addCancelAction(L10n.Core.Global.ok)
2018-10-11 07:13:19 +00:00
present(alert, animated: true, completion: nil)
return
}
2019-06-28 17:10:42 +00:00
availableProviderNames = names
2018-10-11 07:13:19 +00:00
perform(segue: StoryboardSegue.Organizer.addProviderSegueIdentifier)
}
private func addNewHost() {
perform(segue: StoryboardSegue.Organizer.showImportedHostsSegueIdentifier)
2018-10-11 07:13:19 +00:00
}
2019-03-18 19:14:59 +00:00
private func addShortcuts() {
perform(segue: StoryboardSegue.Organizer.siriShortcutsSegueIdentifier)
}
2018-10-11 07:13:19 +00:00
2019-04-06 21:11:07 +00:00
private func donateToDeveloper() {
guard SKPaymentQueue.canMakePayments() else {
let alert = Macros.alert(
L10n.Core.Organizer.Cells.Donate.caption,
L10n.Core.Organizer.Alerts.CannotDonate.message
)
alert.addCancelAction(L10n.Core.Global.ok)
present(alert, animated: true, completion: nil)
return
}
2019-04-06 21:58:19 +00:00
perform(segue: StoryboardSegue.Organizer.donateSegueIdentifier, sender: nil)
2019-04-06 21:11:07 +00:00
}
private func visitPatreon() {
UIApplication.shared.open(AppConstants.URLs.patreon, options: [:], completionHandler: nil)
}
private func offerTranslation() {
let V = AppConstants.Translations.Email.self
let recipient = V.recipient
let subject = V.subject
let body = V.body(V.template)
guard MFMailComposeViewController.canSendMail() else {
let app = UIApplication.shared
guard let url = Utils.mailto(to: recipient, subject: subject, body: body), app.canOpenURL(url) else {
let alert = Macros.alert(L10n.Core.Translations.title, L10n.Core.Global.emailNotConfigured)
alert.addCancelAction(L10n.Core.Global.ok)
present(alert, animated: true, completion: nil)
return
}
app.open(url, options: [:], completionHandler: nil)
return
}
let vc = MFMailComposeViewController()
vc.setToRecipients([recipient])
vc.setSubject(subject)
vc.setMessageBody(body, isHTML: false)
vc.mailComposeDelegate = self
vc.apply(Theme.current)
present(vc, animated: true, completion: nil)
}
private func about() {
perform(segue: StoryboardSegue.Organizer.aboutSegueIdentifier, sender: nil)
}
2018-10-11 07:13:19 +00:00
private func removeProfile(at indexPath: IndexPath) {
let sectionObject = model.section(for: indexPath.section)
let rowProfile = profileKey(at: indexPath)
2018-10-11 07:13:19 +00:00
switch sectionObject {
case .providers:
providers.remove(at: indexPath.row)
2018-10-11 07:13:19 +00:00
case .hosts:
hosts.remove(at: indexPath.row)
2018-10-11 07:13:19 +00:00
default:
return
}
// var fallbackSection: SectionType?
let total = providers.count + hosts.count
2018-10-11 07:13:19 +00:00
// removed all profiles
if total == 0 {
VPN.shared.disconnect(completionHandler: nil)
}
// removed active profile
else if service.isActiveProfile(rowProfile) {
// let anyProvider = providerProfiles.first
// let anyHost = hostProfiles.first
// guard let anyProfile: ConnectionProfile = firstProvider ?? firstHost else {
// fatalError("There must be one profile somewhere")
// }
// fallbackSection = (anyProvider != nil) ? .providers : .hosts
// store.service.activateProfile(only)
VPN.shared.disconnect(completionHandler: nil)
}
tableView.beginUpdates()
model.deleteRow(in: sectionObject, at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .automatic)
// if let fallbackSection = fallbackSection {
// let section = model.index(ofSection: fallbackSection)
// tableView.reloadRows(at: [IndexPath(row: 0, section: section)], with: .none)
// }
tableView.endUpdates()
service.removeProfile(rowProfile)
if #available(iOS 12, *) {
IntentDispatcher.forgetProfile(withKey: rowProfile)
}
2018-10-11 07:13:19 +00:00
}
private func confirmVpnProfileDeletion() {
let alert = Macros.alert(
L10n.Core.Organizer.Cells.Uninstall.caption,
L10n.Core.Organizer.Alerts.DeleteVpnProfile.message
2018-10-11 07:13:19 +00:00
)
alert.addDefaultAction(L10n.Core.Global.ok) {
2018-10-11 07:13:19 +00:00
VPN.shared.uninstall(completionHandler: nil)
}
alert.addCancelAction(L10n.Core.Global.cancel)
2018-10-11 07:13:19 +00:00
present(alert, animated: true, completion: nil)
}
2018-10-18 07:52:23 +00:00
private func subscribeSubreddit() {
UIApplication.shared.open(AppConstants.URLs.subreddit, options: [:], completionHandler: nil)
}
2019-04-15 12:29:31 +00:00
private func writeReview() {
let url = AppConstants.URLs.review(withId: AppConstants.App.appStoreId)
2019-04-15 12:29:31 +00:00
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
2019-03-07 21:59:46 +00:00
//
private func testDisplayLog() {
guard let log = try? String(contentsOf: AppConstants.Log.fileURL) else {
return
}
let alert = Macros.alert("Debug log", log)
alert.addCancelAction(L10n.Core.Global.ok)
2019-03-07 21:59:46 +00:00
present(alert, animated: true, completion: nil)
}
private func testTermination() {
exit(0)
}
2019-04-10 14:38:28 +00:00
// MARK: Notifications
@objc private func vpnDidUpdate() {
tableView.reloadData()
}
2018-10-11 07:13:19 +00:00
}
// MARK: -
extension OrganizerViewController {
enum SectionType: Int {
2019-04-10 14:38:28 +00:00
case vpn
2018-10-11 07:13:19 +00:00
case providers
case hosts
2019-03-18 19:14:59 +00:00
case siri
case support
2019-04-15 12:29:31 +00:00
case feedback
2018-10-11 07:13:19 +00:00
case about
case destruction
case test
2018-10-11 07:13:19 +00:00
}
enum RowType: Int {
2019-04-10 14:38:28 +00:00
case connectionStatus
2018-10-11 07:13:19 +00:00
case profile
case addProvider
case addHost
2019-03-18 19:14:59 +00:00
case siriShortcuts
2019-04-06 21:11:07 +00:00
case donate
2019-04-06 21:11:07 +00:00
case patreon
case translate
2019-04-15 12:29:31 +00:00
case joinCommunity
case writeReview
2019-04-06 21:11:07 +00:00
2018-10-11 07:13:19 +00:00
case openAbout
case uninstall
2019-03-07 21:59:46 +00:00
case testDisplayLog
case testTermination
2018-10-11 07:13:19 +00:00
}
override func numberOfSections(in tableView: UITableView) -> Int {
return model.count
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return model.header(for: section)
}
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
return model.footer(for: section)
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return model.count(for: section)
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch model.row(at: indexPath) {
2019-04-10 14:38:28 +00:00
case .connectionStatus:
let cell = Cells.setting.dequeue(from: tableView, for: indexPath)
cell.applyVPN(Theme.current, with: VPN.shared.isEnabled ? VPN.shared.status : nil, error: nil)
cell.leftText = L10n.Core.Service.Cells.ConnectionStatus.caption
2019-04-10 14:38:28 +00:00
return cell
2018-10-11 07:13:19 +00:00
case .profile:
let cell = Cells.setting.dequeue(from: tableView, for: indexPath)
let rowProfile = profileKey(at: indexPath)
if rowProfile.context == .provider, let providerName = Infrastructure.Name(rawValue: rowProfile.id) {
cell.imageView?.image = providerName.logo
} else {
cell.imageView?.image = nil
}
cell.leftText = rowProfile.id
2019-06-23 08:33:43 +00:00
cell.rightText = service.isActiveProfile(rowProfile) ? L10n.Core.Organizer.Cells.Profile.Value.current : nil
2018-10-11 07:13:19 +00:00
return cell
case .addProvider:
let cell = Cells.setting.dequeue(from: tableView, for: indexPath)
cell.applyAction(Theme.current)
cell.leftText = L10n.App.Organizer.Cells.AddProvider.caption
2018-10-11 07:13:19 +00:00
return cell
case .addHost:
let cell = Cells.setting.dequeue(from: tableView, for: indexPath)
cell.applyAction(Theme.current)
cell.leftText = L10n.App.Organizer.Cells.AddHost.caption
2018-10-11 07:13:19 +00:00
return cell
2019-03-18 19:14:59 +00:00
case .siriShortcuts:
let cell = Cells.setting.dequeue(from: tableView, for: indexPath)
cell.applyAction(Theme.current)
cell.leftText = L10n.Core.Organizer.Cells.SiriShortcuts.caption
2019-03-18 19:14:59 +00:00
return cell
2019-04-06 21:11:07 +00:00
case .donate:
let cell = Cells.setting.dequeue(from: tableView, for: indexPath)
cell.leftText = L10n.Core.Organizer.Cells.Donate.caption
2019-04-06 21:11:07 +00:00
return cell
case .patreon:
let cell = Cells.setting.dequeue(from: tableView, for: indexPath)
cell.leftText = L10n.Core.Organizer.Cells.Patreon.caption
return cell
case .translate:
let cell = Cells.setting.dequeue(from: tableView, for: indexPath)
cell.leftText = L10n.Core.Organizer.Cells.Translate.caption
return cell
2019-04-15 12:29:31 +00:00
case .joinCommunity:
let cell = Cells.setting.dequeue(from: tableView, for: indexPath)
cell.leftText = L10n.Core.Organizer.Cells.JoinCommunity.caption
2019-04-15 12:29:31 +00:00
return cell
case .writeReview:
let cell = Cells.setting.dequeue(from: tableView, for: indexPath)
cell.leftText = L10n.Core.Organizer.Cells.WriteReview.caption
2019-04-15 12:29:31 +00:00
return cell
2018-10-11 07:13:19 +00:00
case .openAbout:
let cell = Cells.setting.dequeue(from: tableView, for: indexPath)
cell.leftText = L10n.Core.Organizer.Cells.About.caption(GroupConstants.App.name)
2019-03-22 09:14:33 +00:00
cell.rightText = Utils.versionString()
2018-10-11 07:13:19 +00:00
return cell
2019-04-06 21:11:07 +00:00
2018-10-11 07:13:19 +00:00
case .uninstall:
let cell = Cells.destructive.dequeue(from: tableView, for: indexPath)
cell.caption = L10n.Core.Organizer.Cells.Uninstall.caption
2018-10-11 07:13:19 +00:00
return cell
2019-03-07 21:59:46 +00:00
case .testDisplayLog:
let cell = Cells.setting.dequeue(from: tableView, for: indexPath)
cell.leftText = "Display current log"
return cell
case .testTermination:
2019-03-07 21:59:46 +00:00
let cell = Cells.setting.dequeue(from: tableView, for: indexPath)
cell.leftText = "Terminate app"
return cell
2018-10-11 07:13:19 +00:00
}
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
switch model.row(at: indexPath) {
2019-04-10 14:38:28 +00:00
case .connectionStatus:
enterActiveProfile()
2019-04-10 14:38:28 +00:00
2018-10-11 07:13:19 +00:00
case .profile:
enterProfile(profile(at: indexPath))
2018-10-11 07:13:19 +00:00
case .addProvider:
addNewProvider()
case .addHost:
addNewHost()
2019-03-18 19:14:59 +00:00
case .siriShortcuts:
addShortcuts()
2019-04-06 21:11:07 +00:00
case .donate:
donateToDeveloper()
case .patreon:
visitPatreon()
case .translate:
offerTranslation()
2019-04-15 12:29:31 +00:00
case .joinCommunity:
subscribeSubreddit()
case .writeReview:
writeReview()
2018-10-11 07:13:19 +00:00
case .openAbout:
about()
case .uninstall:
confirmVpnProfileDeletion()
2019-03-07 21:59:46 +00:00
case .testDisplayLog:
testDisplayLog()
case .testTermination:
testTermination()
2018-10-11 07:13:19 +00:00
}
tableView.deselectRow(at: indexPath, animated: true)
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
guard tableView.isEditing else {
return false
}
return model.row(at: indexPath) == .profile
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
removeProfile(at: indexPath)
}
// MARK: Helpers
private func sectionProfiles(at indexPath: IndexPath) -> [String] {
let ids: [String]
2018-10-11 07:13:19 +00:00
let sectionObject = model.section(for: indexPath.section)
switch sectionObject {
case .providers:
ids = providers
2018-10-11 07:13:19 +00:00
case .hosts:
ids = hosts
2018-10-11 07:13:19 +00:00
default:
fatalError("Unexpected section: \(sectionObject)")
}
guard indexPath.row < ids.count else {
2018-10-11 07:13:19 +00:00
fatalError("No profile found at \(indexPath), is it an add cell?")
}
return ids
2018-10-11 07:13:19 +00:00
}
2018-11-06 10:08:15 +00:00
private func profileKey(at indexPath: IndexPath) -> ProfileKey {
let section = model.section(for: indexPath.section)
switch section {
case .providers:
2018-11-06 10:08:15 +00:00
return ProfileKey(.provider, providers[indexPath.row])
case .hosts:
2018-11-06 10:08:15 +00:00
return ProfileKey(.host, hosts[indexPath.row])
default:
fatalError("Profile found in unexpected section: \(section)")
}
}
2018-10-11 07:13:19 +00:00
private func profile(at indexPath: IndexPath) -> ConnectionProfile {
let id = sectionProfiles(at: indexPath)[indexPath.row]
let section = model.section(for: indexPath.section)
let context: Context
switch section {
case .providers:
context = .provider
case .hosts:
context = .host
default:
fatalError("Profile found in unexpected section: \(section)")
}
guard let found = service.profile(withContext: context, id: id) else {
fatalError("Profile (\(context), \(id)) could not be found, why was it returned?")
}
return found
2018-10-11 07:13:19 +00:00
}
}
// MARK: -
extension OrganizerViewController: ConnectionServiceDelegate {
func connectionService(didAdd profile: ConnectionProfile) {
TransientStore.shared.serialize(withProfiles: false) // add
2018-10-11 07:13:19 +00:00
reloadModel()
tableView.reloadData()
if #available(iOS 12, *) {
IntentDispatcher.donateEnableVPN()
}
// XXX: hack around bad replace when detail presented in compact view
if let detailNav = navigationController?.viewControllers.last as? UINavigationController {
var existingServiceVC: ServiceViewController?
for vc in detailNav.viewControllers {
if let found = vc as? ServiceViewController {
existingServiceVC = found
break
}
}
let serviceVC = existingServiceVC ?? (StoryboardScene.Main.serviceIdentifier.instantiate().topViewController as! ServiceViewController)
serviceVC.setProfile(profile)
detailNav.setViewControllers([serviceVC], animated: true)
return
}
2018-10-11 07:13:19 +00:00
perform(segue: StoryboardSegue.Organizer.selectProfileSegueIdentifier, sender: profile)
}
func connectionService(didRename oldProfile: ConnectionProfile, to newProfile: ConnectionProfile) {
TransientStore.shared.serialize(withProfiles: false) // rename
reloadModel()
tableView.reloadData()
}
2018-11-06 10:08:15 +00:00
func connectionService(didRemoveProfileWithKey key: ProfileKey) {
TransientStore.shared.serialize(withProfiles: false) // delete
splitViewController?.serviceViewController?.hideProfileIfDeleted()
}
// XXX: deactivate + activate leads to a redundant serialization
func connectionService(willDeactivate profile: ConnectionProfile) {
TransientStore.shared.serialize(withProfiles: false) // deactivate
tableView.reloadData()
}
func connectionService(didActivate profile: ConnectionProfile) {
TransientStore.shared.serialize(withProfiles: false) // activate
tableView.reloadData()
if #available(iOS 12, *) {
IntentDispatcher.donateEnableVPN()
}
}
2018-10-11 07:13:19 +00:00
}
extension OrganizerViewController: MFMailComposeViewControllerDelegate {
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
dismiss(animated: true, completion: nil)
}
}