diff --git a/Localization/app.json b/Localization/app.json index dc47417ef..44fa0d26f 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -791,7 +791,7 @@ "general": "General", "notifications": "Notifications", "privacy_safety": "Privacy & Safety", - "support_mastodon": "Support Mastodon", + "support_mastodon": "Donate to Mastodon", "about_mastodon": "About Mastodon", "server_details": "Server Details", "logout": "Logout %@" @@ -880,6 +880,10 @@ "show_followers_and_following": "Show Followers & Following", "suggest_my_account_to_others": "Suggest My Account to Others", "appear_in_search_engines": "Appear in Search Engines" + }, + "donation": { + "title": "Donate to Mastodon", + "manage": "Manage donations" } }, "report": { @@ -965,6 +969,22 @@ "follow": "Follow", "unfollow": "Unfollow" } + }, + "donation": { + "picker": { + "once_title": "Just once", + "monthly_title": "Monthly", + "yearly_title": "Yearly" + }, + "donatebuttontitle": "Donate", + "currency": "Currency", + "success": { + "title": "Thank you for your contribution!", + "subtitle": "You should receive an email confirming your donation soon.", + "server_error_title": "Payment failed", + "server_error_message": "We are sorry, an error occurred and we have not been able to process your donation.\n\nPlease retry in a few minutes.", + "share_button_title": "Spread the word" + } } }, "extension": { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 6059e87f4..19f28fe52 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -41,6 +41,9 @@ 2A631AE82B8C9F6600FE0778 /* LanguagePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A631AE72B8C9F6600FE0778 /* LanguagePickerViewController.swift */; }; 2A64515E29642A8A00CD8B8A /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A6451022964223800CD8B8A /* UniformTypeIdentifiers.framework */; }; 2A64516929642A8B00CD8B8A /* OpenInActionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2A64515D29642A8A00CD8B8A /* OpenInActionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 2A64A2912C92EF0C00E5E913 /* HomeTimelineViewModel+Donation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A64A2902C92EF0C00E5E913 /* HomeTimelineViewModel+Donation.swift */; }; + 2A64A2942C92F71500E5E913 /* DonationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A64A2932C92F71500E5E913 /* DonationViewController.swift */; }; + 2A64A2962C92FD5700E5E913 /* DonationBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A64A2952C92FD5700E5E913 /* DonationBanner.swift */; }; 2A71F541296DBDA80049F54A /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A71F53D296DBDA80049F54A /* Media.xcassets */; }; 2A71F542296DBDA80049F54A /* Action.js in Resources */ = {isa = PBXBuildFile; fileRef = 2A71F53E296DBDA80049F54A /* Action.js */; }; 2A71F543296DBDA80049F54A /* ActionRequestHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A71F53F296DBDA80049F54A /* ActionRequestHandler.swift */; }; @@ -509,6 +512,10 @@ DBFEEC99279BDCDE004F81DD /* ProfileAboutViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEEC98279BDCDE004F81DD /* ProfileAboutViewModel.swift */; }; DBFEEC9B279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEEC9A279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift */; }; DBFEEC9D279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEEC9C279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift */; }; + FB7C4CC62CD2CAB000F6129A /* DonationCompletionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7C4CC52CD2CAA800F6129A /* DonationCompletionViewController.swift */; }; + FB7C4CCC2CD55DEB00F6129A /* NavigationFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7C4CCB2CD55DEB00F6129A /* NavigationFlow.swift */; }; + FB7C4CCE2CD55DFF00F6129A /* NewDonationNavigationFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7C4CCD2CD55DFE00F6129A /* NewDonationNavigationFlow.swift */; }; + FBD689B52CCBF0AC00CE29F3 /* DonationCampaignViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBD689B42CCBF09F00CE29F3 /* DonationCampaignViewModel.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -660,6 +667,9 @@ 2A631AE72B8C9F6600FE0778 /* LanguagePickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguagePickerViewController.swift; sourceTree = ""; }; 2A6451022964223800CD8B8A /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; }; 2A64515D29642A8A00CD8B8A /* OpenInActionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenInActionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A64A2902C92EF0C00E5E913 /* HomeTimelineViewModel+Donation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+Donation.swift"; sourceTree = ""; }; + 2A64A2932C92F71500E5E913 /* DonationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationViewController.swift; sourceTree = ""; }; + 2A64A2952C92FD5700E5E913 /* DonationBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationBanner.swift; sourceTree = ""; }; 2A71F53D296DBDA80049F54A /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = ""; }; 2A71F53E296DBDA80049F54A /* Action.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = Action.js; sourceTree = ""; }; 2A71F53F296DBDA80049F54A /* ActionRequestHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionRequestHandler.swift; sourceTree = ""; }; @@ -1257,6 +1267,10 @@ DBFEF06726A58D07006D7ED1 /* ShareActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareActionExtension.entitlements; sourceTree = ""; }; E9AABD3D26B64B8C00E237DA /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Intents.strings; sourceTree = ""; }; E9AABD4026B64B8D00E237DA /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; + FB7C4CC52CD2CAA800F6129A /* DonationCompletionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationCompletionViewController.swift; sourceTree = ""; }; + FB7C4CCB2CD55DEB00F6129A /* NavigationFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationFlow.swift; sourceTree = ""; }; + FB7C4CCD2CD55DFE00F6129A /* NewDonationNavigationFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewDonationNavigationFlow.swift; sourceTree = ""; }; + FBD689B42CCBF09F00CE29F3 /* DonationCampaignViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationCampaignViewModel.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1436,6 +1450,19 @@ path = Language; sourceTree = ""; }; + 2A64A2922C92F70700E5E913 /* Donation */ = { + isa = PBXGroup; + children = ( + 2A64A2952C92FD5700E5E913 /* DonationBanner.swift */, + FBD689B42CCBF09F00CE29F3 /* DonationCampaignViewModel.swift */, + 2A64A2932C92F71500E5E913 /* DonationViewController.swift */, + FB7C4CC52CD2CAA800F6129A /* DonationCompletionViewController.swift */, + FB7C4CCB2CD55DEB00F6129A /* NavigationFlow.swift */, + FB7C4CCD2CD55DFE00F6129A /* NewDonationNavigationFlow.swift */, + ); + path = Donation; + sourceTree = ""; + }; 2A71F53C296DBDA80049F54A /* OpenInActionExtension */ = { isa = PBXGroup; children = ( @@ -1556,6 +1583,7 @@ 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */, 2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */, 2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */, + 2A64A2902C92EF0C00E5E913 /* HomeTimelineViewModel+Donation.swift */, D84738D32BBD9ABE00ECD52B /* TimelineStatusPill.swift */, ); path = HomeTimeline; @@ -2578,6 +2606,7 @@ DB8AF55525C1379F002E6C99 /* Scene */ = { isa = PBXGroup; children = ( + 2A64A2922C92F70700E5E913 /* Donation */, 2D7631A425C1532200929FB9 /* Share */, DB6180E426391A500018D199 /* Transition */, DB852D1D26FB021900FC9D81 /* Root */, @@ -3545,6 +3574,8 @@ DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */, DBEFCD80282A2AA900C0ABEA /* ReportServerRulesViewModel.swift in Sources */, + FB7C4CCE2CD55DFF00F6129A /* NewDonationNavigationFlow.swift in Sources */, + FB7C4CCC2CD55DEB00F6129A /* NavigationFlow.swift in Sources */, DB0617FF27855D6C0030EE79 /* MastodonServerRulesViewModel+Diffable.swift in Sources */, DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */, DB0FCB982797F6BF006C02E2 /* UserTableViewCell+ViewModel.swift in Sources */, @@ -3563,6 +3594,7 @@ DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */, 62FD27D12893707600B205C5 /* BookmarkViewController.swift in Sources */, DB63F767279A5EB300455B82 /* NotificationTimelineViewModel.swift in Sources */, + 2A64A2942C92F71500E5E913 /* DonationViewController.swift in Sources */, 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */, DB5B54AB2833C12A00DEF8B2 /* RebloggedByViewController.swift in Sources */, D81D12462A4E1861005009D4 /* PolicySelectionViewController.swift in Sources */, @@ -3603,6 +3635,7 @@ DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */, DBA9443E265CFA6400C537E1 /* ProfileFieldCollectionViewCell.swift in Sources */, + FBD689B52CCBF0AC00CE29F3 /* DonationCampaignViewModel.swift in Sources */, 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */, DBFEEC9D279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift in Sources */, DB3E6FEC2806D7F100B035AE /* DiscoveryNewsViewController.swift in Sources */, @@ -3654,6 +3687,7 @@ DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */, DB5B729E273113F300081888 /* FollowingListViewModel+State.swift in Sources */, D8B5E4F02A4EB8A00008970C /* NotificationSettingTableViewCell.swift in Sources */, + 2A64A2912C92EF0C00E5E913 /* HomeTimelineViewModel+Donation.swift in Sources */, DBF9814C265E339500E4BA07 /* ProfileFieldAddEntryCollectionViewCell.swift in Sources */, DB63F76227996B6600455B82 /* SearchHistoryViewController+DataSourceProvider.swift in Sources */, DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */, @@ -3815,6 +3849,7 @@ 2A0BF97F2C0622AA004A1E29 /* PrivacySafetyViewController.swift in Sources */, DB3E6FE22806A50100B035AE /* DiscoveryHashtagsViewModel+Diffable.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, + FB7C4CC62CD2CAB000F6129A /* DonationCompletionViewController.swift in Sources */, DB0FCB822796AC78006C02E2 /* UserTimelineViewController+DataSourceProvider.swift in Sources */, DB0EF72E26FDB24F00347686 /* SidebarListContentView.swift in Sources */, D85DF96D2C481AF700A01408 /* NotificationPolicyHeaderView.swift in Sources */, @@ -3893,6 +3928,7 @@ DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, DBEFCD7D282A2A3B00C0ABEA /* ReportServerRulesViewController.swift in Sources */, DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, + 2A64A2962C92FD5700E5E913 /* DonationBanner.swift in Sources */, D8F917122A4C6B67008A5370 /* GeneralSettingsViewController.swift in Sources */, D81A94122B07A1BE0067A19D /* ProfileCardTableViewCell.swift in Sources */, DB98EB4927B0F0CD0082E365 /* ReportStatusTableViewCell.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 4ca70d8ec..b6924cd53 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -429,8 +429,7 @@ private extension SceneCoordinator { _viewController.viewModel = viewModel viewController = _viewController case .mastodonWebView(let viewModel): - let _viewController = WebViewController() - _viewController.viewModel = viewModel + let _viewController = WebViewController(viewModel) viewController = _viewController case .searchDetail(let viewModel): let _viewController = SearchDetailViewController(appContext: appContext, sceneCoordinator: self, authContext: viewModel.authContext) diff --git a/Mastodon/Scene/Donation/DonationBanner.swift b/Mastodon/Scene/Donation/DonationBanner.swift new file mode 100644 index 000000000..cba429712 --- /dev/null +++ b/Mastodon/Scene/Donation/DonationBanner.swift @@ -0,0 +1,125 @@ +// Copyright © 2024 Mastodon gGmbH. All rights reserved. + +import MastodonAsset +import MastodonSDK +import UIKit + +class DonationBanner: UIView { + private enum Constants { + static let padding: CGFloat = 16 + static let textToButtonPadding: CGFloat = 48 + } + + public private(set) var campaign: Mastodon.Entity.DonationCampaign? + private lazy var backgroundImageView = UIImageView( + image: Asset.Asset.scribble.image) + private let messageLabel = UILabel() + private lazy var closeButton: UIButton = { + let button = UIButton(type: .custom) + button.setImage(.init(systemName: "xmark"), for: .normal) + return button + }() + private lazy var tapGestureRecognizer: UITapGestureRecognizer = { + let gestureRecognizer = UITapGestureRecognizer( + target: self, action: #selector(showDonationDialogPressed(_:))) + gestureRecognizer.numberOfTapsRequired = 1 + return gestureRecognizer + }() + + init() { + super.init(frame: .zero) + self.overrideUserInterfaceStyle = .dark + setupViews() + } + + var onClose: ((String?) -> Void)? + var onShowDonationDialog: ((Mastodon.Entity.DonationCampaign) -> Void)? + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(campaign: Mastodon.Entity.DonationCampaign) { + self.campaign = campaign + let spacing = " " + let stringValue = + "\(campaign.bannerMessage)\(spacing)\(campaign.bannerButtonText)" + let attributedString = NSMutableAttributedString(string: stringValue) + let fullTextRange = NSRange(location: 0, length: stringValue.length) + let buttonRange = NSRange( + location: campaign.bannerMessage.length + spacing.length, + length: campaign.bannerButtonText.length) + attributedString.addAttributes( + [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont( + for: .systemFont(ofSize: 14, weight: .regular)), + .foregroundColor: Asset.Colors.Secondary.onContainer.color, + ], + range: fullTextRange + ) + attributedString.addAttributes( + [ + .font: UIFontMetrics(forTextStyle: .body).scaledFont( + for: .systemFont(ofSize: 14, weight: .bold)), + .underlineStyle: NSUnderlineStyle.single.rawValue, + .underlineColor: Asset.Colors.goldenrod.color, + .foregroundColor: Asset.Colors.goldenrod.color, + ], + range: buttonRange + ) + messageLabel.attributedText = attributedString + } + + private func setupViews() { + addGestureRecognizer(tapGestureRecognizer) + backgroundColor = Asset.Colors.Secondary.container.color + addSubview(backgroundImageView) + backgroundImageView.translatesAutoresizingMaskIntoConstraints = false + backgroundImageView.alpha = 0.08 + + closeButton.tintColor = Asset.Colors.Secondary.onContainer.color + + messageLabel.translatesAutoresizingMaskIntoConstraints = false + messageLabel.numberOfLines = 0 + + closeButton.translatesAutoresizingMaskIntoConstraints = false + closeButton.setContentCompressionResistancePriority( + .required, for: .horizontal) + closeButton.addTarget( + self, action: #selector(closeButtonPressed(_:)), for: .touchUpInside + ) + addSubview(messageLabel) + addSubview(closeButton) + + NSLayoutConstraint.activate([ + backgroundImageView.trailingAnchor.constraint( + equalTo: trailingAnchor), + backgroundImageView.centerYAnchor.constraint( + equalTo: centerYAnchor), + messageLabel.leadingAnchor.constraint( + equalTo: leadingAnchor, constant: Constants.padding), + messageLabel.topAnchor.constraint( + equalTo: topAnchor, constant: Constants.padding), + messageLabel.bottomAnchor.constraint( + equalTo: bottomAnchor, constant: -Constants.padding), + messageLabel.trailingAnchor.constraint( + equalTo: closeButton.leadingAnchor, + constant: -Constants.padding * 2), + closeButton.trailingAnchor.constraint( + equalTo: trailingAnchor, constant: -Constants.padding / 2), + closeButton.topAnchor.constraint(equalTo: topAnchor), + closeButton.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + @objc + private func closeButtonPressed(_ sender: Any?) { + onClose?(campaign?.id) + } + + @objc + private func showDonationDialogPressed(_ sender: Any?) { + guard let campaign else { return } + onShowDonationDialog?(campaign) + } +} diff --git a/Mastodon/Scene/Donation/DonationCampaignViewModel.swift b/Mastodon/Scene/Donation/DonationCampaignViewModel.swift new file mode 100644 index 000000000..c18a0f03d --- /dev/null +++ b/Mastodon/Scene/Donation/DonationCampaignViewModel.swift @@ -0,0 +1,184 @@ +// Copyright © 2024 Mastodon gGmbH. All rights reserved. +import Foundation +import MastodonSDK + +struct SuggestedDonation { + let unitAmount: Int + let plainString: String + let currencyFormattedString: String + + init(pennies: Int, currency: String) { + unitAmount = pennies / 100 + plainString = unitAmount.formatted( + .number.precision(.fractionLength(0))) + currencyFormattedString = unitAmount.formatted( + .currency(code: currency).precision(.fractionLength(0))) + } +} + +typealias DonationFrequency = Mastodon.Entity.DonationCampaign.DonationFrequency +typealias DonationSource = Mastodon.Entity.DonationCampaign.DonationSource + +protocol DonationCampaignViewModel { + var id: String { get } + func paymentURL( + currency: String, source: DonationSource, + frequency: Mastodon.Entity.DonationCampaign.DonationFrequency, + amount: Int + ) -> URL? + var paymentBaseURL: URL? { get } + var callbackBaseURL: URL? { get } + var source: DonationSource { get } + var donationMessage: String { get } + var defaultFrequency: DonationFrequency { get } + var defaultCurrency: String { get } + var defaultAmount: Int { get } + var availableFrequencies: [DonationFrequency] { get } + func suggestedDonations(frequency: DonationFrequency, currency: String, sorted: Bool) + -> [SuggestedDonation]? + func availableCurrencies(frequency: DonationFrequency) -> [String]? + var donationSuccessPost: String { get } +} + +extension DonationCampaignViewModel { + + public func paymentURL( + currency: String, source: DonationSource, + frequency: Mastodon.Entity.DonationCampaign.DonationFrequency, + amount: Int + ) -> URL? { + guard let paymentBaseURL = paymentBaseURL, + let callbackBaseURL = callbackBaseURL + else { return nil } + let successURL = callbackBaseURL.appendingPathComponent( + "success", isDirectory: false) + let cancelURL = callbackBaseURL.appendingPathComponent( + "cancel", isDirectory: false) + let failureURL = callbackBaseURL.appendingPathComponent( + "failure", isDirectory: false) + + let locale = Locale.current.identifier + var queryItems = [ + URLQueryItem(name: "platform", value: "ios"), + URLQueryItem(name: "locale", value: locale), + URLQueryItem(name: "currency", value: currency), + URLQueryItem(name: "source", value: source.queryValue), + URLQueryItem(name: "frequency", value: frequency.queryValue), + URLQueryItem(name: "amount", value: "\(amount)"), + URLQueryItem( + name: "success_callback_url", value: successURL.absoluteString), + /*must be one of + https://sponsor.joinmastodon.org/donate/success + https://sponsor.staging.joinmastodon.org/donate/success + */ + URLQueryItem( + name: "cancel_callback_url", value: cancelURL.absoluteString), + /*must be one of + https://sponsor.joinmastodon.org/donate/cancel + https://sponsor.staging.joinmastodon.org/donate/cancel + */ + URLQueryItem( + name: "failure_callback_url", value: failureURL.absoluteString), + /*must be one of + https://sponsor.joinmastodon.org/donate/failure + https://sponsor.staging.joinmastodon.org/donate/failure + */ + ] + switch source { + case .campaign(let id): + queryItems.append(URLQueryItem(name: "campaign_id", value: id)) + default: + break + } + #if DEBUG + queryItems.append( + URLQueryItem(name: "environment", value: "staging")) + #endif + var components = URLComponents(string: paymentBaseURL.absoluteString) + components?.queryItems = queryItems + return components?.url + } +} + +extension Mastodon.Entity.DonationCampaign: DonationCampaignViewModel { + + private typealias MulticurrencySuggestedDonationAmounts = [String: [Int]] + + var paymentBaseURL: URL? { + return URL(string: self.donationUrl) + } + var callbackBaseURL: URL? { + return URL(string: self.donationUrl)?.deletingLastPathComponent() + } + var source: DonationSource { + return .campaign(id: id) + } + var defaultFrequency: DonationFrequency { + return availableFrequencies.last ?? .monthly + } + var defaultCurrency: String { + let fallback = "EUR" + guard let currencies = availableCurrencies(frequency: defaultFrequency) + else { return fallback } + if let localCurrency = Locale.current.currency?.identifier, + currencies.contains(localCurrency) + { + return localCurrency + } + return currencies.first ?? fallback + } + var defaultAmount: Int { + let least = + suggestedDonations( + frequency: defaultFrequency, currency: defaultCurrency, sorted: false)?.first? + .unitAmount ?? 1 + return least + } + + var availableFrequencies: [DonationFrequency] { + return [.oneTime, .monthly, .yearly].filter { + suggestedAmounts($0)?.isNotEmpty ?? false + } + } + + private func suggestedAmounts(_ frequency: DonationFrequency) + -> MulticurrencySuggestedDonationAmounts? + { + switch frequency { + case .oneTime: + return amounts.oneTime + case .monthly: + return amounts.monthly + case .yearly: + return amounts.yearly + } + } + + func suggestedDonations(frequency: DonationFrequency, currency: String, sorted: Bool) + -> [SuggestedDonation]? + { + let multiCurrencySuggestions: MulticurrencySuggestedDonationAmounts? + + switch frequency { + case .monthly: + multiCurrencySuggestions = amounts.monthly + case .oneTime: + multiCurrencySuggestions = amounts.oneTime + case .yearly: + multiCurrencySuggestions = amounts.yearly + } + guard let rawAmounts = multiCurrencySuggestions?[currency] else { + return nil + } + + let inOrder = sorted ? rawAmounts.sorted().reversed() : rawAmounts + return inOrder.map { + SuggestedDonation(pennies: $0, currency: currency) + } + } + + func availableCurrencies(frequency: DonationFrequency) -> [String]? { + let suggestions = suggestedAmounts(frequency) + return suggestions?.keys.map { $0 } as? [String] + } +} diff --git a/Mastodon/Scene/Donation/DonationCompletionViewController.swift b/Mastodon/Scene/Donation/DonationCompletionViewController.swift new file mode 100644 index 000000000..4a2a3fb43 --- /dev/null +++ b/Mastodon/Scene/Donation/DonationCompletionViewController.swift @@ -0,0 +1,118 @@ +// Copyright © 2024 Mastodon gGmbH. All rights reserved. + +import MastodonAsset +import MastodonLocalization +import SwiftUI + +public enum DonationResult { + case successful(suggestedPost: String) + case failed + case canceled +} + +public enum DonationCompletionAction { + case makePost(string: String) + case close +} + +class DonationCompletionViewController: UIHostingController< + DonationCompletionView +> +{ + + init( + _ result: DonationResult, + completion: @escaping (DonationCompletionAction) -> Void + ) { + super.init( + rootView: DonationCompletionView( + result: result, completion: completion) + ) + } + + @MainActor required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +struct DonationCompletionView: View { + + let result: DonationResult + let completion: (DonationCompletionAction) -> Void + + init( + result: DonationResult, + completion: @escaping (DonationCompletionAction) -> Void + ) { + self.result = result + self.completion = completion + } + + var suggestedPost: String { + switch result { + case .successful(let suggestedPost): + return suggestedPost + case .failed, .canceled: + return "No suggested post" + } + } + + var body: some View { + GeometryReader { geometry in + VStack(spacing: 15) { + Spacer() + topMessage + subMessage + messageImage + Spacer() + buttons + } + .padding([.leading, .trailing], 30) + .frame(maxWidth: geometry.size.width) + } + } + + @ViewBuilder var topMessage: some View { + Text(L10n.Scene.Donation.Success.title) + .font(.largeTitle) + .bold() + .multilineTextAlignment(.center) + } + @ViewBuilder var subMessage: some View { + Text(L10n.Scene.Donation.Success.subtitle) + .multilineTextAlignment(.center) + } + + @ViewBuilder var messageImage: some View { + Image(uiImage: Asset.Asset.donationThankYou.image) + .resizable() + .scaledToFit() + } + + @ViewBuilder var buttons: some View { + VStack { + Button(action: { + completion(.makePost(string: suggestedPost)) + }) { + HStack { + Spacer() + Text(L10n.Scene.Donation.Success.shareButtonTitle) + Spacer() + } + } + .frame(maxWidth: .infinity) + .buttonStyle(DonationButtonStyle(type: .action, filled: true)) + Button(action: { + completion(.close) + }) { + HStack { + Spacer() + Text(L10n.Common.Controls.Actions.done) + Spacer() + } + } + .frame(maxWidth: .infinity) + .buttonStyle(DonationButtonStyle(type: .action, filled: false)) + } + } +} diff --git a/Mastodon/Scene/Donation/DonationViewController.swift b/Mastodon/Scene/Donation/DonationViewController.swift new file mode 100644 index 000000000..c79ee65a6 --- /dev/null +++ b/Mastodon/Scene/Donation/DonationViewController.swift @@ -0,0 +1,266 @@ +// Copyright © 2024 Mastodon gGmbH. All rights reserved. + +import Foundation +import MastodonAsset +import MastodonLocalization +import MastodonSDK +import SwiftUI +import UIKit + +class DonationViewController: UIHostingController { + + init( + campaign: DonationCampaignViewModel, + completion: @escaping (URL?) -> Void + ) { + super.init( + rootView: DonationView( + campaign, completion: completion)) + self.navigationItem.leftBarButtonItem = UIBarButtonItem(systemItem: .done, primaryAction: UIAction(handler: { _ in + completion(nil) + })) + self.navigationItem.title = L10n.Scene.Donation.title + } + + @MainActor required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension DonationFrequency { + var pickerLabel: String { + switch self { + case .monthly: + return L10n.Scene.Donation.Picker.monthlyTitle + case .yearly: + return L10n.Scene.Donation.Picker.yearlyTitle + case .oneTime: + return L10n.Scene.Donation.Picker.onceTitle + } + } +} + +struct DonationView: View { + let campaign: DonationCampaignViewModel + let completion: (URL?) -> Void + + @State private var selectedFrequency: DonationFrequency + @State private var selectedCurrency: String + @State private var selectedAmount: Int + + var urlForCurrentSelections: URL? { + campaign.paymentURL( + currency: selectedCurrency, source: campaign.source, + frequency: selectedFrequency, amount: selectedAmount * 100) // amount needs to be sent in pennies + } + + init( + _ campaign: DonationCampaignViewModel, + completion: @escaping (URL?) -> Void + ) { + self.completion = completion + self.campaign = campaign + _selectedFrequency = State(initialValue: campaign.defaultFrequency) + _selectedCurrency = State(initialValue: campaign.defaultCurrency) + _selectedAmount = State(initialValue: campaign.defaultAmount) + } + + var body: some View { + HStack { + Spacer() + VStack(spacing: 30) { + topMessage + frequencyPicker + amountEntry + donationButton + } + .frame(maxWidth: 328) + Spacer() + } + .padding(.top) + } + + @ViewBuilder var topMessage: some View { + Text(campaign.donationMessage) + } + + @ViewBuilder var frequencyPicker: some View { + Picker(selection: $selectedFrequency) { + // TODO: if there is only one available frequency, display a message instead of a single-segment picker + ForEach( + [DonationFrequency.oneTime, .monthly, .yearly].filter { + campaign.availableFrequencies.contains($0) + }, id: \.self + ) { + Text($0.pickerLabel) + .tag($0) + } + } label: { + } + .pickerStyle(.segmented) + // .onAppear { + // UISegmentedControl.appearance().selectedSegmentTintColor = Asset.Colors.Secondary.container.color + // } + } + + @ViewBuilder var amountEntry: some View { + VStack { + HStack { + Picker(selection: $selectedCurrency) { + ForEach( + campaign.availableCurrencies( + frequency: selectedFrequency) ?? [], id: \.self + ) { + Text($0) + .tag($0) + } + } label: { + Text(selectedCurrency) + } + .frame(height: 52) + .background(Color.gray.opacity(0.25)) + .clipShape(.rect(topLeadingRadius: 4, bottomLeadingRadius: 4)) + + TextField( + value: $selectedAmount, + format: .currency(code: selectedCurrency) + ) {} + .font(.title3) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .padding(.trailing, 8) + + } + .background( + RoundedRectangle(cornerRadius: 4.0).stroke( + Color.gray.opacity(0.25), lineWidth: 1)) + + HStack { + if let predefinedAmounts = campaign.suggestedDonations( + frequency: selectedFrequency, currency: selectedCurrency, sorted: true) + { + ForEach(predefinedAmounts, id: \.unitAmount) { amount in + Button(action: { + self.selectedAmount = amount.unitAmount + }) { + Text(amount.currencyFormattedString) + .frame(minWidth: 45) + } + .buttonStyle( + DonationButtonStyle( + type: .amount, + filled: self.selectedAmount == amount.unitAmount + )) + if amount.unitAmount + != predefinedAmounts.last!.unitAmount + { + Spacer() + } + } + } + } + .frame(maxWidth: .infinity) + } + } + + @ViewBuilder var donationButton: some View { + Button(action: { + if let urlForCurrentSelections = urlForCurrentSelections { + completion(urlForCurrentSelections) + } + }) { + HStack { + Spacer() + Text(L10n.Scene.Donation.donateButtonTitle) + Spacer() + } + .frame(maxWidth: .infinity) + } + .buttonStyle(DonationButtonStyle(type: .action, filled: true)) + } +} + +enum DonationButtonStyleType { + case amount + case action +} + +struct DonationButtonStyle: ButtonStyle { + + let type: DonationButtonStyleType + let filled: Bool + let cornerRadius: CGFloat = 8 + + func makeBody(configuration: Configuration) -> some View { + switch (type, filled) { + case (.amount, true): + configuration.label + .bold() + .padding() + .foregroundStyle(Color.white) + .background(Color.indigo) + .cornerRadius(cornerRadius) + case (.amount, false): + configuration.label + .padding() + .background(Color.indigo.opacity(0.15)) + .cornerRadius(cornerRadius) + case (.action, true): + configuration.label + .bold() + .foregroundStyle(.white) + .padding() + .background(Color.indigo) + .cornerRadius(cornerRadius) + case (.action, false): + configuration.label + .foregroundStyle(Color.indigo) + .padding() + .overlay( + RoundedRectangle(cornerRadius: cornerRadius).stroke( + Color.indigo, lineWidth: 1)) + } + } +} + +struct DefaultDonationViewModel: DonationCampaignViewModel { + var id: String = "default" + var paymentBaseURL: URL? { + if Mastodon.API.isTestingDonations { + URL(string: "https://sponsor.staging.joinmastodon.org/donation/new") + } else { + URL(string: "https://sponsor.joinmastodon.org/donation/new") + } + } + + var callbackBaseURL: URL? { + return paymentBaseURL?.deletingLastPathComponent() + } + + var source = DonationSource.menu + + var donationMessage = + "By supporting Mastodon, you help sustain a global network that values people over profit. Will you join us today?" // TODO: L10 string if this is going to remain hardcoded + + var defaultFrequency = DonationFrequency.monthly + + var defaultCurrency = "EUR" + + var defaultAmount = 5 + + var availableFrequencies = [DonationFrequency.monthly, .yearly, .oneTime] + + func suggestedDonations(frequency: DonationFrequency, currency: String, sorted: Bool) + -> [SuggestedDonation]? + { + return [300, 500, 1000, 2000].map { + SuggestedDonation(pennies: $0, currency: currency) + } + } + + func availableCurrencies(frequency: DonationFrequency) -> [String]? { + return ["EUR", "USD"] + } + + var donationSuccessPost = "Need default success post text and localized" // TODO: needs L10 string if remaining hardcoded +} diff --git a/Mastodon/Scene/Donation/NavigationFlow.swift b/Mastodon/Scene/Donation/NavigationFlow.swift new file mode 100644 index 000000000..c15a8d7c9 --- /dev/null +++ b/Mastodon/Scene/Donation/NavigationFlow.swift @@ -0,0 +1,87 @@ +// Copyright © 2024 Mastodon gGmbH. All rights reserved. + +import UIKit + +protocol NavigationFlowPresenter { + var currentlyDisplayedViewController: UIViewController? { get } + func show( + _ viewController: UIViewController, + preferredDetents: [UISheetPresentationController.Detent]) + func dismissFlow(initialViewController: UIViewController?) + func showAlert(_ alert: UIAlertController) +} + +@MainActor +class NavigationFlow { + let flowPresenter: NavigationFlowPresenter + let initialViewController: UIViewController? + private var completionHandler: (() -> Void)? + + init(flowPresenter: NavigationFlowPresenter) { + self.flowPresenter = flowPresenter + initialViewController = flowPresenter.currentlyDisplayedViewController + } + + final func presentFlow(completionHandler: @escaping (() -> Void)) { + self.completionHandler = completionHandler + startFlow() + } + + func startFlow() { + fatalError("subclasses must implement") + } + + final func dismissFlow() { + flowPresenter.dismissFlow(initialViewController: initialViewController) + completionHandler?() + } +} + +extension UIViewController: NavigationFlowPresenter { + var currentlyDisplayedViewController: UIViewController? { + if let nav = self as? UINavigationController { + return nav.topViewController + } else { + return self.presentedViewController + } + } + + func show( + _ viewController: UIViewController, + preferredDetents: [UISheetPresentationController.Detent] + ) { + if let nav = self as? UINavigationController { + nav.pushViewController(viewController, animated: true) + } else { + if presentedViewController != nil { + dismiss(animated: true) + } + viewController.modalPresentationStyle = .pageSheet + if let sheet = viewController.sheetPresentationController { + sheet.detents = preferredDetents + } + present(viewController, animated: true, completion: nil) + } + } + + func dismissFlow(initialViewController: UIViewController?) { + if let nav = self as? UINavigationController { + if let initialViewController = initialViewController { + nav.popToViewController(initialViewController, animated: true) + } else { + nav.popToRootViewController(animated: true) + } + } else { + dismiss(animated: true) + } + } + + func showAlert(_ alert: UIAlertController) { + if let nav = self as? UINavigationController { + nav.topViewController?.present(alert, animated: true) + } else { + dismiss(animated: true) + present(alert, animated: true) + } + } +} diff --git a/Mastodon/Scene/Donation/NewDonationNavigationFlow.swift b/Mastodon/Scene/Donation/NewDonationNavigationFlow.swift new file mode 100644 index 000000000..59cb7ded2 --- /dev/null +++ b/Mastodon/Scene/Donation/NewDonationNavigationFlow.swift @@ -0,0 +1,122 @@ +// Copyright © 2024 Mastodon gGmbH. All rights reserved. + +import Foundation +import MastodonCore +import MastodonLocalization +import MastodonSDK +import UIKit + +@MainActor +class NewDonationNavigationFlow: NavigationFlow { + + private let campaign: DonationCampaignViewModel + private let appContext: AppContext + private let authContext: AuthContext + private let sceneCoordinator: SceneCoordinator + + init( + flowPresenter: NavigationFlowPresenter, + campaign: DonationCampaignViewModel, appContext: AppContext, + authContext: AuthContext, sceneCoordinator: SceneCoordinator + ) { + self.campaign = campaign + self.appContext = appContext + self.authContext = authContext + self.sceneCoordinator = sceneCoordinator + super.init(flowPresenter: flowPresenter) + } + + override func startFlow() { + showDonationOptionsController() + } + + private func showDonationOptionsController() { + let optionsController = DonationViewController(campaign: campaign) { + [weak self] attemptedDonation in + guard let s = self else { return } + if let attemptedDonation { + s.showDonationPaymentWebview( + attemptedDonation, campaign: s.campaign) + } else { + s.dismissFlow() + } + } + + if flowPresenter is UINavigationController { + flowPresenter.show(optionsController, preferredDetents: [.medium()]) + } else { + let navController = UINavigationController(rootViewController: optionsController) + flowPresenter.show(navController, preferredDetents: [.medium()]) + } + } + + private func showDonationPaymentWebview( + _ paymentURL: URL, campaign: DonationCampaignViewModel + ) { + let model = WebViewModel(url: paymentURL) + let viewController = NotifyingWebViewController(model) + + Task { [weak self] in + for await url in viewController.navigationEvents.dropFirst(1) { + self?.handleDonationCompletion(url, campaign: campaign) + break + } + } + flowPresenter.show(viewController, preferredDetents: [.large()]) + } + + private func handleDonationCompletion( + _ response: URL, campaign: DonationCampaignViewModel + ) { + let result: DonationResult + let responseString = response.lastPathComponent + switch responseString { + case "success": + result = .successful(suggestedPost: campaign.donationSuccessPost) + showDonationCompletionMessage(result) + Mastodon.Entity.DonationCampaign.didContribute(campaign.id) + case "failure": + let alert = UIAlertController( + title: L10n.Scene.Donation.Success.serverErrorTitle, + message: L10n.Scene.Donation.Success.serverErrorMessage, + preferredStyle: .actionSheet) + flowPresenter.showAlert(alert) + result = .failed + case "cancel": + result = .canceled + dismissFlow() + break + default: + return + } + } + + private func showDonationCompletionMessage(_ result: DonationResult) { + let viewController = DonationCompletionViewController(result) { + [weak self] completionEvent in + switch completionEvent { + case .makePost(let suggestedPost): + self?.composeDonationSuccessPost(suggestedPost) + case .close: + self?.dismissFlow() + } + } + flowPresenter.show(viewController, preferredDetents: [.large()]) + } + + private func composeDonationSuccessPost(_ suggestedText: String) { + let composeViewModel = ComposeViewModel( + context: appContext, + authContext: authContext, + composeContext: .composeStatus, + destination: .topLevel, + initialContent: suggestedText + ) + sceneCoordinator.present( + scene: .compose(viewModel: composeViewModel), + from: nil, + transition: .modal(animated: true, completion: nil) + ) + } + +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index cfe79afa4..fa2ae8f7d 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -29,6 +29,8 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media let mediaPreviewTransitionController = MediaPreviewTransitionController() + var navigationFlow: NavigationFlow? + enum EmptyViewUseCase { case timeline, list } @@ -102,7 +104,12 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency, Media var timelinePillCenterXAnchor: NSLayoutConstraint? var timelinePillVisibleTopAnchor: NSLayoutConstraint? var timelinePillHiddenTopAnchor: NSLayoutConstraint? - + + /// Donations + let donationBanner = DonationBanner() + var donationBannerCenterXAnchor: NSLayoutConstraint? + var donationBannerVisibleBottomAnchor: NSLayoutConstraint? + var donationBannerHiddenBottomAnchor: NSLayoutConstraint? private func generateTimelineSelectorMenu() -> UIMenu { let showFollowingAction = UIAction(title: L10n.Scene.HomeTimeline.TimelineMenu.following, image: .init(systemName: "house")) { [weak self] _ in @@ -440,6 +447,41 @@ extension HomeTimelineViewController { } } .store(in: &disposeBag) + + view.addSubview(donationBanner) + donationBanner.alpha = 0 + donationBanner.translatesAutoresizingMaskIntoConstraints = false + donationBanner.onClose = { [weak self] campaignID in + self?.hideDonationCampaignBanner() + if let campaignID { + Mastodon.Entity.DonationCampaign.didDismiss(campaignID) + } + } + donationBanner.onShowDonationDialog = { [weak self] campaign in + self?.showDonationCampaign(campaign) + } + + let donationBannerCenterXAnchor = donationBanner.centerXAnchor.constraint(equalTo: view.centerXAnchor) + let donationBannerVisibleBottomAnchor = donationBanner.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + let donationBannerHiddenBottomAnchor = view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: donationBanner.topAnchor) + + NSLayoutConstraint.activate([ + donationBannerHiddenBottomAnchor, + donationBannerCenterXAnchor, + donationBanner.leadingAnchor.constraint(equalTo: view.leadingAnchor), + donationBanner.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + self.donationBannerCenterXAnchor = donationBannerCenterXAnchor + self.donationBannerVisibleBottomAnchor = donationBannerVisibleBottomAnchor + self.donationBannerHiddenBottomAnchor = donationBannerHiddenBottomAnchor + + viewModel?.onPresentDonationCampaign + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] campaign in + self?.showDonationCampaignBanner(campaign) + }) + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { @@ -450,6 +492,8 @@ extension HomeTimelineViewController { // needs trigger manually after onboarding dismiss setNeedsStatusBarAppearanceUpdate() + + viewModel?.askForDonationIfPossible() } override func viewDidAppear(_ animated: Bool) { @@ -661,7 +705,41 @@ extension HomeTimelineViewController { self?.view.layoutIfNeeded() }) } + + private func showDonationCampaignBanner(_ campaign: Mastodon.Entity.DonationCampaign) { + guard let donationBannerHiddenBottomAnchor, let donationBannerVisibleBottomAnchor else { return } + donationBanner.update(campaign: campaign) + donationBanner.setNeedsLayout() + donationBanner.layoutIfNeeded() + NSLayoutConstraint.deactivate([donationBannerHiddenBottomAnchor]) + NSLayoutConstraint.activate([donationBannerVisibleBottomAnchor]) + + UIView.animate(withDuration: 0.5, delay: 0.0, usingSpringWithDamping: 0.75, initialSpringVelocity: 0.9) { [weak self] in + self?.donationBanner.alpha = 1 + self?.view.layoutIfNeeded() + } + } + + private func hideDonationCampaignBanner() { + guard let donationBannerHiddenBottomAnchor, let donationBannerVisibleBottomAnchor else { return } + + NSLayoutConstraint.deactivate([donationBannerVisibleBottomAnchor]) + NSLayoutConstraint.activate([donationBannerHiddenBottomAnchor]) + donationBanner.alpha = 1 + UIView.animate(withDuration: 0.5, animations: { [weak self] in + self?.donationBanner.alpha = 0 + self?.view.layoutIfNeeded() + }) + } + + private func showDonationCampaign(_ campaign: Mastodon.Entity.DonationCampaign) { + hideDonationCampaignBanner() + navigationFlow = NewDonationNavigationFlow(flowPresenter: self, campaign: campaign, appContext: context, authContext: authContext, sceneCoordinator: coordinator) + navigationFlow?.presentFlow { [weak self] in + self?.navigationFlow = nil + } + } } // MARK: - UIScrollViewDelegate extension HomeTimelineViewController { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Donation.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Donation.swift new file mode 100644 index 000000000..8cef7ae45 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Donation.swift @@ -0,0 +1,35 @@ +// Copyright © 2024 Mastodon gGmbH. All rights reserved. + +import Combine +import Foundation +import MastodonSDK + +extension HomeTimelineViewModel { + + func askForDonationIfPossible() { + let userAuthentication = authContext.mastodonAuthenticationBox + .authentication + guard + Mastodon.Entity.DonationCampaign.isEligibleForDonationsBanner( + domain: userAuthentication.domain, + accountCreationDate: userAuthentication.accountCreatedAt) + else { return } + + Task { @MainActor [weak self] in + guard let self else { return } + + let seed = Mastodon.Entity.DonationCampaign.donationSeed( + username: userAuthentication.username, + domain: userAuthentication.domain) + + do { + let campaign = try await self.context.apiService + .getDonationCampaign(seed: seed, source: nil).value + guard !Mastodon.Entity.DonationCampaign.hasPreviouslyDismissed(campaign.id) && !Mastodon.Entity.DonationCampaign.hasPreviouslyContributed(campaign.id) else { return } + onPresentDonationCampaign.send(campaign) + } catch { + // no-op + } + } + } +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 297c43be0..733700683 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -37,6 +37,7 @@ final class HomeTimelineViewModel: NSObject { /// Becomes `true` if `networkErrorCount` is bigger than 5 let isOffline = CurrentValueSubject(false) var networkErrorCount = CurrentValueSubject(0) + var onPresentDonationCampaign = PassthroughSubject() var timelineContext: MastodonFeed.Kind.TimelineContext = .home { didSet { diff --git a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift index 9f4a4ff90..8feff7a1f 100644 --- a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift +++ b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift @@ -196,12 +196,13 @@ extension AuthenticationViewModel { let account = response.value let authentication = MastodonAuthentication.createFrom(domain: info.domain, - userID: account.id, - username: account.username, - appAccessToken: userToken.accessToken, // TODO: swap app token - userAccessToken: userToken.accessToken, - clientID: info.clientID, - clientSecret: info.clientSecret) + userID: account.id, + username: account.username, + appAccessToken: userToken.accessToken, // TODO: swap app token + userAccessToken: userToken.accessToken, + clientID: info.clientID, + clientSecret: info.clientSecret, + accountCreatedAt: account.createdAt) AuthenticationServiceProvider.shared .authentications diff --git a/Mastodon/Scene/Settings/Settings Overview/Settings.swift b/Mastodon/Scene/Settings/Settings Overview/Settings.swift index 6a2233612..1edd20f26 100644 --- a/Mastodon/Scene/Settings/Settings Overview/Settings.swift +++ b/Mastodon/Scene/Settings/Settings Overview/Settings.swift @@ -2,6 +2,7 @@ import UIKit import MastodonLocalization +import MastodonSDK struct SettingsSection: Hashable { let entries: [SettingsEntry] @@ -13,7 +14,11 @@ enum SettingsEntry: Hashable { case privacySafety case serverDetails(domain: String) case aboutMastodon + case makeDonation + case manageDonations case logout(accountName: String) + case toggleTestDonations + case clearPreviousDonationCampaigns var title: String { switch self { @@ -25,10 +30,18 @@ enum SettingsEntry: Hashable { return L10n.Scene.Settings.Overview.privacySafety case .serverDetails(_): return L10n.Scene.Settings.Overview.serverDetails + case .makeDonation: + return L10n.Scene.Settings.Overview.supportMastodon + case .manageDonations: + return L10n.Scene.Settings.Overview.manageDonations case .aboutMastodon: return L10n.Scene.Settings.Overview.aboutMastodon case .logout(let accountName): return L10n.Scene.Settings.Overview.logout(accountName) + case .toggleTestDonations: + return Mastodon.API.isTestingDonations ? "Donations use staging: ON" : "Donations use staging: OFF" + case .clearPreviousDonationCampaigns: + return "Clear Donation History" } } @@ -36,15 +49,19 @@ enum SettingsEntry: Hashable { switch self { case .serverDetails(domain: let domain): return domain - case .general, .notifications, .privacySafety, .aboutMastodon, .logout(_): + case .general, .notifications, .privacySafety, .makeDonation, .manageDonations, .aboutMastodon, .logout(_): + return nil + case .toggleTestDonations, .clearPreviousDonationCampaigns: return nil } } var accessoryType: UITableViewCell.AccessoryType { switch self { - case .general, .notifications, .privacySafety, .serverDetails(_), .aboutMastodon, .logout(_): + case .general, .notifications, .privacySafety, .serverDetails(_), .makeDonation, .manageDonations, .aboutMastodon, .logout(_): return .disclosureIndicator + case .toggleTestDonations, .clearPreviousDonationCampaigns: + return .none } } @@ -58,10 +75,16 @@ enum SettingsEntry: Hashable { return UIImage(systemName: "lock.fill") case .serverDetails(_): return UIImage(systemName: "server.rack") + case .makeDonation: + return UIImage(systemName: "heart.fill") + case .manageDonations: + return UIImage(systemName: "gear") case .aboutMastodon: return UIImage(systemName: "info.circle.fill") case .logout(_): return nil + case .toggleTestDonations, .clearPreviousDonationCampaigns: + return nil } } @@ -75,20 +98,26 @@ enum SettingsEntry: Hashable { return .systemBlue case .serverDetails(_): return .systemTeal + case .makeDonation, .manageDonations: + return .systemPurple case .aboutMastodon: return .systemPurple case .logout(_): return nil + case .toggleTestDonations, .clearPreviousDonationCampaigns: + return nil } } var textColor: UIColor { switch self { - case .general, .notifications, .privacySafety, .aboutMastodon, .serverDetails(_): + case .general, .notifications, .privacySafety, .makeDonation, .manageDonations, .aboutMastodon, .serverDetails(_): return .label case .logout(_): return .red + case .toggleTestDonations, .clearPreviousDonationCampaigns: + return .systemIndigo } } diff --git a/Mastodon/Scene/Settings/Settings Overview/SettingsViewController.swift b/Mastodon/Scene/Settings/Settings Overview/SettingsViewController.swift index 74c57be8b..418343a36 100644 --- a/Mastodon/Scene/Settings/Settings Overview/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/Settings Overview/SettingsViewController.swift @@ -1,6 +1,7 @@ // Copyright © 2023 Mastodon gGmbH. All rights reserved. import UIKit +import MastodonSDK import MastodonLocalization protocol SettingsViewControllerDelegate: AnyObject { @@ -11,6 +12,7 @@ protocol SettingsViewControllerDelegate: AnyObject { class SettingsViewController: UIViewController { let sections: [SettingsSection] + var donationCampaign: Mastodon.Entity.DonationCampaign? weak var delegate: SettingsViewControllerDelegate? var tableViewDataSource: UITableViewDiffableDataSource? @@ -18,11 +20,19 @@ class SettingsViewController: UIViewController { init(accountName: String, domain: String) { - sections = [ + var baseSections: [SettingsSection] = [ .init(entries: [.general, .notifications, .privacySafety]), .init(entries: [.serverDetails(domain: domain), .aboutMastodon]), .init(entries: [.logout(accountName: accountName)]) ] + + if Mastodon.Entity.DonationCampaign.isEligibleForDonationsSettingsSection(domain: domain) { + baseSections.insert(.init(entries: [.makeDonation, .manageDonations]), at: baseSections.count - 1) + } + if isDebugOrTestflightOrSimulator { + baseSections.append(.init(entries: [.toggleTestDonations, .clearPreviousDonationCampaigns])) + } + sections = baseSections tableView = UITableView(frame: .zero, style: .insetGrouped) tableView.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mastodon/Scene/Settings/SettingsCoordinator.swift b/Mastodon/Scene/Settings/SettingsCoordinator.swift index 7c98cdbbf..bf9ee642f 100644 --- a/Mastodon/Scene/Settings/SettingsCoordinator.swift +++ b/Mastodon/Scene/Settings/SettingsCoordinator.swift @@ -20,6 +20,7 @@ class SettingsCoordinator: NSObject, Coordinator { let navigationController: UINavigationController let presentedOn: UIViewController + var navigationFlow: NavigationFlow? weak var delegate: SettingsCoordinatorDelegate? private let settingsViewController: SettingsViewController @@ -39,6 +40,24 @@ class SettingsCoordinator: NSObject, Coordinator { self.sceneCoordinator = sceneCoordinator settingsViewController = SettingsViewController(accountName: accountName, domain: authContext.mastodonAuthenticationBox.domain) + + super.init() + + Task { [weak self] in + guard let s = self else { return } + let userAuthentication = s.authContext.mastodonAuthenticationBox.authentication + let seed = Mastodon.Entity.DonationCampaign.donationSeed(username: userAuthentication.username, domain: userAuthentication.domain) + do { + let campaign = try await s.appContext.apiService.getDonationCampaign(seed: seed, source: nil).value + + await MainActor.run { + s.settingsViewController.donationCampaign = campaign + + } + } catch { + // TODO: it would be nice to hide the Make Donation row if there was nothing to configure the donation screen with + } + } } func start() { @@ -100,6 +119,23 @@ extension SettingsCoordinator: SettingsViewControllerDelegate { navigationController.pushViewController(serverDetailsViewController, animated: true) + + case .makeDonation: + Task { + await MainActor.run { [weak self] in + guard let s = self, let donationCampaign = s.settingsViewController.donationCampaign else { return } + + let donationFlow = NewDonationNavigationFlow(flowPresenter: viewController, campaign: donationCampaign, appContext: s.appContext, authContext: s.authContext, sceneCoordinator: s.sceneCoordinator) + s.navigationFlow = donationFlow + donationFlow.presentFlow { [weak self] in + self?.navigationFlow = nil + } + } + } + case .manageDonations: + guard let url = URL(string: "https://sponsor.joinmastodon.org/donate/manage") else { return } + let webViewController = WebViewController(WebViewModel(url: url)) + navigationController.pushViewController(webViewController, animated: true) case .aboutMastodon: let aboutViewController = AboutViewController() aboutViewController.delegate = self @@ -107,6 +143,11 @@ extension SettingsCoordinator: SettingsViewControllerDelegate { navigationController.pushViewController(aboutViewController, animated: true) case .logout(_): delegate?.logout(self) + case .toggleTestDonations: + Mastodon.API.toggleTestingDonations() + settingsViewController.tableView.reloadData() + case .clearPreviousDonationCampaigns: + Mastodon.Entity.DonationCampaign.forgetPreviousCampaigns() } } } diff --git a/Mastodon/Scene/Share/Webview/WebViewController.swift b/Mastodon/Scene/Share/Webview/WebViewController.swift index b4f45c436..aee40e1bb 100644 --- a/Mastodon/Scene/Share/Webview/WebViewController.swift +++ b/Mastodon/Scene/Share/Webview/WebViewController.swift @@ -11,13 +11,10 @@ import UIKit import WebKit import MastodonCore -final class WebViewController: UIViewController, NeedsDependency { - - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } - weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } +class WebViewController: UIViewController { var disposeBag = Set() - var viewModel: WebViewModel! + var viewModel: WebViewModel let webView: WKWebView = { let configuration = WKWebViewConfiguration() @@ -26,6 +23,15 @@ final class WebViewController: UIViewController, NeedsDependency { return webView }() + required init(_ viewModel: WebViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + deinit { // cleanup cookie let httpCookieStore = webView.configuration.websiteDataStore.httpCookieStore @@ -58,3 +64,29 @@ extension WebViewController { dismiss(animated: true, completion: nil) } } + +class NotifyingWebViewController: WebViewController, WKNavigationDelegate { + + let navigationEvents: AsyncStream + let navigationEventsContinuation: AsyncStream.Continuation + + @MainActor required init(_ viewModel: WebViewModel) { + (navigationEvents, navigationEventsContinuation) = AsyncStream.makeStream() + super.init(viewModel) + webView.navigationDelegate = self + } + + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation: WKNavigation!) { + if let url = webView.url { + navigationEventsContinuation.yield(url) + } + } + + func dealloc() { + navigationEventsContinuation.finish() + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/donationThankYou.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/donationThankYou.imageset/Contents.json new file mode 100644 index 000000000..3f25ac6f8 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/donationThankYou.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "donation_successful_art.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/donationThankYou.imageset/donation_successful_art.png b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/donationThankYou.imageset/donation_successful_art.png new file mode 100644 index 000000000..ad10f9db7 Binary files /dev/null and b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/donationThankYou.imageset/donation_successful_art.png differ diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/scribble.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/scribble.imageset/Contents.json new file mode 100644 index 000000000..9cc2ef47a --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/scribble.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "scribble.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/scribble.imageset/scribble.svg b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/scribble.imageset/scribble.svg new file mode 100644 index 000000000..f96be0ea6 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/scribble.imageset/scribble.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Primary/300.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Primary/300.colorset/Contents.json similarity index 80% rename from MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Primary/300.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Primary/300.colorset/Contents.json index e1a0ce662..a3ace74e9 100644 --- a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Primary/300.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Primary/300.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xFF", - "green" : "0xC2", - "red" : "0xC2" + "blue" : "0xDD", + "green" : "0x00", + "red" : "0x40" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "1.000", - "green" : "0.761", - "red" : "0.761" + "blue" : "0xFF", + "green" : "0xBF", + "red" : "0xC7" } }, "idiom" : "universal" diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Primary/700.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Primary/700.colorset/Contents.json similarity index 88% rename from MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Primary/700.colorset/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Primary/700.colorset/Contents.json index b9b9d31dd..64cbf1937 100644 --- a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Primary/700.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Primary/700.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.733", - "green" : "0.110", - "red" : "0.263" + "blue" : "0xBA", + "green" : "0x1C", + "red" : "0x43" } }, "idiom" : "universal" diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Primary/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Primary/Contents.json similarity index 100% rename from MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Primary/Contents.json rename to MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Primary/Contents.json diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Secondary/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Secondary/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Secondary/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Secondary/container.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Secondary/container.colorset/Contents.json new file mode 100644 index 000000000..38a27ca8c --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Secondary/container.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xA5", + "red" : "0xB0" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x8E", + "green" : "0x30", + "red" : "0x3D" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Secondary/on.container.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Secondary/on.container.colorset/Contents.json new file mode 100644 index 000000000..71f0f0cf6 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/Secondary/on.container.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xCF", + "red" : "0xD6" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/outline.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/outline.colorset/Contents.json new file mode 100644 index 000000000..8943457a6 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/outline.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x88", + "green" : "0x75", + "red" : "0x78" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xA3", + "green" : "0x8E", + "red" : "0x92" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/outline.variant.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/outline.variant.colorset/Contents.json new file mode 100644 index 000000000..ee16898a9 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/outline.variant.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xDA", + "green" : "0xC4", + "red" : "0xC9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x57", + "green" : "0x45", + "red" : "0x47" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/surface.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/surface.colorset/Contents.json new file mode 100644 index 000000000..c94c86ddd --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/surface.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xF8", + "red" : "0xFC" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x1D", + "green" : "0x12", + "red" : "0x13" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/surface.container.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/surface.container.colorset/Contents.json new file mode 100644 index 000000000..b4bb51b6a --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Donations/surface.container.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFB", + "green" : "0xEB", + "red" : "0xF1" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x29", + "green" : "0x1E", + "red" : "0x20" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/goldenrod.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/goldenrod.colorset/Contents.json new file mode 100644 index 000000000..22f0847a4 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/goldenrod.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0xB9", + "red" : "0xF9" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift index 860a46c6f..e5713715c 100644 --- a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift +++ b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift @@ -28,9 +28,11 @@ public enum Asset { public static let squareAndArrowUp = ImageAsset(name: "Arrow/square.and.arrow.up") } public enum Asset { + public static let donationThankYou = ImageAsset(name: "Asset/donationThankYou") public static let email = ImageAsset(name: "Asset/email") public static let friends = ImageAsset(name: "Asset/friends") public static let mastodonTextLogo = ImageAsset(name: "Asset/mastodon.text.logo") + public static let scribble = ImageAsset(name: "Asset/scribble") } public enum Circles { public static let forbidden20 = ImageAsset(name: "Circles/forbidden.20") @@ -60,6 +62,18 @@ public enum Asset { public static let userFollowing = ColorAsset(name: "Colors/Button/userFollowing") public static let userFollowingTitle = ColorAsset(name: "Colors/Button/userFollowingTitle") } + public enum Primary { + public static let _300 = ColorAsset(name: "Colors/Primary/300") + public static let _700 = ColorAsset(name: "Colors/Primary/700") + } + public enum Secondary { + public static let container = ColorAsset(name: "Colors/Secondary/container") + public static let onContainer = ColorAsset(name: "Colors/Secondary/on.container") + } + public static let outline = ColorAsset(name: "Colors/outline") + public static let outlineVariant = ColorAsset(name: "Colors/outline.variant") + public static let surface = ColorAsset(name: "Colors/surface") + public static let surfaceContainer = ColorAsset(name: "Colors/surface.container") public enum Icon { public static let plus = ColorAsset(name: "Colors/Icon/plus") } @@ -77,10 +91,6 @@ public enum Asset { public enum Poll { public static let disabled = ColorAsset(name: "Colors/Poll/disabled") } - public enum Primary { - public static let _300 = ColorAsset(name: "Colors/Primary/300") - public static let _700 = ColorAsset(name: "Colors/Primary/700") - } public enum Shadow { public static let searchCard = ColorAsset(name: "Colors/Shadow/SearchCard") } @@ -102,6 +112,7 @@ public enum Asset { public static let brandBlueDarken20 = ColorAsset(name: "Colors/deprecated/brand.blue.darken.20") } public static let disabled = ColorAsset(name: "Colors/disabled") + public static let goldenrod = ColorAsset(name: "Colors/goldenrod") public static let inactive = ColorAsset(name: "Colors/inactive") public static let mediaTypeIndicotor = ColorAsset(name: "Colors/media.type.indicotor") public static let selectionHighlight = ColorAsset(name: "Colors/selection.highlight") diff --git a/MastodonSDK/Sources/MastodonCore/AuthenticationServiceProvider.swift b/MastodonSDK/Sources/MastodonCore/AuthenticationServiceProvider.swift index 6c51e557e..8e2068973 100644 --- a/MastodonSDK/Sources/MastodonCore/AuthenticationServiceProvider.swift +++ b/MastodonSDK/Sources/MastodonCore/AuthenticationServiceProvider.swift @@ -116,7 +116,8 @@ public extension AuthenticationServiceProvider { createdAt: auth.createdAt, updatedAt: auth.updatedAt, activedAt: auth.activedAt, - userID: auth.userID + userID: auth.userID, + accountCreatedAt: auth.createdAt ) } diff --git a/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift b/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift index d29995f54..bb3fac5f9 100644 --- a/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift +++ b/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift @@ -5,6 +5,7 @@ import CoreDataStack import MastodonSDK public struct MastodonAuthentication: Codable, Hashable, UserIdentifier { + public static let fallbackCharactersReservedPerURL = 23 public enum InstanceConfiguration: Codable, Hashable { @@ -69,6 +70,7 @@ public struct MastodonAuthentication: Codable, Hashable, UserIdentifier { public private(set) var userID: String public private(set) var instanceConfiguration: InstanceConfiguration? + public private(set) var accountCreatedAt: Date public var persistenceIdentifier: String { "\(username)@\(domain)" @@ -81,7 +83,8 @@ public struct MastodonAuthentication: Codable, Hashable, UserIdentifier { appAccessToken: String, userAccessToken: String, clientID: String, - clientSecret: String + clientSecret: String, + accountCreatedAt: Date ) -> Self { let now = Date() return MastodonAuthentication( @@ -96,7 +99,8 @@ public struct MastodonAuthentication: Codable, Hashable, UserIdentifier { updatedAt: now, activedAt: now, userID: userID, - instanceConfiguration: nil + instanceConfiguration: nil, + accountCreatedAt: accountCreatedAt ) } @@ -126,7 +130,8 @@ public struct MastodonAuthentication: Codable, Hashable, UserIdentifier { updatedAt: updatedAt ?? self.updatedAt, activedAt: activedAt ?? self.activedAt, userID: userID ?? self.userID, - instanceConfiguration: instanceConfiguration ?? self.instanceConfiguration + instanceConfiguration: instanceConfiguration ?? self.instanceConfiguration, + accountCreatedAt: self.accountCreatedAt ) } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Donation.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Donation.swift new file mode 100644 index 000000000..51361ce4b --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Donation.swift @@ -0,0 +1,23 @@ +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK + +extension APIService { + + public func getDonationCampaign( + seed: Int, + source: String? + ) async throws + -> Mastodon.Response.Content + { + let campaign = try await Mastodon.API.getDonationCampaign( + session: session, query: .init(seed: seed, source: source)) + guard campaign.value.isValid else { + throw Mastodon.Entity.DonationError.campaignInvalid + } + return campaign + } + +} diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index cea1404b1..fd7e16ff7 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -808,6 +808,36 @@ public enum L10n { public static let posts = L10n.tr("Localizable", "Scene.Discovery.Tabs.Posts", fallback: "Posts") } } + public enum Donation { + /// Currency + public static let currency = L10n.tr("Localizable", "Scene.Donation.Currency", fallback: "Currency") + /// Donate + public static let donateButtonTitle = L10n.tr("Localizable", "Scene.Donation.DonateButtonTitle", fallback: "Donate") + /// Donate to Mastodon + public static let title = L10n.tr("Localizable", "Scene.Donation.Title", fallback: "Donate to Mastodon") + public enum Picker { + /// Monthly + public static let monthlyTitle = L10n.tr("Localizable", "Scene.Donation.Picker.MonthlyTitle", fallback: "Monthly") + /// Just once + public static let onceTitle = L10n.tr("Localizable", "Scene.Donation.Picker.OnceTitle", fallback: "Just once") + /// Yearly + public static let yearlyTitle = L10n.tr("Localizable", "Scene.Donation.Picker.YearlyTitle", fallback: "Yearly") + } + public enum Success { + /// We are sorry, an error occurred and we have not been able to process your donation. + /// + /// Please retry in a few minutes. + public static let serverErrorMessage = L10n.tr("Localizable", "Scene.Donation.Success.ServerErrorMessage", fallback: "We are sorry, an error occurred and we have not been able to process your donation.\n\nPlease retry in a few minutes.") + /// Payment failed + public static let serverErrorTitle = L10n.tr("Localizable", "Scene.Donation.Success.ServerErrorTitle", fallback: "Payment failed") + /// Spread the word + public static let shareButtonTitle = L10n.tr("Localizable", "Scene.Donation.Success.ShareButtonTitle", fallback: "Spread the word") + /// You should receive an email confirming your donation soon. + public static let subtitle = L10n.tr("Localizable", "Scene.Donation.Success.Subtitle", fallback: "You should receive an email confirming your donation soon.") + /// Thank you for your contribution! + public static let title = L10n.tr("Localizable", "Scene.Donation.Success.Title", fallback: "Thank you for your contribution!") + } + } public enum Familiarfollowers { /// Followed by %@ public static func followedByNames(_ p1: Any) -> String { @@ -1569,6 +1599,12 @@ public enum L10n { /// About public static let title = L10n.tr("Localizable", "Scene.Settings.AboutMastodon.Title", fallback: "About") } + public enum Donation { + /// Manage donations + public static let manage = L10n.tr("Localizable", "Scene.Settings.Donation.Manage", fallback: "Manage donations") + /// Donate to Mastodon + public static let title = L10n.tr("Localizable", "Scene.Settings.Donation.Title", fallback: "Donate to Mastodon") + } public enum General { /// General public static let title = L10n.tr("Localizable", "Scene.Settings.General.Title", fallback: "General") @@ -1656,14 +1692,16 @@ public enum L10n { public static func logout(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Settings.Overview.Logout", String(describing: p1), fallback: "Logout %@") } + /// Manage donations + public static let manageDonations = L10n.tr("Localizable", "Scene.Settings.Overview.ManageDonations", fallback: "Manage donations") /// Notifications public static let notifications = L10n.tr("Localizable", "Scene.Settings.Overview.Notifications", fallback: "Notifications") /// Privacy & Safety public static let privacySafety = L10n.tr("Localizable", "Scene.Settings.Overview.PrivacySafety", fallback: "Privacy & Safety") /// Server Details public static let serverDetails = L10n.tr("Localizable", "Scene.Settings.Overview.ServerDetails", fallback: "Server Details") - /// Support Mastodon - public static let supportMastodon = L10n.tr("Localizable", "Scene.Settings.Overview.SupportMastodon", fallback: "Support Mastodon") + /// Donate to Mastodon + public static let supportMastodon = L10n.tr("Localizable", "Scene.Settings.Overview.SupportMastodon", fallback: "Donate to Mastodon") /// Settings public static let title = L10n.tr("Localizable", "Scene.Settings.Overview.Title", fallback: "Settings") } diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index c8369ecf6..fc49cc2e6 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -299,6 +299,17 @@ uploaded to Mastodon."; "Scene.FollowedTags.Header.Posts" = "posts"; "Scene.FollowedTags.Header.PostsToday" = "posts today"; "Scene.FollowedTags.Title" = "Followed Tags"; +"Scene.Donation.Title" = "Donate to Mastodon"; +"Scene.Donation.Picker.OnceTitle" = "Just once"; +"Scene.Donation.Picker.MonthlyTitle" = "Monthly"; +"Scene.Donation.Picker.YearlyTitle" = "Yearly"; +"Scene.Donation.DonateButtonTitle" = "Donate"; +"Scene.Donation.Currency" = "Currency"; +"Scene.Donation.Success.Title" = "Thank you for your contribution!"; +"Scene.Donation.Success.Subtitle" = "You should receive an email confirming your donation soon."; +"Scene.Donation.Success.ServerErrorTitle" = "Payment failed"; +"Scene.Donation.Success.ServerErrorMessage" = "We are sorry, an error occurred and we have not been able to process your donation.\n\nPlease retry in a few minutes."; +"Scene.Donation.Success.ShareButtonTitle" = "Spread the word"; "Scene.Follower.Footer" = "Followers from other servers are not displayed."; "Scene.Follower.Title" = "follower"; "Scene.Following.Footer" = "Follows from other servers are not displayed."; @@ -579,7 +590,8 @@ If you disagree with the policy for **%@**, you can go back and pick a different "Scene.Settings.Overview.Notifications" = "Notifications"; "Scene.Settings.Overview.PrivacySafety" = "Privacy & Safety"; "Scene.Settings.Overview.ServerDetails" = "Server Details"; -"Scene.Settings.Overview.SupportMastodon" = "Support Mastodon"; +"Scene.Settings.Overview.ManageDonations" = "Manage donations"; +"Scene.Settings.Overview.SupportMastodon" = "Donate to Mastodon"; "Scene.Settings.Overview.Title" = "Settings"; "Scene.Settings.PrivacySafety.AppearInSearchEngines" = "Appear in Search Engines"; "Scene.Settings.PrivacySafety.DefaultPostVisibility.FollowersOnly" = "Followers Only"; @@ -599,6 +611,8 @@ If you disagree with the policy for **%@**, you can go back and pick a different "Scene.Settings.ServerDetails.AboutInstance.MessageAdmin" = "Message Admin"; "Scene.Settings.ServerDetails.AboutInstance.Title" = "Administrator"; "Scene.Settings.ServerDetails.Rules" = "Rules"; +"Scene.Settings.Donation.Title" = "Donate to Mastodon"; +"Scene.Settings.Donation.Manage" = "Manage donations"; "Scene.SuggestionAccount.FollowAll" = "Follow all"; "Scene.SuggestionAccount.Title" = "Popular on Mastodon"; "Scene.Thread.BackTitle" = "Post"; @@ -634,4 +648,4 @@ If you disagree with the policy for **%@**, you can go back and pick a different "Widget.MultipleFollowers.ConfigurationDescription" = "Show number of followers for multiple accounts."; "Widget.MultipleFollowers.ConfigurationDisplayName" = "Multiple followers"; "Widget.MultipleFollowers.MockUser.AccountName" = "another@follower.social"; -"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower"; \ No newline at end of file +"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower"; diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Donation.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Donation.swift new file mode 100644 index 000000000..d41e0955d --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Donation.swift @@ -0,0 +1,88 @@ +// Copyright © 2024 Mastodon gGmbH. All rights reserved. + +import Foundation + +public var isDebugOrTestflightOrSimulator: Bool { + #if DEBUG + return true + #else + guard let path = Bundle.main.appStoreReceiptURL?.path else { + return false + } + return path.contains("CoreSimulator") || path.contains("sandboxReceipt") + #endif +} + +extension Mastodon.API { + public static var isTestingDonations: Bool { + return isDebugOrTestflightOrSimulator && useStaging + } + public static func toggleTestingDonations() { + useStaging = !useStaging + } + private static let stagingKey = "use_staging_for_donations_testing" + private static var useStaging: Bool { + get { + if UserDefaults.standard.value(forKey: stagingKey) != nil { + return UserDefaults.standard.bool(forKey: stagingKey) + } else { + return true + } + } + set { + UserDefaults.standard.set(newValue, forKey: stagingKey) + } + } + + public static var donationsEndpoint: URL { + URL( + string: "https://api.joinmastodon.org/v1/donations/campaigns/active" + )! + } + + public struct GetDonationCampaignsQuery: GetQuery { + let seed: Int + let source: String? + + public init(seed: Int, source: String?) { + self.seed = seed + self.source = source + } + + var queryItems: [URLQueryItem]? { + let locale = Locale.current.identifier + var queryItems = [ + URLQueryItem(name: "platform", value: "ios"), + URLQueryItem(name: "locale", value: locale), + URLQueryItem(name: "seed", value: "\(seed)"), + ] + if isTestingDonations { + queryItems.append( + URLQueryItem(name: "environment", value: "staging")) + } + + if let source, !source.isEmpty { + queryItems.append(URLQueryItem(name: "source", value: source)) + } + + return queryItems + } + } + + public static func getDonationCampaign( + session: URLSession, + query: GetDonationCampaignsQuery + ) async throws + -> Mastodon.Response.Content + { + let url = donationsEndpoint + + let request = Mastodon.API.get(url: url, query: query) + let (data, response) = try await session.data(for: request) + + let value = try Mastodon.API.decode( + type: Mastodon.Entity.DonationCampaign.self, from: data, + response: response) + return Mastodon.Response.Content(value: value, response: response) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+DonationCampaign.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+DonationCampaign.swift new file mode 100644 index 000000000..18cebacae --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+DonationCampaign.swift @@ -0,0 +1,150 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation + +private let maxCampaignsToRemember = 25 +private let dismissedCampaignsKey = "dismissed_donation_campaigns" +private let contributedCampaignsKey = "contributed_donation_campaigns" + +extension Mastodon.Entity { + + public enum DonationError: Swift.Error { + case campaignInvalid + } + + public struct DonationCampaign: Codable { + + public enum DonationSource { + case campaign(id: String) + case menu + + public var queryValue: String { + switch self { + case .campaign: + return "campaign" + case .menu: + return "menu" + } + } + } + private static let minDaysAccountAgeForDonations = 28 + static public func isEligibleForDonationsBanner( + domain: String, accountCreationDate: Date + ) -> Bool { + guard + let minDateForDonations = Calendar.current.date( + byAdding: .day, value: -minDaysAccountAgeForDonations, + to: Date()) + else { + return false + } + let becauseOnOfficialServer = + ["mastodon.social", "mastodon.online"].contains(domain) + && accountCreationDate < minDateForDonations + let becauseTesting = domain == "staging.mastodon.social" + return becauseOnOfficialServer || becauseTesting + } + + static public func isEligibleForDonationsSettingsSection(domain: String) + -> Bool + { + let becauseOnOfficialServer = [ + "mastodon.social", "mastodon.online", + ].contains(domain) + let becauseTesting = domain == "staging.mastodon.social" + return becauseOnOfficialServer || becauseTesting + } + + static public func donationSeed(username: String, domain: String) -> Int + { + return abs("@\(username)@\(domain)".hashValue) % 100 + } + + public enum DonationFrequency { + case oneTime, monthly, yearly + + public var queryValue: String { + switch self { + case .monthly: + return "monthly" + case .oneTime: + return "one_time" + case .yearly: + return "yearly" + } + } + } + + public struct Amounts: Codable { + public let oneTime: [String: [Int]]? + public let monthly: [String: [Int]]? + public let yearly: [String: [Int]]? + + enum CodingKeys: String, CodingKey { + case oneTime = "one_time" + case monthly + case yearly + } + } + + public let id: String + public let bannerMessage: String + public let bannerButtonText: String + public let donationMessage: String + public let donationButtonText: String + public let defaultCurrency: String + public let donationUrl: String + public let donationSuccessPost: String + public let amounts: Amounts + + public var isValid: Bool { + for options in [amounts.oneTime, amounts.monthly, amounts.yearly] { + if let options, !options.isEmpty { + return true + } + } + return false + } + + enum CodingKeys: String, CodingKey { + case id + case bannerMessage = "banner_message" + case bannerButtonText = "banner_button_text" + case donationMessage = "donation_message" + case donationButtonText = "donation_button_text" + case defaultCurrency = "default_currency" + case donationUrl = "donation_url" + case donationSuccessPost = "donation_success_post" + case amounts + } + + static public func hasPreviouslyDismissed(_ campaign: String) -> Bool { + let ids = UserDefaults.standard.array(forKey: dismissedCampaignsKey) as? [String] + return ids?.contains(campaign) ?? false + } + static public func hasPreviouslyContributed(_ campaign: String) -> Bool { + let ids = UserDefaults.standard.array(forKey: contributedCampaignsKey) as? [String] + return ids?.contains(campaign) ?? false + } + static public func didDismiss(_ campaign: String) { + var ids = UserDefaults.standard.array(forKey: dismissedCampaignsKey) as? [String] ?? [] + if ids.count == maxCampaignsToRemember { + ids.removeFirst() + } + ids.append(campaign) + UserDefaults.standard.setValue(ids, forKey: dismissedCampaignsKey) + } + static public func didContribute(_ campaign: String) { + var ids = UserDefaults.standard.array(forKey: contributedCampaignsKey) as? [String] ?? [] + if ids.count == maxCampaignsToRemember { + ids.removeFirst() + } + ids.append(campaign) + UserDefaults.standard.setValue(ids, forKey: contributedCampaignsKey) + } + static public func forgetPreviousCampaigns() { + UserDefaults.standard.removeObject(forKey: contributedCampaignsKey) + UserDefaults.standard.removeObject(forKey: dismissedCampaignsKey) + } + } +}