2
2
mirror of https://github.com/mastodon/mastodon-ios synced 2025-04-11 22:58:02 +02:00

Add Terms of Service to the onboarding flow

Fixes IOS-385
This commit is contained in:
shannon 2025-04-07 17:29:26 -04:00
parent 1421795eab
commit fa207e3a44
12 changed files with 218 additions and 50 deletions

View File

@ -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"

View File

@ -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"

View File

@ -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))

View File

@ -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<AnyCancellable>()
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)
}
}
}

View File

@ -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)])
]
}
}

View File

@ -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")
}
}
}

View File

@ -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)

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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";

View File

@ -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")!

View File

@ -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"
}
}
}