Compare commits

...

15 Commits

Author SHA1 Message Date
Nathan Mattes b9c932f4b9
Merge be01db62b7 into ddb3211641 2024-04-26 15:19:57 +02:00
Marcus Kida ddb3211641
Fix array oob crash (IOS-257) (#1279)
Fix array oob crash
2024-04-25 16:14:08 +02:00
Nathan Mattes be01db62b7 Don't crash on iPad when logging out of all accounts (IOS-245) 2024-04-23 14:39:22 +02:00
Nathan Mattes 0ca3d773b5 Fix separator-insets (IOS-245) 2024-04-23 14:33:26 +02:00
Nathan Mattes edc2f71704 Add a little bit of margin (IOS-245) 2024-04-23 11:26:53 +02:00
Nathan Mattes 7c3805dc42 Localize (IOS-245) 2024-04-19 15:57:25 +02:00
Nathan Mattes c85b778c64 Add swipe-to-logout-action (IOS-245) 2024-04-19 12:20:45 +02:00
Nathan Mattes f3f82f994f UI-fixes (IOS-245) 2024-04-19 12:20:07 +02:00
Nathan Mattes b1e171eb8a Remove panModal (IOS-245) 2024-04-18 11:42:36 +02:00
Nathan Mattes d10dd37012 Don't animate account-switches (IOS-245) 2024-04-18 11:42:36 +02:00
Nathan Mattes cc4fc05997 Remove dead code (IOS-245) 2024-04-18 11:42:36 +02:00
Nathan Mattes 693e0ede54 Use iOS-formsheet to present account-list (IOS-245) 2024-04-18 11:42:36 +02:00
Nathan Mattes 7cf8752ff6 Logout of all accounts (IOS-245) 2024-04-18 11:42:36 +02:00
Nathan Mattes 949c22eb4e Show cell (IOS-245) 2024-04-18 11:42:36 +02:00
Nathan Mattes 381bbb6b7c Copynpaste cell to logout all accounts (IOS-245) 2024-04-18 11:42:36 +02:00
17 changed files with 375 additions and 442 deletions

View File

@ -17,7 +17,6 @@
- [Nuke-FLAnimatedImage-Plugin](https://github.com/kean/Nuke-FLAnimatedImage-Plugin)
- [Nuke](https://github.com/kean/Nuke)
- [Pageboy](https://github.com/uias/Pageboy#the-basics)
- [PanModal](https://github.com/slackhq/PanModal.git)
- [SDWebImage](https://github.com/SDWebImage/SDWebImage)
- [swift-collections](https://github.com/apple/swift-collections)
- [swift-nio](https://github.com/apple/swift-nio)

View File

@ -877,7 +877,9 @@
"account_list": {
"tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher",
"dismiss_account_switcher": "Dismiss Account Switcher",
"add_account": "Add Account"
"add_account": "Add Account",
"logout_all_accounts": "Log Out Of All Accounts",
"logout": "Logout"
},
"bookmark": {
"title": "Bookmarks"

View File

@ -877,7 +877,9 @@
"account_list": {
"tab_bar_hint": "Current selected profile: %s. Double tap then hold to show account switcher",
"dismiss_account_switcher": "Dismiss Account Switcher",
"add_account": "Add Account"
"add_account": "Add Account",
"logout_all_accounts": "Log Out Of All Accounts",
"logout": "Logout"
},
"bookmark": {
"title": "Bookmarks"

View File

@ -173,6 +173,7 @@
D8D688F92AB8B970000F651A /* SearchResultOverviewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D688F82AB8B970000F651A /* SearchResultOverviewCoordinator.swift */; };
D8E5C346296DAB84007E76A7 /* DataSourceFacade+Status+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */; };
D8E5C349296DB8A3007E76A7 /* StatusEditHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */; };
D8E64F412BA84F80003A4539 /* LogoutOfAllAccountsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E64F402BA84F80003A4539 /* LogoutOfAllAccountsCell.swift */; };
D8ECC8102AC31EA400AE0818 /* NotificationSettingsDisabledTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8ECC80F2AC31EA400AE0818 /* NotificationSettingsDisabledTableViewCell.swift */; };
D8F0372C29D232730027DE2E /* HashtagIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F0372B29D232730027DE2E /* HashtagIntentHandler.swift */; };
D8F8A03A29CA5C15000195DD /* HashtagWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F8A03929CA5C15000195DD /* HashtagWidgetView.swift */; };
@ -812,6 +813,7 @@
D8D688F82AB8B970000F651A /* SearchResultOverviewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultOverviewCoordinator.swift; sourceTree = "<group>"; };
D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Status+History.swift"; sourceTree = "<group>"; };
D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryViewController.swift; sourceTree = "<group>"; };
D8E64F402BA84F80003A4539 /* LogoutOfAllAccountsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutOfAllAccountsCell.swift; sourceTree = "<group>"; };
D8ECC80F2AC31EA400AE0818 /* NotificationSettingsDisabledTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsDisabledTableViewCell.swift; sourceTree = "<group>"; };
D8F0372B29D232730027DE2E /* HashtagIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagIntentHandler.swift; sourceTree = "<group>"; };
D8F8A03929CA5C15000195DD /* HashtagWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagWidgetView.swift; sourceTree = "<group>"; };
@ -2709,6 +2711,7 @@
children = (
DB9F58F026EF512300E7BBE9 /* AccountListTableViewCell.swift */,
DBA5A53426F0A36A00CACBAA /* AddAccountTableViewCell.swift */,
D8E64F402BA84F80003A4539 /* LogoutOfAllAccountsCell.swift */,
);
path = Cell;
sourceTree = "<group>";
@ -3799,6 +3802,7 @@
85BC11B32932414900E191CD /* AltTextViewController.swift in Sources */,
DB63F775279A997D00455B82 /* NotificationTableViewCell+ViewModel.swift in Sources */,
DB98EB5927B109890082E365 /* ReportSupplementaryViewController.swift in Sources */,
D8E64F412BA84F80003A4539 /* LogoutOfAllAccountsCell.swift in Sources */,
DB0617EB277EF3820030EE79 /* GradientBorderView.swift in Sources */,
DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */,
DBEFCD7D282A2A3B00C0ABEA /* ReportServerRulesViewController.swift in Sources */,

View File

@ -8,7 +8,6 @@ import UIKit
import Combine
import SafariServices
import CoreDataStack
import PanModal
import MastodonSDK
import MastodonCore
import MastodonAsset
@ -155,12 +154,12 @@ extension SceneCoordinator {
case showDetail // replace
case modal(animated: Bool, completion: (() -> Void)? = nil)
case popover(sourceView: UIView)
case panModal
case custom(transitioningDelegate: UIViewControllerTransitioningDelegate)
case customPush(animated: Bool)
case safariPresent(animated: Bool, completion: (() -> Void)? = nil)
case alertController(animated: Bool, completion: (() -> Void)? = nil)
case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil)
case formSheet
case none
}
@ -307,76 +306,70 @@ extension SceneCoordinator {
let topViewController = navigationController.topViewController {
presentingViewController = topViewController
}
switch transition {
case .none:
// do nothing
break
case .show:
presentingViewController.show(viewController, sender: sender)
case .showDetail:
secondaryStackHashValues.insert(viewController.hashValue)
let navigationController = AdaptiveStatusBarStyleNavigationController(rootViewController: viewController)
presentingViewController.showDetailViewController(navigationController, sender: sender)
case .none:
// do nothing
break
case .show:
presentingViewController.show(viewController, sender: sender)
case .showDetail:
secondaryStackHashValues.insert(viewController.hashValue)
let navigationController = AdaptiveStatusBarStyleNavigationController(rootViewController: viewController)
presentingViewController.showDetailViewController(navigationController, sender: sender)
case .modal(let animated, let completion):
let modalNavigationController: UINavigationController = {
if scene.isOnboarding {
return OnboardingNavigationController(rootViewController: viewController)
} else {
return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController)
}
}()
modalNavigationController.modalPresentationCapturesStatusBarAppearance = true
if let adaptivePresentationControllerDelegate = viewController as? UIAdaptivePresentationControllerDelegate {
modalNavigationController.presentationController?.delegate = adaptivePresentationControllerDelegate
}
presentingViewController.present(modalNavigationController, animated: animated, completion: completion)
case .panModal:
guard let panModalPresentable = viewController as? PanModalPresentable & UIViewController else {
assertionFailure()
return nil
}
// https://github.com/slackhq/PanModal/issues/74#issuecomment-572426441
panModalPresentable.modalPresentationStyle = .custom
panModalPresentable.modalPresentationCapturesStatusBarAppearance = true
panModalPresentable.transitioningDelegate = PanModalPresentationDelegate.default
presentingViewController.present(panModalPresentable, animated: true, completion: nil)
//presentingViewController.presentPanModal(panModalPresentable)
case .popover(let sourceView):
viewController.modalPresentationStyle = .popover
viewController.popoverPresentationController?.sourceView = sourceView
(splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil)
case .custom(let transitioningDelegate):
viewController.modalPresentationStyle = .custom
viewController.transitioningDelegate = transitioningDelegate
viewController.modalPresentationCapturesStatusBarAppearance = true
(splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil)
case .customPush(let animated):
// set delegate in view controller
assert(sender?.navigationController?.delegate != nil)
sender?.navigationController?.pushViewController(viewController, animated: animated)
case .safariPresent(let animated, let completion):
if UserDefaults.shared.preferredUsingDefaultBrowser, case let .safari(url) = scene {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
case .modal(let animated, let completion):
let modalNavigationController: UINavigationController = {
if scene.isOnboarding {
return OnboardingNavigationController(rootViewController: viewController)
} else {
viewController.modalPresentationCapturesStatusBarAppearance = true
presentingViewController.present(viewController, animated: animated, completion: completion)
return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController)
}
}()
modalNavigationController.modalPresentationCapturesStatusBarAppearance = true
if let adaptivePresentationControllerDelegate = viewController as? UIAdaptivePresentationControllerDelegate {
modalNavigationController.presentationController?.delegate = adaptivePresentationControllerDelegate
}
presentingViewController.present(modalNavigationController, animated: animated, completion: completion)
case .popover(let sourceView):
viewController.modalPresentationStyle = .popover
viewController.popoverPresentationController?.sourceView = sourceView
(splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil)
case .custom(let transitioningDelegate):
viewController.modalPresentationStyle = .custom
viewController.transitioningDelegate = transitioningDelegate
viewController.modalPresentationCapturesStatusBarAppearance = true
(splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil)
case .alertController(let animated, let completion):
case .customPush(let animated):
// set delegate in view controller
assert(sender?.navigationController?.delegate != nil)
sender?.navigationController?.pushViewController(viewController, animated: animated)
case .safariPresent(let animated, let completion):
if UserDefaults.shared.preferredUsingDefaultBrowser, case let .safari(url) = scene {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
} else {
viewController.modalPresentationCapturesStatusBarAppearance = true
presentingViewController.present(viewController, animated: animated, completion: completion)
}
case .activityViewControllerPresent(let animated, let completion):
viewController.modalPresentationCapturesStatusBarAppearance = true
presentingViewController.present(viewController, animated: animated, completion: completion)
case .alertController(let animated, let completion):
viewController.modalPresentationCapturesStatusBarAppearance = true
presentingViewController.present(viewController, animated: animated, completion: completion)
case .activityViewControllerPresent(let animated, let completion):
viewController.modalPresentationCapturesStatusBarAppearance = true
presentingViewController.present(viewController, animated: animated, completion: completion)
case .formSheet:
viewController.modalPresentationStyle = .formSheet
if let sheetPresentation = viewController.sheetPresentationController {
sheetPresentation.detents = [.large(), .medium()]
}
presentingViewController.present(viewController, animated: true)
}
return viewController
}
@ -397,175 +390,175 @@ private extension SceneCoordinator {
let viewController: UIViewController?
switch scene {
case .welcome:
let _viewController = WelcomeViewController()
viewController = _viewController
case .mastodonPickServer(let viewModel):
let _viewController = MastodonPickServerViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonRegister(let viewModel):
let _viewController = MastodonRegisterViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonServerRules(let viewModel):
let _viewController = MastodonServerRulesViewController(viewModel: viewModel)
viewController = _viewController
case .mastodonConfirmEmail(let viewModel):
let _viewController = MastodonConfirmEmailViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonLogin:
let loginViewController = MastodonLoginViewController(appContext: appContext,
authenticationViewModel: AuthenticationViewModel(context: appContext, coordinator: self, isAuthenticationExist: false),
sceneCoordinator: self)
loginViewController.delegate = self
case .welcome:
let _viewController = WelcomeViewController()
viewController = _viewController
case .mastodonPickServer(let viewModel):
let _viewController = MastodonPickServerViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonRegister(let viewModel):
let _viewController = MastodonRegisterViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonServerRules(let viewModel):
let _viewController = MastodonServerRulesViewController(viewModel: viewModel)
viewController = _viewController
case .mastodonConfirmEmail(let viewModel):
let _viewController = MastodonConfirmEmailViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonLogin:
let loginViewController = MastodonLoginViewController(appContext: appContext,
authenticationViewModel: AuthenticationViewModel(context: appContext, coordinator: self, isAuthenticationExist: false),
sceneCoordinator: self)
loginViewController.delegate = self
viewController = loginViewController
case .mastodonPrivacyPolicies(let viewModel):
let privacyViewController = PrivacyTableViewController(context: appContext, coordinator: self, viewModel: viewModel)
viewController = privacyViewController
case .mastodonResendEmail(let viewModel):
let _viewController = MastodonResendEmailViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonWebView(let viewModel):
let _viewController = WebViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .searchDetail(let viewModel):
let _viewController = SearchDetailViewController(appContext: appContext, sceneCoordinator: self, authContext: viewModel.authContext)
_viewController.viewModel = viewModel
viewController = _viewController
case .searchResult(let viewModel):
let searchResultViewController = SearchResultViewController()
searchResultViewController.context = appContext
searchResultViewController.coordinator = self
searchResultViewController.viewModel = viewModel
viewController = searchResultViewController
case .compose(let viewModel):
let _viewController = ComposeViewController(viewModel: viewModel)
viewController = _viewController
case .thread(let viewModel):
let _viewController = ThreadViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .editHistory(let viewModel):
let editHistoryViewController = StatusEditHistoryViewController(viewModel: viewModel)
viewController = editHistoryViewController
case .hashtagTimeline(let viewModel):
let _viewController = HashtagTimelineViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .accountList(let viewModel):
let _viewController = AccountListViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .profile(let viewModel):
let _viewController = ProfileViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .bookmark(let viewModel):
let _viewController = BookmarkViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .followedTags(let viewModel):
guard let authContext else { return nil }
viewController = FollowedTagsViewController(appContext: appContext, sceneCoordinator: self, authContext: authContext, viewModel: viewModel)
case .favorite(let viewModel):
let _viewController = FavoriteViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .follower(let viewModel):
let followerListViewController = FollowerListViewController(viewModel: viewModel, coordinator: self, context: appContext)
viewController = followerListViewController
case .following(let viewModel):
let followingListViewController = FollowingListViewController(viewModel: viewModel, coordinator: self, context: appContext)
viewController = followingListViewController
case .familiarFollowers(let viewModel):
viewController = FamiliarFollowersViewController(viewModel: viewModel, context: appContext, coordinator: self)
case .rebloggedBy(let viewModel):
let _viewController = RebloggedByViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .favoritedBy(let viewModel):
let _viewController = FavoritedByViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .report(let viewModel):
viewController = ReportViewController(viewModel: viewModel)
case .reportServerRules(let viewModel):
let _viewController = ReportServerRulesViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportStatus(let viewModel):
let _viewController = ReportStatusViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportSupplementary(let viewModel):
let _viewController = ReportSupplementaryViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportResult(let viewModel):
let _viewController = ReportResultViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .suggestionAccount(let viewModel):
let _viewController = SuggestionAccountViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mediaPreview(let viewModel):
let _viewController = MediaPreviewViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .safari(let url):
guard let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return nil
}
let _viewController = SFSafariViewController(url: url)
_viewController.preferredBarTintColor = SystemTheme.navigationBarBackgroundColor
_viewController.preferredControlTintColor = Asset.Colors.Brand.blurple.color
viewController = _viewController
case .alertController(let alertController):
if let popoverPresentationController = alertController.popoverPresentationController {
assert(
popoverPresentationController.sourceView != nil ||
popoverPresentationController.sourceRect != .zero ||
popoverPresentationController.barButtonItem != nil
)
}
viewController = alertController
case .activityViewController(let activityViewController, let sourceView, let barButtonItem):
activityViewController.popoverPresentationController?.sourceView = sourceView
activityViewController.popoverPresentationController?.barButtonItem = barButtonItem
viewController = activityViewController
case .settings(let setting):
guard let presentedOn = sender,
let accountName = authContext?.mastodonAuthenticationBox.authentication.username,
let authContext
else { return nil }
let settingsCoordinator = SettingsCoordinator(presentedOn: presentedOn,
accountName: accountName,
setting: setting,
appContext: appContext,
authContext: authContext,
sceneCoordinator: self
viewController = loginViewController
case .mastodonPrivacyPolicies(let viewModel):
let privacyViewController = PrivacyTableViewController(context: appContext, coordinator: self, viewModel: viewModel)
viewController = privacyViewController
case .mastodonResendEmail(let viewModel):
let _viewController = MastodonResendEmailViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mastodonWebView(let viewModel):
let _viewController = WebViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .searchDetail(let viewModel):
let _viewController = SearchDetailViewController(appContext: appContext, sceneCoordinator: self, authContext: viewModel.authContext)
_viewController.viewModel = viewModel
viewController = _viewController
case .searchResult(let viewModel):
let searchResultViewController = SearchResultViewController()
searchResultViewController.context = appContext
searchResultViewController.coordinator = self
searchResultViewController.viewModel = viewModel
viewController = searchResultViewController
case .compose(let viewModel):
let _viewController = ComposeViewController(viewModel: viewModel)
viewController = _viewController
case .thread(let viewModel):
let _viewController = ThreadViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .editHistory(let viewModel):
let editHistoryViewController = StatusEditHistoryViewController(viewModel: viewModel)
viewController = editHistoryViewController
case .hashtagTimeline(let viewModel):
let _viewController = HashtagTimelineViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .accountList(let viewModel):
let accountListViewController = AccountListViewController()
accountListViewController.viewModel = viewModel
viewController = accountListViewController
case .profile(let viewModel):
let _viewController = ProfileViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .bookmark(let viewModel):
let _viewController = BookmarkViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .followedTags(let viewModel):
guard let authContext else { return nil }
viewController = FollowedTagsViewController(appContext: appContext, sceneCoordinator: self, authContext: authContext, viewModel: viewModel)
case .favorite(let viewModel):
let _viewController = FavoriteViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .follower(let viewModel):
let followerListViewController = FollowerListViewController(viewModel: viewModel, coordinator: self, context: appContext)
viewController = followerListViewController
case .following(let viewModel):
let followingListViewController = FollowingListViewController(viewModel: viewModel, coordinator: self, context: appContext)
viewController = followingListViewController
case .familiarFollowers(let viewModel):
viewController = FamiliarFollowersViewController(viewModel: viewModel, context: appContext, coordinator: self)
case .rebloggedBy(let viewModel):
let _viewController = RebloggedByViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .favoritedBy(let viewModel):
let _viewController = FavoritedByViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .report(let viewModel):
viewController = ReportViewController(viewModel: viewModel)
case .reportServerRules(let viewModel):
let _viewController = ReportServerRulesViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportStatus(let viewModel):
let _viewController = ReportStatusViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportSupplementary(let viewModel):
let _viewController = ReportSupplementaryViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .reportResult(let viewModel):
let _viewController = ReportResultViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .suggestionAccount(let viewModel):
let _viewController = SuggestionAccountViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .mediaPreview(let viewModel):
let _viewController = MediaPreviewViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .safari(let url):
guard let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return nil
}
let _viewController = SFSafariViewController(url: url)
_viewController.preferredBarTintColor = SystemTheme.navigationBarBackgroundColor
_viewController.preferredControlTintColor = Asset.Colors.Brand.blurple.color
viewController = _viewController
case .alertController(let alertController):
if let popoverPresentationController = alertController.popoverPresentationController {
assert(
popoverPresentationController.sourceView != nil ||
popoverPresentationController.sourceRect != .zero ||
popoverPresentationController.barButtonItem != nil
)
settingsCoordinator.delegate = self
settingsCoordinator.start()
viewController = settingsCoordinator.navigationController
childCoordinator = settingsCoordinator
case .editStatus(let viewModel):
let composeViewController = ComposeViewController(viewModel: viewModel)
viewController = composeViewController
}
viewController = alertController
case .activityViewController(let activityViewController, let sourceView, let barButtonItem):
activityViewController.popoverPresentationController?.sourceView = sourceView
activityViewController.popoverPresentationController?.barButtonItem = barButtonItem
viewController = activityViewController
case .settings(let setting):
guard let presentedOn = sender,
let accountName = authContext?.mastodonAuthenticationBox.authentication.username,
let authContext
else { return nil }
let settingsCoordinator = SettingsCoordinator(presentedOn: presentedOn,
accountName: accountName,
setting: setting,
appContext: appContext,
authContext: authContext,
sceneCoordinator: self
)
settingsCoordinator.delegate = self
settingsCoordinator.start()
viewController = settingsCoordinator.navigationController
childCoordinator = settingsCoordinator
case .editStatus(let viewModel):
let composeViewController = ComposeViewController(viewModel: viewModel)
viewController = composeViewController
}
setupDependency(for: viewController as? NeedsDependency)
return viewController

