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:
parent
fdca50179e
commit
9d774cb541
@ -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": {
|
||||
|
@ -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 */,
|
||||
|
@ -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)
|
||||
|
125
Mastodon/Scene/Donation/DonationBanner.swift
Normal file
125
Mastodon/Scene/Donation/DonationBanner.swift
Normal 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)
|
||||
}
|
||||
}
|
184
Mastodon/Scene/Donation/DonationCampaignViewModel.swift
Normal file
184
Mastodon/Scene/Donation/DonationCampaignViewModel.swift
Normal 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]
|
||||
}
|
||||
}
|
118
Mastodon/Scene/Donation/DonationCompletionViewController.swift
Normal file
118
Mastodon/Scene/Donation/DonationCompletionViewController.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
266
Mastodon/Scene/Donation/DonationViewController.swift
Normal file
266
Mastodon/Scene/Donation/DonationViewController.swift
Normal 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
|
||||
}
|
87
Mastodon/Scene/Donation/NavigationFlow.swift
Normal file
87
Mastodon/Scene/Donation/NavigationFlow.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
122
Mastodon/Scene/Donation/NewDonationNavigationFlow.swift
Normal file
122
Mastodon/Scene/Donation/NewDonationNavigationFlow.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
12
MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/donationThankYou.imageset/Contents.json
vendored
Normal file
12
MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/donationThankYou.imageset/Contents.json
vendored
Normal 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 |
12
MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/scribble.imageset/Contents.json
vendored
Normal file
12
MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/scribble.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "scribble.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
39
MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/scribble.imageset/scribble.svg
vendored
Normal file
39
MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Asset/scribble.imageset/scribble.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 165 KiB |
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -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"
|
@ -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"
|
@ -0,0 +1,9 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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")
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -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")
|
||||
}
|
||||
|
@ -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";
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user