diff --git a/Localization/StringsConvertor/input/Base.lproj/app.json b/Localization/StringsConvertor/input/Base.lproj/app.json index f325e7d72..9ab4f5fd3 100644 --- a/Localization/StringsConvertor/input/Base.lproj/app.json +++ b/Localization/StringsConvertor/input/Base.lproj/app.json @@ -376,9 +376,13 @@ "privacy": { "title": "Your Privacy", "description": "Although the Mastodon app does not collect any data, the server you sign up through may have a different policy.\n\nIf you disagree with the policy for **%s**, you can go back and pick a different server.", + "terms_of_service_title": "Terms of Service", + "terms_of_service": "Terms of Service - %s", + "terms_of_service_description": "Please review the terms of service for **%s**. If you disagree, you can go back and pick a different server." "policy": { "ios": "Privacy Policy - Mastodon for iOS", - "server": "Privacy Policy - %s" + "server": "Privacy Policy - %s", + "terms_of_service": "Terms of Service - %s" }, "button": { "confirm": "I Agree" diff --git a/Localization/app.json b/Localization/app.json index f325e7d72..9ab4f5fd3 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -376,9 +376,13 @@ "privacy": { "title": "Your Privacy", "description": "Although the Mastodon app does not collect any data, the server you sign up through may have a different policy.\n\nIf you disagree with the policy for **%s**, you can go back and pick a different server.", + "terms_of_service_title": "Terms of Service", + "terms_of_service": "Terms of Service - %s", + "terms_of_service_description": "Please review the terms of service for **%s**. If you disagree, you can go back and pick a different server." "policy": { "ios": "Privacy Policy - Mastodon for iOS", - "server": "Privacy Policy - %s" + "server": "Privacy Policy - %s", + "terms_of_service": "Terms of Service - %s" }, "button": { "confirm": "I Agree" diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index bd485b820..ec5bcd477 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -160,7 +160,7 @@ extension SceneCoordinator { case welcome case mastodonPickServer(viewMode: MastodonPickServerViewModel) case mastodonRegister(viewModel: MastodonRegisterViewModel) - case mastodonPrivacyPolicies(viewModel: PrivacyViewModel) + case mastodonPrivacyPolicies(viewModel: PolicyViewModel) case mastodonServerRules(viewModel: MastodonServerRulesView.ViewModel) case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel) case mastodonResendEmail(viewModel: MastodonResendEmailViewModel) @@ -408,8 +408,8 @@ private extension SceneCoordinator { viewController = loginViewController case .mastodonPrivacyPolicies(let viewModel): - let privacyViewController = PrivacyTableViewController(coordinator: self, viewModel: viewModel) - viewController = privacyViewController + let policyViewController = PolicyTableViewController(coordinator: self, viewModel: viewModel) + viewController = policyViewController case .mastodonResendEmail(let viewModel): let _viewController = MastodonResendEmailViewController() _viewController.viewModel = viewModel @@ -651,11 +651,7 @@ extension SceneCoordinator: SettingsCoordinatorDelegate { @MainActor func openPrivacyURL(_ settingsCoordinator: SettingsCoordinator) { - guard let authenticationBox else { return } - - let domain = authenticationBox.domain - let privacyURL = Mastodon.API.privacyURL(domain: domain) - + guard let privacyURL = URL(string: "https://joinmastodon.org/ios/privacy") else { return } _ = present(scene: .safari(url: privacyURL), from: settingsCoordinator.navigationController, transition: .safariPresent(animated: true)) diff --git a/Mastodon/Scene/Onboarding/Privacy/PrivacyTableViewController.swift b/Mastodon/Scene/Onboarding/Privacy/PrivacyTableViewController.swift index 37baa62fa..90b806641 100644 --- a/Mastodon/Scene/Onboarding/Privacy/PrivacyTableViewController.swift +++ b/Mastodon/Scene/Onboarding/Privacy/PrivacyTableViewController.swift @@ -11,37 +11,48 @@ import MastodonCore import MastodonSDK import MastodonLocalization import MastodonAsset +import Combine -enum PrivacyRow { - case iOSApp - case server(domain: String) +enum PolicyRow { + case iosAppPrivacy + case serverPrivacy(domain: String) + case serverTermsOfService(domain: String, confirmedReachable: Bool) var url: URL? { switch self { - case .iOSApp: - return URL(string: "https://joinmastodon.org/ios/privacy") - case .server(let domain): - return URL(string: "https://\(domain)/privacy-policy") + case .iosAppPrivacy: + return URL(string: "https://joinmastodon.org/ios/privacy") + case .serverPrivacy(let domain): + return URL(string: "https://\(domain)/privacy-policy") + case .serverTermsOfService(let domain, _): + return URL(string: "\(URL.httpScheme(domain: domain))://" + domain + "/terms-of-service") } } var title: String { switch self { - case .iOSApp: + case .iosAppPrivacy: return L10n.Scene.Privacy.Policy.ios - case .server(let domain): + case .serverPrivacy(let domain): return L10n.Scene.Privacy.Policy.server(domain) + case .serverTermsOfService(let domain, let fetched): + if fetched { + return L10n.Scene.Privacy.Policy.termsOfService(domain) + } else { + return "..." + } } } } -class PrivacyTableViewController: UIViewController { +class PolicyTableViewController: UIViewController { private let coordinator: SceneCoordinator private let tableView: UITableView - let viewModel: PrivacyViewModel + let viewModel: PolicyViewModel + var disposeBag = Set() - init(coordinator: SceneCoordinator, viewModel: PrivacyViewModel) { + init(coordinator: SceneCoordinator, viewModel: PolicyViewModel) { self.coordinator = coordinator self.viewModel = viewModel @@ -58,9 +69,16 @@ class PrivacyTableViewController: UIViewController { view.addSubview(tableView) setupConstraints() - navigationItem.rightBarButtonItem = UIBarButtonItem(title: L10n.Scene.Privacy.Button.confirm, style: .done, target: self, action: #selector(PrivacyTableViewController.nextButtonPressed(_:))) - + navigationItem.rightBarButtonItem = UIBarButtonItem(title: L10n.Scene.Privacy.Button.confirm, style: .done, target: self, action: #selector(PolicyTableViewController.nextButtonPressed(_:))) + title = L10n.Scene.Privacy.title + + viewModel.$sections.receive(on: DispatchQueue.main) + .sink { [weak self] newSections in + self?.title = newSections.count > 1 ? L10n.Scene.Privacy.termsOfServiceTitle : L10n.Scene.Privacy.title + self?.tableView.reloadData() + } + .store(in: &disposeBag) } required init?(coder: NSCoder) { fatalError("init(coder:) won't been implemented, please don't use Storyboards.") } @@ -86,16 +104,33 @@ class PrivacyTableViewController: UIViewController { } } -extension PrivacyTableViewController: UITableViewDataSource { +extension PolicyTableViewController: UITableViewDataSource { + + private func rows(forSection sectionIndex: Int) -> [PolicyRow] { + let section = viewModel.sections[sectionIndex] + switch section { + case .termsOfService(let rows), .privacy(let rows): + return rows + } + } + + private func row(at indexPath: IndexPath) -> PolicyRow { + return rows(forSection: indexPath.section)[indexPath.row] + } + + func numberOfSections(in tableView: UITableView) -> Int { + return viewModel.sections.count + } + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return viewModel.rows.count + return rows(forSection: section).count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell(withIdentifier: PrivacyTableViewCell.reuseIdentifier, for: indexPath) as? PrivacyTableViewCell else { fatalError("Wrong cell?") } - let row = viewModel.rows[indexPath.row] - + let row = row(at: indexPath) + var contentConfiguration = cell.defaultContentConfiguration() contentConfiguration.textProperties.color = Asset.Colors.Brand.blurple.color contentConfiguration.text = row.title @@ -107,21 +142,24 @@ extension PrivacyTableViewController: UITableViewDataSource { } } -extension PrivacyTableViewController: UITableViewDelegate { +extension PolicyTableViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - let row = viewModel.rows[indexPath.row] + let row = row(at: indexPath) guard let url = row.url else { return } _ = coordinator.present(scene: .safari(url: url), from: self, transition: .safariPresent(animated: true)) } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let sectionItem = viewModel.sections[section] + let wrapper = UIView() let controller = UIHostingController( rootView: HeaderTextView( - text: LocalizedStringKey(L10n.Scene.Privacy.description(viewModel.domain)) + title: section == 0 ? nil : LocalizedStringKey(sectionItem.title), + text: LocalizedStringKey(sectionItem.description(viewModel.domain) ?? "") ) ) guard let label = controller.view else { return nil } @@ -141,15 +179,26 @@ extension PrivacyTableViewController: UITableViewDelegate { } } -extension PrivacyTableViewController: OnboardingViewControllerAppearance { } +extension PolicyTableViewController: OnboardingViewControllerAppearance { } private struct HeaderTextView: View { + let title: LocalizedStringKey? let text: LocalizedStringKey var body: some View { - Text(text) - .fixedSize(horizontal: false, vertical: true) - .foregroundStyle(Asset.Colors.Label.primary.swiftUIColor) - .padding(.bottom, 16) + VStack(alignment: .leading) { + if let title { + Text(title) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(Asset.Colors.Label.primary.swiftUIColor) + .font(.title) + .padding(.bottom, 16) + } + Text(text) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(Asset.Colors.Label.primary.swiftUIColor) + .padding(.bottom, 16) + .padding(.leading, 5) + } } } diff --git a/Mastodon/Scene/Onboarding/Privacy/PrivacyViewModel.swift b/Mastodon/Scene/Onboarding/Privacy/PrivacyViewModel.swift index 45ae8654a..2cf2afddf 100644 --- a/Mastodon/Scene/Onboarding/Privacy/PrivacyViewModel.swift +++ b/Mastodon/Scene/Onboarding/Privacy/PrivacyViewModel.swift @@ -7,13 +7,38 @@ import Foundation import MastodonSDK +import MastodonLocalization +import SwiftUICore -final class PrivacyViewModel { +enum PolicySection { + case termsOfService([PolicyRow]) + case privacy([PolicyRow]) + + var title: String { + switch self { + case .termsOfService: + return L10n.Scene.Privacy.termsOfServiceTitle + case .privacy: + return L10n.Scene.Privacy.title + } + } + + func description(_ domain: String) -> String? { + switch self { + case .termsOfService: + return L10n.Scene.Privacy.termsOfServiceDescription(domain) + case .privacy: + return L10n.Scene.Privacy.description(domain) + } + } +} + +class PolicyViewModel: ObservableObject { // input let domain: String let authenticateInfo: AuthenticationViewModel.AuthenticateInfo - let rows: [PrivacyRow] + @Published var sections: [PolicySection] let instance: RegistrationInstance let applicationToken: Mastodon.Entity.Token let didAccept: ()->() @@ -21,16 +46,53 @@ final class PrivacyViewModel { init( domain: String, authenticateInfo: AuthenticationViewModel.AuthenticateInfo, - rows: [PrivacyRow], instance: RegistrationInstance, applicationToken: Mastodon.Entity.Token, didAccept: @escaping ()->() ) { self.domain = domain self.authenticateInfo = authenticateInfo - self.rows = rows self.instance = instance self.applicationToken = applicationToken self.didAccept = didAccept + self.sections = [ + .termsOfService([.serverTermsOfService(domain: domain, confirmedReachable: false)]), + .privacy([.iosAppPrivacy, .serverPrivacy(domain: domain)]) + ] + + checkForTermsOfService(domain) + } + + func checkForTermsOfService(_ domain: String) { + guard let termsOfService = instance.termsOfService else { removeTermsOfServiceSection(); return } + + var request = URLRequest(url: termsOfService) + request.httpMethod = "HEAD" + URLSession(configuration: .default) + .dataTask(with: request) { (_, response, error) -> Void in + guard error == nil else { + self.removeTermsOfServiceSection() + return + } + + guard (response as? HTTPURLResponse)? + .statusCode == 200 else { + self.removeTermsOfServiceSection() + return + } + + self.sections = [ + .termsOfService([.serverTermsOfService(domain: domain, confirmedReachable: true)]), + .privacy([.iosAppPrivacy, .serverPrivacy(domain: domain)]) + ] + } + .resume() + + } + + func removeTermsOfServiceSection() { + sections = [ + .privacy([.iosAppPrivacy, .serverPrivacy(domain: domain)]) + ] } } diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 0e3d472c7..0e36c6609 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -513,6 +513,8 @@ protocol RegistrationInstance { var isBeyondVersion1: Bool { get } var isOpenToNewRegistrations: Bool? { get } var rules: [Mastodon.Entity.Instance.Rule]? { get } + var termsOfService: URL? { get } + var privacyPolicy: URL? { get } } extension Mastodon.Entity.Instance: RegistrationInstance { @@ -524,6 +526,14 @@ extension Mastodon.Entity.Instance: RegistrationInstance { var reasonRequired: Bool { return approvalRequired ?? false } + + var termsOfService: URL? { + return nil + } + + var privacyPolicy: URL? { + return URL(string: "https://\(uri)/privacy-policy") + } } extension Mastodon.Entity.V2.Instance: RegistrationInstance { @@ -534,4 +544,27 @@ extension Mastodon.Entity.V2.Instance: RegistrationInstance { var reasonRequired: Bool { return registrations?.reasonRequired ?? approvalRequired ?? false } + + var termsOfService: URL? { + if version?.serverVersionGreaterThanOrEqual(toMajorVersion: 4, minorVersion: 4) ?? false { + if let string = urls?.termsOfService { + return URL(string: string) + } else { + guard let domain else { return nil } + return URL(string: "https://\(domain)/terms-of-service") + } + } else { + return nil + } + } + + var privacyPolicy: URL? { + if version?.serverVersionGreaterThanOrEqual(toMajorVersion: 4, minorVersion: 4) ?? false { + guard let string = urls?.privacyPolicy else { return nil } + return URL(string: string) + } else { + guard let domain else { return nil } + return URL(string: "https://\(domain)/privacy-policy") + } + } } diff --git a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift index 50e87def7..9a8ff0285 100644 --- a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift +++ b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift @@ -151,7 +151,7 @@ extension AuthenticationViewModel { disclaimer: LocalizedStringKey(L10n.Scene.ServerRules.subtitle(server.domain)), rules: rules.map({ $0.text }), onAgree: { [weak self] in - let privacyViewModel = PrivacyViewModel(domain: server.domain, authenticateInfo: authenticateInfo, rows: [.iOSApp, .server(domain: server.domain)], instance: instance, applicationToken: applicationToken, didAccept: { doStartRegistration() }) + let privacyViewModel = PolicyViewModel(domain: server.domain, authenticateInfo: authenticateInfo, instance: instance, applicationToken: applicationToken, didAccept: { doStartRegistration() }) self?.stateStreamContinuation.yield(.showingPrivacyPolicy(privacyViewModel)) }, onDisagree: { [weak self] in self?.stateStreamContinuation.yield(.showingRules(nil)) }) @@ -281,7 +281,7 @@ extension AuthenticationViewModel { case joiningServer(Mastodon.Entity.Server) case showingRules(MastodonServerRulesView.ViewModel?) // nil when we're returning to a previously configured state case registering(MastodonRegisterViewModel) - case showingPrivacyPolicy(PrivacyViewModel) + case showingPrivacyPolicy(PolicyViewModel) case confirmingEmail(MastodonConfirmEmailViewModel) case authenticatingUser case authenticatedUser(MastodonAuthenticationBox) diff --git a/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift b/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift index 49c73cb00..e41e41b88 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/InstanceService.swift @@ -70,4 +70,13 @@ public extension String { return majorVersionInt >= comparedVersion } + func serverVersionGreaterThanOrEqual(toMajorVersion majorThreshold: Int, minorVersion minorThreshold: Int?) -> Bool { + let majorAndMinor = split(separator: ".").prefix(2) + let major = majorAndMinor.first + let minor = majorAndMinor.count > 1 ? majorAndMinor[1] : "0" + guard let major, let majorVersionInt = Int(major) else { return false } + guard let minorThreshold, minorThreshold > 0 else { return majorVersionInt >= majorThreshold } + guard let minorVersionInt = Int(minor) else { return false } + return majorVersionInt >= majorThreshold && minorVersionInt >= minorThreshold + } } diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index afada161a..1fedc8fec 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -1195,6 +1195,12 @@ public enum L10n { public static func description(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Privacy.Description", String(describing: p1), fallback: "Although the Mastodon app does not collect any data, the server you sign up through may have a different policy.\n\nIf you disagree with the policy for **%@**, you can go back and pick a different server.") } + /// Please review the terms of service for **%@**. If you disagree, you can go back and pick a different server. + public static func termsOfServiceDescription(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Privacy.TermsOfServiceDescription", String(describing: p1), fallback: "Please review the terms of service for **%@**. If you disagree, you can go back and pick a different server.") + } + /// Terms of Service + public static let termsOfServiceTitle = L10n.tr("Localizable", "Scene.Privacy.TermsOfServiceTitle", fallback: "Terms of Service") /// Your Privacy public static let title = L10n.tr("Localizable", "Scene.Privacy.Title", fallback: "Your Privacy") public enum Button { @@ -1208,6 +1214,10 @@ public enum L10n { public static func server(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Privacy.Policy.Server", String(describing: p1), fallback: "Privacy Policy - %@") } + /// Terms of Service - %@ + public static func termsOfService(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Privacy.Policy.TermsOfService", String(describing: p1), fallback: "Terms of Service - %@") + } } } public enum Profile { diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index 6e4422324..0af2e8ea6 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -418,12 +418,15 @@ Please retry in a few minutes."; "Scene.Preview.Keyboard.ShowNext" = "Show Next"; "Scene.Preview.Keyboard.ShowPrevious" = "Show Previous"; "Scene.Privacy.Button.Confirm" = "I Agree"; +"Scene.Privacy.TermsOfServiceDescription" = "Please review the terms of service for **%@**. If you disagree, you can go back and pick a different server."; +"Scene.Privacy.Policy.TermsOfService" = "Terms of Service - %@"; "Scene.Privacy.Description" = "Although the Mastodon app does not collect any data, the server you sign up through may have a different policy. If you disagree with the policy for **%@**, you can go back and pick a different server."; "Scene.Privacy.Policy.Ios" = "Privacy Policy - Mastodon for iOS"; "Scene.Privacy.Policy.Server" = "Privacy Policy - %@"; "Scene.Privacy.Title" = "Your Privacy"; +"Scene.Privacy.TermsOfServiceTitle" = "Terms of Service"; "Scene.Profile.Accessibility.DoubleTapToOpenTheList" = "Double tap to open the list"; "Scene.Profile.Accessibility.EditAvatarImage" = "Edit avatar image"; "Scene.Profile.Accessibility.ShowAvatarImage" = "Show avatar image"; diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 92215dfe5..6e869e07a 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -87,14 +87,6 @@ extension Mastodon.API { public static func resendEmailURL(domain: String) -> URL { return URL(string: "\(URL.httpScheme(domain: domain))://" + domain + "/auth/confirmation/new")! } - - public static func serverRulesURL(domain: String) -> URL { - return URL(string: "\(URL.httpScheme(domain: domain))://" + domain + "/about/more")! - } - - public static func privacyURL(domain: String) -> URL { - return URL(string: "\(URL.httpScheme(domain: domain))://" + domain + "/terms")! - } public static func profileSettingsURL(domain: String) -> URL { return URL(string: "\(URL.httpScheme(domain: domain))://" + domain + "/auth/edit")! diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift index fc34d841a..f84673a5a 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift @@ -83,9 +83,15 @@ extension Mastodon.Entity { extension Mastodon.Entity.Instance { public struct InstanceURL: Codable { public let streamingAPI: String + public let aboutPage: String? + public let privacyPolicy: String? // added in 4.4.0 + public let termsOfService: String? // added in 4.4.0 enum CodingKeys: String, CodingKey { case streamingAPI = "streaming_api" + case aboutPage = "about" + case privacyPolicy = "privacy_policy" + case termsOfService = "terms_of_service" } } }