View File

@ -25,7 +25,6 @@ final class AccountListViewModel: NSObject {
// output
@Published var items: [Item] = []
let dataSourceDidUpdate = PassthroughSubject<Void, Never>()
var diffableDataSource: UITableViewDiffableDataSource<Section, Item>!
init(context: AppContext, authContext: AuthContext) {
@ -49,9 +48,11 @@ final class AccountListViewModel: NSObject {
snapshot.appendItems(authenticationItems, toSection: .main)
snapshot.appendItems([.addAccount], toSection: .main)
diffableDataSource.apply(snapshot) {
self.dataSourceDidUpdate.send()
if authentications.count > 1 {
snapshot.appendItems([.logoutOfAllAccounts], toSection: .main)
}
diffableDataSource.apply(snapshot, animatingDifferences: false)
}
.store(in: &disposeBag)
}
@ -66,12 +67,10 @@ extension AccountListViewModel {
enum Item: Hashable {
case authentication(record: MastodonAuthentication)
case addAccount
case logoutOfAllAccounts
}
func setupDiffableDataSource(
tableView: UITableView,
managedObjectContext: NSManagedObjectContext
) {
func setupDiffableDataSource(tableView: UITableView) {
diffableDataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in
switch item {
case .authentication(let record):
@ -79,7 +78,6 @@ extension AccountListViewModel {
if let activeAuthentication = AuthenticationServiceProvider.shared.authenticationSortedByActivation().first
{
AccountListViewModel.configure(
in: managedObjectContext,
cell: cell,
authentication: record,
activeAuthentication: activeAuthentication
@ -89,6 +87,9 @@ extension AccountListViewModel {
case .addAccount:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AddAccountTableViewCell.self), for: indexPath) as! AddAccountTableViewCell
return cell
case .logoutOfAllAccounts:
let cell = tableView.dequeueReusableCell(withIdentifier: LogoutOfAllAccountsCell.reuseIdentifier, for: indexPath) as! LogoutOfAllAccountsCell
return cell
}
}
@ -98,7 +99,6 @@ extension AccountListViewModel {
}
static func configure(
in context: NSManagedObjectContext,
cell: AccountListTableViewCell,
authentication: MastodonAuthentication,
activeAuthentication: MastodonAuthentication

View File

@ -8,7 +8,6 @@
import UIKit
import Combine
import CoreDataStack
import PanModal
import MastodonAsset
import MastodonLocalization
import MastodonCore
@ -35,14 +34,14 @@ final class AccountListViewController: UIViewController, NeedsDependency {
self?.dismiss(animated: true, completion: nil)
}
var hasLoaded = false
private(set) lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.register(AccountListTableViewCell.self, forCellReuseIdentifier: String(describing: AccountListTableViewCell.self))
tableView.register(AddAccountTableViewCell.self, forCellReuseIdentifier: String(describing: AddAccountTableViewCell.self))
tableView.register(LogoutOfAllAccountsCell.self, forCellReuseIdentifier: LogoutOfAllAccountsCell.reuseIdentifier)
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.tableFooterView = UIView()
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 0)
return tableView
}()
@ -51,38 +50,11 @@ final class AccountListViewController: UIViewController, NeedsDependency {
}
}
// MARK: - PanModalPresentable
extension AccountListViewController: PanModalPresentable {
var panScrollable: UIScrollView? { tableView }
var showDragIndicator: Bool { false }
var shortFormHeight: PanModalHeight {
func calculateHeight(of numberOfItems: Int) -> CGFloat {
return CGFloat(numberOfItems * 60 + 64)
}
if hasLoaded {
let height = calculateHeight(of: viewModel.diffableDataSource.snapshot().numberOfItems)
return .contentHeight(CGFloat(height))
}
let authenticationCount = AuthenticationServiceProvider.shared.authentications.count
let count = authenticationCount + 1
let height = calculateHeight(of: count)
return .contentHeight(height)
}
var longFormHeight: PanModalHeight {
return .maxHeightWithTopInset(0)
}
}
extension AccountListViewController {
override func viewDidLoad() {
super.viewDidLoad()
setupBackgroundColor()
view.backgroundColor = .secondarySystemGroupedBackground
navigationItem.rightBarButtonItem = addBarButtonItem
dragIndicatorView.translatesAutoresizingMaskIntoConstraints = false
@ -104,40 +76,8 @@ extension AccountListViewController {
])
tableView.delegate = self
viewModel.setupDiffableDataSource(
tableView: tableView,
managedObjectContext: context.managedObjectContext
)
viewModel.dataSourceDidUpdate
.receive(on: DispatchQueue.main)
.sink { [weak self, weak presentingViewController] in
guard let self = self else { return }
// the presentingViewController may deinit.
// Hold it and check the window to prevent PanModel crash
guard let _ = presentingViewController else { return }
guard self.view.window != nil else { return }
self.hasLoaded = true
self.panModalSetNeedsLayoutUpdate() // <<< may crash the app
self.panModalTransition(to: .shortForm)
}
.store(in: &disposeBag)
viewModel.setupDiffableDataSource(tableView: tableView)
}
private func setupBackgroundColor() {
let backgroundColor = UIColor { traitCollection in
switch traitCollection.userInterfaceLevel {
case .elevated where traitCollection.userInterfaceStyle == .dark:
return SystemTheme.systemElevatedBackgroundColor
default:
return .systemBackground.withAlphaComponent(0.9)
}
}
view.backgroundColor = backgroundColor
}
}
extension AccountListViewController {
@ -155,11 +95,50 @@ extension AccountListViewController {
// MARK: - UITableViewDelegate
extension AccountListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
guard let diffableDataSource = viewModel.diffableDataSource,
let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil }
switch item {
case .authentication(let record):
let logoutAction = UIContextualAction(style: .destructive, title: L10n.Scene.AccountList.logout, handler: { [weak self] action, view, completion in
guard let self else { return }
UserDefaults.shared.setNotificationCountWithAccessToken(accessToken: record.userAccessToken, value: 0)
Task { @MainActor in
do {
try await self.viewModel.context.authenticationService.signOutMastodonUser(authentication: record)
let userIdentifier = record
FileManager.default.invalidateHomeTimelineCache(for: userIdentifier)
FileManager.default.invalidateNotificationsAll(for: userIdentifier)
FileManager.default.invalidateNotificationsMentions(for: userIdentifier)
self.coordinator.setup()
} catch {
assertionFailure("Failed to delete Authentication: \(error)")
}
}
})
logoutAction.image = UIImage(systemName: "rectangle.portrait.and.arrow.forward")
let swipeConfiguration = UISwipeActionsConfiguration(actions: [logoutAction])
swipeConfiguration.performsFirstActionWithFullSwipe = false
return swipeConfiguration
case .addAccount, .logoutOfAllAccounts:
return nil
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
guard let diffableDataSource = viewModel.diffableDataSource,
let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .authentication(let record):
@ -172,6 +151,27 @@ extension AccountListViewController: UITableViewDelegate {
case .addAccount:
// TODO: add dismiss entry for welcome scene
_ = coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil))
case .logoutOfAllAccounts:
let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
let logoutAction = UIAlertAction(title: L10n.Scene.AccountList.logoutAllAccounts, style: .destructive) { _ in
Task { @MainActor in
self.coordinator.showLoading()
for authenticationBox in self.context.authenticationService.mastodonAuthenticationBoxes {
try? await self.context.authenticationService.signOutMastodonUser(authenticationBox: authenticationBox)
}
self.coordinator.hideLoading()
self.coordinator.setup()
}
}
alert.addAction(logoutAction)
alert.popoverPresentationController?.sourceView = tableView.cellForRow(at: indexPath)
let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default)
alert.addAction(cancelAction)
present(alert, animated: true)
}
}
}

