Add ability to donate to Mastodon from the app (#1353)

This change only affects users logged in to mastodon.social or mastodon.online. A banner may be periodically displayed at the bottom of the homescreen encouraging donations and menu options are now available in Settings to make new donations or manage existing ones.

Amounts will not necessarily be returned from the server in order. The first amount returned is taken as the default and the amounts are sorted before display.

---------

Co-authored-by: Marcus Kida <marcus.kida@bearologics.com>
This commit is contained in:
whattherestimefor 2024-11-06 19:37:52 -05:00 committed by GitHub
parent fdca50179e
commit 9d774cb541
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1862 additions and 40 deletions

View File

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

View File

@ -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 = "<group>"; };
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 = "<group>"; };
2A64A2932C92F71500E5E913 /* DonationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationViewController.swift; sourceTree = "<group>"; };
2A64A2952C92FD5700E5E913 /* DonationBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationBanner.swift; sourceTree = "<group>"; };
2A71F53D296DBDA80049F54A /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
2A71F53E296DBDA80049F54A /* Action.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = Action.js; sourceTree = "<group>"; };
2A71F53F296DBDA80049F54A /* ActionRequestHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionRequestHandler.swift; sourceTree = "<group>"; };
@ -1257,6 +1267,10 @@
DBFEF06726A58D07006D7ED1 /* ShareActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareActionExtension.entitlements; sourceTree = "<group>"; };
E9AABD3D26B64B8C00E237DA /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Intents.strings; sourceTree = "<group>"; };
E9AABD4026B64B8D00E237DA /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = "<group>"; };
FB7C4CC52CD2CAA800F6129A /* DonationCompletionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationCompletionViewController.swift; sourceTree = "<group>"; };
FB7C4CCB2CD55DEB00F6129A /* NavigationFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationFlow.swift; sourceTree = "<group>"; };
FB7C4CCD2CD55DFE00F6129A /* NewDonationNavigationFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewDonationNavigationFlow.swift; sourceTree = "<group>"; };
FBD689B42CCBF09F00CE29F3 /* DonationCampaignViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationCampaignViewModel.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -1436,6 +1450,19 @@
path = Language;
sourceTree = "<group>";
};
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 = "<group>";
};
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 */,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,6 +37,7 @@ final class HomeTimelineViewModel: NSObject {
/// Becomes `true` if `networkErrorCount` is bigger than 5
let isOffline = CurrentValueSubject<Bool, Never>(false)
var networkErrorCount = CurrentValueSubject<Int, Never>(0)
var onPresentDonationCampaign = PassthroughSubject<Mastodon.Entity.DonationCampaign, Never>()
var timelineContext: MastodonFeed.Kind.TimelineContext = .home {
didSet {

View File

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

View File

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

View File

@ -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<SettingsSection, SettingsEntry>?
@ -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

View File

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

View File

@ -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<AnyCancellable>()
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<URL>
let navigationEventsContinuation: AsyncStream<URL>.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()
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "donation_successful_art.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "scribble.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 165 KiB

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

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

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Mastodon.Entity.DonationCampaign>
{
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
}
}

View File

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

View File

@ -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";
"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower";

View File

@ -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<Mastodon.Entity.DonationCampaign>
{
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)
}
}

View File

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