View File

@ -1,9 +1,4 @@
//
// AccountListTableViewCell.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-9-13.
//
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import UIKit
import Combine
@ -26,8 +21,7 @@ final class AccountListTableViewCell: UITableViewCell {
let imageView = UIImageView(image: image)
return imageView
}()
let separatorLine = UIView.separatorLine
override func prepareForReuse() {
super.prepareForReuse()
@ -107,15 +101,6 @@ extension AccountListTableViewCell {
usernameLabel.isUserInteractionEnabled = false
badgeButton.isUserInteractionEnabled = false
separatorLine.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(separatorLine)
NSLayoutConstraint.activate([
separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
separatorLine.trailingAnchor.constraint(equalTo: trailingAnchor), // needs align to edge
separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)),
])
badgeButton.setBadge(number: 0)
checkmarkImageView.isHidden = true

View File

@ -1,108 +1,28 @@
//
// AddAccountTableViewCell.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-9-14.
//
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import UIKit
import Combine
import MetaTextKit
import MastodonAsset
import MastodonLocalization
import MastodonCore
import MastodonUI
final class AddAccountTableViewCell: UITableViewCell {
private var _disposeBag = Set<AnyCancellable>()
let iconImageView: UIImageView = {
let image = UIImage(systemName: "plus.circle.fill")!
let imageView = UIImageView(image: image)
imageView.tintColor = Asset.Colors.Label.primary.color
return imageView
}()
let titleLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 22)
label.textColor = Asset.Colors.Label.primary.color
label.text = L10n.Scene.AccountList.addAccount
return label
}()
let usernameLabel = MetaLabel(style: .accountListUsername)
let separatorLine = UIView.separatorLine
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
var configuration = defaultContentConfiguration()
configuration.image = UIImage(systemName: "plus")
configuration.imageProperties.tintColor = Asset.Colors.Label.primary.color
}
extension AddAccountTableViewCell {
private func _init() {
configuration.text = L10n.Scene.AccountList.addAccount
configuration.textProperties.color = Asset.Colors.Label.primary.color
configuration.textProperties.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 22)
configuration.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)
backgroundColor = .secondarySystemGroupedBackground
iconImageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(iconImageView)
NSLayoutConstraint.activate([
iconImageView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
iconImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
iconImageView.heightAnchor.constraint(equalTo: iconImageView.widthAnchor, multiplier: 1.0).priority(.required - 1),
iconImageView.heightAnchor.constraint(greaterThanOrEqualToConstant: 30).priority(.required - 1),
])
iconImageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
iconImageView.setContentHuggingPriority(.defaultLow, for: .vertical)
// layout the same placeholder UI from `AccountListTableViewCell`
let placeholderLabelContainerStackView = UIStackView()
placeholderLabelContainerStackView.axis = .vertical
placeholderLabelContainerStackView.distribution = .equalCentering
placeholderLabelContainerStackView.spacing = 2
placeholderLabelContainerStackView.distribution = .fillProportionally
placeholderLabelContainerStackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(placeholderLabelContainerStackView)
NSLayoutConstraint.activate([
placeholderLabelContainerStackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
placeholderLabelContainerStackView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: 10),
contentView.bottomAnchor.constraint(equalTo: placeholderLabelContainerStackView.bottomAnchor, constant: 10),
iconImageView.heightAnchor.constraint(equalTo: placeholderLabelContainerStackView.heightAnchor, multiplier: 0.8).priority(.required - 10),
])
let _nameLabel = MetaLabel(style: .accountListName)
_nameLabel.configure(content: PlaintextMetaContent(string: " "))
let _usernameLabel = MetaLabel(style: .accountListUsername)
_usernameLabel.configure(content: PlaintextMetaContent(string: " "))
placeholderLabelContainerStackView.addArrangedSubview(_nameLabel)
placeholderLabelContainerStackView.addArrangedSubview(_usernameLabel)
placeholderLabelContainerStackView.isHidden = true
titleLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(titleLabel)
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 15),
titleLabel.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: 10),
contentView.bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 15),
// iconImageView.heightAnchor.constraint(equalTo: titleLabel.heightAnchor, multiplier: 1.0).priority(.required - 10),
titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
])
separatorLine.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(separatorLine)
NSLayoutConstraint.activate([
separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)),
])
self.contentConfiguration = configuration
accessibilityTraits.insert(.button)
}
required init?(coder: NSCoder) { fatalError() }
}

View File

@ -0,0 +1,30 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import UIKit
import MastodonLocalization
final class LogoutOfAllAccountsCell: UITableViewCell {
static let reuseIdentifier = "LogoutOfAllAccountsCell"
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
var configuration = defaultContentConfiguration()
configuration.image = UIImage(systemName: "rectangle.portrait.and.arrow.forward")
configuration.imageProperties.tintColor = .systemRed
configuration.text = L10n.Scene.AccountList.logoutAllAccounts
configuration.textProperties.color = .systemRed
configuration.textProperties.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 22)
configuration.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)
backgroundColor = .secondarySystemGroupedBackground
self.contentConfiguration = configuration
accessibilityTraits.insert(.button)
}
required init?(coder: NSCoder) { fatalError() }
}

View File

@ -105,9 +105,10 @@ extension HomeTimelineViewModel.LoadLatestState {
guard let viewModel else { return }
let latestFeedRecords = viewModel.dataController.records.prefix(APIService.onceRequestStatusMaxCount)
Task {
Task { @MainActor in
let latestFeedRecords = viewModel.dataController.records.prefix(APIService.onceRequestStatusMaxCount)
let latestStatusIDs: [Status.ID] = latestFeedRecords.compactMap { record in
return record.status?.reblog?.id ?? record.status?.id
}
@ -128,7 +129,7 @@ extension HomeTimelineViewModel.LoadLatestState {
)
}
await enter(state: Idle.self)
enter(state: Idle.self)
viewModel.receiveLoadingStateCompletion(.finished)
// stop refresher if no new statuses
@ -146,7 +147,7 @@ extension HomeTimelineViewModel.LoadLatestState {
for (i, record) in newRecords.enumerated() {
if let index = oldRecords.firstIndex(where: { $0.status?.reblog?.id == record.id || $0.status?.id == record.id }) {
oldRecords[index] = record
if newRecords.count > index {
if newRecords.count > i {
newRecords.remove(at: i)
}
}
@ -165,7 +166,7 @@ extension HomeTimelineViewModel.LoadLatestState {
}
} catch {
await enter(state: Idle.self)
enter(state: Idle.self)
viewModel.didLoadLatest.send()
viewModel.receiveLoadingStateCompletion(.failure(error))
}

View File

@ -297,7 +297,7 @@ extension MainTabBarController {
case .me:
guard let authContext = self.authContext else { return }
let accountListViewModel = AccountListViewModel(context: context, authContext: authContext)
_ = coordinator.present(scene: .accountList(viewModel: accountListViewModel), from: self, transition: .panModal)
_ = coordinator.present(scene: .accountList(viewModel: accountListViewModel), from: self, transition: .formSheet)
default:
break
}

View File

@ -44,7 +44,6 @@ let package = Package(
.package(url: "https://github.com/Flipboard/FLAnimatedImage.git", from: "1.0.0"),
.package(url: "https://github.com/kean/Nuke.git", from: "10.3.1"),
.package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", from: "4.2.2"),
.package(url: "https://github.com/slackhq/PanModal.git", from: "1.2.7"),
.package(url: "https://github.com/TimOliver/TOCropViewController.git", from: "2.6.1"),
.package(url: "https://github.com/TwidereProject/MetaTextKit.git", exact: "2.2.5"),
.package(url: "https://github.com/TwidereProject/TabBarPager.git", from: "0.1.0"),
@ -124,7 +123,6 @@ let package = Package(
.product(name: "Tabman", package: "Tabman"),
.product(name: "MetaTextKit", package: "MetaTextKit"),
.product(name: "CropViewController", package: "TOCropViewController"),
.product(name: "PanModal", package: "PanModal"),
.product(name: "Stripes", package: "Stripes"),
.product(name: "NextLevelSessionExporter", package: "NextLevelSessionExporter"),
.product(name: "SDWebImage", package: "SDWebImage"),

View File

@ -4,7 +4,7 @@ import Foundation
import CoreDataStack
import MastodonSDK
public struct MastodonAuthentication: Codable, Hashable {
public struct MastodonAuthentication: Codable, Hashable, UserIdentifier {
public typealias ID = UUID
public private(set) var identifier: ID
@ -23,7 +23,7 @@ public struct MastodonAuthentication: Codable, Hashable {
public private(set) var userID: String
public private(set) var instanceObjectIdURI: URL?
internal var persistenceIdentifier: String {
public var persistenceIdentifier: String {
"\(username)@\(domain)"
}
@ -120,4 +120,8 @@ public struct MastodonAuthentication: Codable, Hashable {
func updating(activatedAt: Date) -> Self {
copy(activedAt: activatedAt)
}
var authorization: Mastodon.API.OAuth.Authorization {
.init(accessToken: userAccessToken)
}
}

View File

@ -135,24 +135,13 @@ extension AuthenticationService {
return isActive
}
public func signOutMastodonUser(authentication: MastodonAuthentication) async throws {
try AuthenticationServiceProvider.shared.delete(authentication: authentication)
_ = try await apiService?.cancelSubscription(domain: authentication.domain, authorization: authentication.authorization)
}
public func signOutMastodonUser(authenticationBox: MastodonAuthenticationBox) async throws {
let managedObjectContext = backgroundManagedObjectContext
try await managedObjectContext.performChanges {
// remove Feed
let request = Feed.sortedFetchRequest
request.predicate = Feed.predicate(
acct: .mastodon(
domain: authenticationBox.domain,
userID: authenticationBox.userID
)
)
let feeds = managedObjectContext.safeFetch(request)
for feed in feeds {
managedObjectContext.delete(feed)
}
}
do {
try AuthenticationServiceProvider.shared.delete(authentication: authenticationBox.authentication)
} catch {

View File

@ -572,6 +572,10 @@ public enum L10n {
public static let addAccount = L10n.tr("Localizable", "Scene.AccountList.AddAccount", fallback: "Add Account")
/// Dismiss Account Switcher
public static let dismissAccountSwitcher = L10n.tr("Localizable", "Scene.AccountList.DismissAccountSwitcher", fallback: "Dismiss Account Switcher")
/// Logout
public static let logout = L10n.tr("Localizable", "Scene.AccountList.Logout", fallback: "Logout")
/// Log Out Of All Accounts
public static let logoutAllAccounts = L10n.tr("Localizable", "Scene.AccountList.LogoutAllAccounts", fallback: "Log Out Of All Accounts")
/// Current selected profile: %@. Double tap then hold to show account switcher
public static func tabBarHint(_ p1: Any) -> String {
return L10n.tr("Localizable", "Scene.AccountList.TabBarHint", String(describing: p1), fallback: "Current selected profile: %@. Double tap then hold to show account switcher")

View File

@ -202,6 +202,8 @@ Your profile looks like this to them.";
"Extension.OpenIn.InvalidLinkError" = "This doesn't seem to be a valid Mastodon link.";
"Scene.AccountList.AddAccount" = "Add Account";
"Scene.AccountList.DismissAccountSwitcher" = "Dismiss Account Switcher";
"Scene.AccountList.Logout" = "Logout";
"Scene.AccountList.LogoutAllAccounts" = "Log Out Of All Accounts";
"Scene.AccountList.TabBarHint" = "Current selected profile: %@. Double tap then hold to show account switcher";
"Scene.Bookmark.Title" = "Bookmarks";
"Scene.Compose.Accessibility.AppendAttachment" = "Add Attachment";