Implement "Default Post Language" and "Ask Before"-Alerts (#1240)

* Implement Settings->General->"Ask Before" and add "Ask Before Posting Without Alt Text" IOS-166

* Implement Alt Missing Alert for Status Edits (IOS-166)

* Fix status edit composes duplicate message

* Show (or don't) the "Really delete post?" Alert based on the User's preference (IOS-166)

* Implement alert for boost/unboost (IOS-166)

* Begin implementing "Default Post Language"-Setting (IOS-166)

* Show "Unfollow @user?" Alert (IOS-166)

* Merge conflict fixes for IOS-166

* Implement default post language setting (IOS-166)

* Fix follow button state not updated correctly (IOS-166)

* Add PR feedback (IOS-166)

* Improve default language cell style (IOS-166)

* Fix language filter broken (IOS-166)
This commit is contained in:
Marcus Kida 2024-02-28 10:52:04 +01:00 committed by GitHub
parent d3ffa3782e
commit 2e7054cb68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 606 additions and 102 deletions

View File

@ -52,6 +52,24 @@
"title": "Note",
"message": "Translation failed. Maybe the administrator has not enabled translations on this server or this server is running an older version of Mastodon where translations are not yet supported.",
"button": "OK"
},
"media_missing_alt_text": {
"title": "Media Missing Alt Text",
"message": "%d of your images are missing alt text.\nPost Anyway?",
"cancel": "Cancel",
"post": "Post"
},
"boost_a_post": {
"title_boost": "Boost Post?",
"title_unboost": "Unboost Post?",
"cancel": "Cancel",
"boost": "Boost",
"unboost": "Unboost"
},
"unfollow_user": {
"title": "Unfollow %@?",
"cancel": "Cancel",
"unfollow": "Unfollow"
}
},
"controls": {
@ -751,10 +769,21 @@
"light": "Light",
"system": "Use Device Appearance"
},
"ask_before": {
"section_title": "Ask before…",
"posting_without_alt_text": "Posting without Alt Text",
"unfollowing_someone": "Unfollowing Someone",
"boosting_a_post": "Boosting a Post",
"deleting_a_post": "Deleting a Post"
},
"design": {
"section_title": "Design",
"show_animations": "Play Animated Avatars and Emoji"
},
"language": {
"section_title": "Language",
"default_post_language": "Default Post Language"
},
"links": {
"section_title": "Links",
"open_in_mastodon": "Open in Mastodon",

View File

@ -32,6 +32,7 @@
2A409F832B5955290044E472 /* MastodonStatusThreadViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A409F822B5955290044E472 /* MastodonStatusThreadViewModel+State.swift */; };
2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; };
2A506CF6292D040100059C37 /* HashtagTimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */; };
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, ); }; };
2A71F541296DBDA80049F54A /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A71F53D296DBDA80049F54A /* Media.xcassets */; };
@ -642,6 +643,7 @@
2A409F822B5955290044E472 /* MastodonStatusThreadViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonStatusThreadViewModel+State.swift"; sourceTree = "<group>"; };
2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = "<group>"; };
2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderView.swift; sourceTree = "<group>"; };
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; };
2A71F53D296DBDA80049F54A /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
@ -1474,6 +1476,14 @@
path = FollowedTags;
sourceTree = "<group>";
};
2A631AE62B8C9F5900FE0778 /* Language */ = {
isa = PBXGroup;
children = (
2A631AE72B8C9F6600FE0778 /* LanguagePickerViewController.swift */,
);
path = Language;
sourceTree = "<group>";
};
2A71F53C296DBDA80049F54A /* OpenInActionExtension */ = {
isa = PBXGroup;
children = (
@ -1947,6 +1957,7 @@
D8F917042A4B0657008A5370 /* General Settings */ = {
isa = PBXGroup;
children = (
2A631AE62B8C9F5900FE0778 /* Language */,
D8318A832A4468A800C0FB73 /* GeneralSettingsViewController.swift */,
D8F917052A4B0791008A5370 /* GeneralSettings.swift */,
D8F917102A4C6B40008A5370 /* GeneralSettingToggleTableViewCell.swift */,
@ -3931,6 +3942,7 @@
DB6B7500272FF73800C70B6E /* UserTableViewCell.swift in Sources */,
DB1D842E26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift in Sources */,
DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */,
2A631AE82B8C9F6600FE0778 /* LanguagePickerViewController.swift in Sources */,
DBB45B5B27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift in Sources */,
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
DB4932B926F31AD300EF46D4 /* BadgeButton.swift in Sources */,

View File

@ -12,25 +12,67 @@ import MastodonSDK
import MastodonLocalization
extension DataSourceFacade {
@MainActor
static func responseToUserFollowAction(
dependency: NeedsDependency & AuthContextProvider,
dependency: ViewControllerWithDependencies & AuthContextProvider,
account: Mastodon.Entity.Account
) async throws -> Mastodon.Entity.Relationship {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged()
let authBox = dependency.authContext.mastodonAuthenticationBox
let relationship = try await dependency.context.apiService.relationship(
forAccounts: [account], authenticationBox: authBox
).value.first
return try await withCheckedThrowingContinuation { continuation in
Task { @MainActor in
let performAction = {
let selectionFeedbackGenerator = UISelectionFeedbackGenerator()
selectionFeedbackGenerator.selectionChanged()
let response = try await dependency.context.apiService.toggleFollow(
account: account,
authenticationBox: dependency.authContext.mastodonAuthenticationBox
).value
let response = try await dependency.context.apiService.toggleFollow(
account: account,
authenticationBox: dependency.authContext.mastodonAuthenticationBox
).value
dependency.context.authenticationService.fetchFollowingAndBlockedAsync()
dependency.context.authenticationService.fetchFollowingAndBlockedAsync()
NotificationCenter.default.post(name: .relationshipChanged, object: nil, userInfo: [
UserInfoKey.relationship: response
])
NotificationCenter.default.post(name: .relationshipChanged, object: nil, userInfo: [
UserInfoKey.relationship: response
])
continuation.resume(returning: response)
}
return response
if relationship?.following == true {
let alert = UIAlertController(
title: L10n.Common.Alerts.UnfollowUser.title("@\(account.username)"),
message: nil,
preferredStyle: .alert
)
let cancel = UIAlertAction(title: L10n.Common.Alerts.UnfollowUser.cancel, style: .default) { _ in
if let relationship {
NotificationCenter.default.post(name: .relationshipChanged, object: nil, userInfo: [
UserInfoKey.relationship: relationship
])
continuation.resume(returning: relationship)
} else {
continuation.resume(throwing: AppError.unexpected)
}
}
alert.addAction(cancel)
let unfollow = UIAlertAction(title: L10n.Common.Alerts.UnfollowUser.unfollow, style: .destructive) { _ in
Task {
try await performAction()
}
}
alert.addAction(unfollow)
dependency.present(alert, animated: true)
} else {
try await performAction()
}
}
}
}
}

View File

@ -9,12 +9,43 @@ import UIKit
import MastodonCore
import MastodonUI
import MastodonSDK
import MastodonLocalization
extension DataSourceFacade {
@MainActor
static func responseToStatusReblogAction(
provider: DataSourceProvider & AuthContextProvider,
status: MastodonStatus
) async throws {
if UserDefaults.shared.askBeforeBoostingAPost {
let alertController = UIAlertController(
title: status.entity.reblogged == true ? L10n.Common.Alerts.BoostAPost.titleUnboost : L10n.Common.Alerts.BoostAPost.titleBoost,
message: nil,
preferredStyle: .alert
)
let cancelAction = UIAlertAction(title: L10n.Common.Alerts.BoostAPost.cancel, style: .default)
alertController.addAction(cancelAction)
let confirmAction = UIAlertAction(
title: status.entity.reblogged == true ? L10n.Common.Alerts.BoostAPost.unboost : L10n.Common.Alerts.BoostAPost.boost,
style: .default
) { _ in
Task { @MainActor in
try? await performReblog(provider: provider, status: status)
}
}
alertController.addAction(confirmAction)
provider.present(alertController, animated: true)
} else {
try await performReblog(provider: provider, status: status)
}
}
}
private extension DataSourceFacade {
@MainActor
static func performReblog(
provider: DataSourceProvider & AuthContextProvider,
status: MastodonStatus
) async throws {
let selectionFeedbackGenerator = UISelectionFeedbackGenerator()
selectionFeedbackGenerator.selectionChanged()

View File

@ -295,29 +295,28 @@ extension DataSourceFacade {
transition: .activityViewControllerPresent(animated: true, completion: nil)
)
case .deleteStatus:
let alertController = UIAlertController(
title: L10n.Common.Alerts.DeletePost.title,
message: L10n.Common.Alerts.DeletePost.message,
preferredStyle: .alert
)
let confirmAction = UIAlertAction(
title: L10n.Common.Controls.Actions.delete,
style: .destructive
) { [weak dependency] _ in
guard let dependency = dependency else { return }
if UserDefaults.shared.askBeforeDeletingAPost {
let alertController = UIAlertController(
title: L10n.Common.Alerts.DeletePost.title,
message: L10n.Common.Alerts.DeletePost.message,
preferredStyle: .alert
)
let confirmAction = UIAlertAction(
title: L10n.Common.Controls.Actions.delete,
style: .destructive
) { [weak dependency] _ in
guard let dependency else { return }
guard let status = menuContext.statusViewModel?.originalStatus else { return }
performDeletion(of: status, with: dependency)
}
alertController.addAction(confirmAction)
let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel)
alertController.addAction(cancelAction)
dependency.present(alertController, animated: true)
} else {
guard let status = menuContext.statusViewModel?.originalStatus else { return }
Task {
try await DataSourceFacade.responseToDeleteStatus(
dependency: dependency,
status: status
)
} // end Task
performDeletion(of: status, with: dependency)
}
alertController.addAction(confirmAction)
let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel)
alertController.addAction(cancelAction)
dependency.present(alertController, animated: true)
case .translateStatus:
guard let status = menuContext.statusViewModel?.originalStatus else { return }
@ -406,3 +405,13 @@ extension DataSourceFacade {
}
private extension DataSourceFacade {
static func performDeletion(of status: MastodonStatus, with dependency: NeedsDependency & AuthContextProvider & DataSourceProvider) {
Task {
try await DataSourceFacade.responseToDeleteStatus(
dependency: dependency,
status: status
)
}
}
}

View File

@ -8,7 +8,7 @@ import MastodonSDK
extension DataSourceFacade {
static func responseToUserViewButtonAction(
dependency: NeedsDependency & AuthContextProvider,
dependency: ViewControllerWithDependencies & AuthContextProvider,
account: Mastodon.Entity.Account,
buttonState: UserView.ButtonState
) async throws {

View File

@ -195,6 +195,12 @@ extension ComposeViewController {
extension ComposeViewController {
private var mediaAttachmentViewModelsWithoutCaption: [AttachmentViewModel] {
get {
composeContentViewModel.attachmentViewModels.filter({ $0.caption.isEmpty })
}
}
@objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) {
guard composeContentViewModel.shouldDismiss else {
showDismissConfirmAlertController()
@ -215,12 +221,30 @@ extension ComposeViewController {
return
}
let attachmentsWithoutCaptionCount = mediaAttachmentViewModelsWithoutCaption.count
if UserDefaults.shared.askBeforePostingWithoutAltText && attachmentsWithoutCaptionCount > 0 {
let alertController = UIAlertController(
title: L10n.Common.Alerts.MediaMissingAltText.title,
message: L10n.Common.Alerts.MediaMissingAltText.message(attachmentsWithoutCaptionCount),
preferredStyle: .alert
)
let cancelAction = UIAlertAction(title: L10n.Common.Alerts.MediaMissingAltText.cancel, style: .default, handler: nil)
alertController.addAction(cancelAction)
let confirmAction = UIAlertAction(title: L10n.Common.Alerts.MediaMissingAltText.post, style: .default) { [weak self] action in
self?.enqueuePublishStatus()
}
alertController.addAction(confirmAction)
_ = coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil))
return
}
enqueuePublishStatus()
}
private func enqueuePublishStatus() {
do {
let statusPublisher = try composeContentViewModel.statusPublisher()
// let result = try await statusPublisher.publish(api: context.apiService, authContext: viewModel.authContext)
// if let reactor = presentingViewController?.topMostNotModal as? StatusPublisherReactor {
// statusPublisher.reactor = reactor
// }
viewModel.context.publisherService.enqueue(
statusPublisher: statusPublisher,
authContext: viewModel.authContext
@ -246,6 +270,28 @@ extension ComposeViewController {
return
}
let attachmentsWithoutCaptionCount = mediaAttachmentViewModelsWithoutCaption.count
if UserDefaults.shared.askBeforePostingWithoutAltText && attachmentsWithoutCaptionCount > 0 {
let alertController = UIAlertController(
title: L10n.Common.Alerts.MediaMissingAltText.title,
message: L10n.Common.Alerts.MediaMissingAltText.message(attachmentsWithoutCaptionCount),
preferredStyle: .alert
)
let cancelAction = UIAlertAction(title: L10n.Common.Alerts.MediaMissingAltText.cancel, style: .default, handler: nil)
alertController.addAction(cancelAction)
let confirmAction = UIAlertAction(title: L10n.Common.Alerts.MediaMissingAltText.post, style: .default) { [weak self] action in
self?.enqueuePublishStatusEdit()
}
alertController.addAction(confirmAction)
_ = coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil))
return
}
enqueuePublishStatusEdit()
}
private func enqueuePublishStatusEdit() {
do {
guard let editStatusPublisher = try composeContentViewModel.statusEditPublisher() else { return }
viewModel.context.publisherService.enqueue(
@ -259,7 +305,6 @@ extension ComposeViewController {
}
dismiss(animated: true, completion: nil)
}
}

View File

@ -2,18 +2,23 @@
import UIKit
import MastodonAsset
import MastodonUI
class GeneralSettingSelectionCell: UITableViewCell {
static let reuseIdentifier = "GeneralSettingSelectionCell"
func configure(with setting: GeneralSetting, viewModel: GeneralSettingsViewModel) {
switch setting {
case .appearance(let appearanceSetting):
case let .appearance(appearanceSetting):
configureAppearanceSetting(appearanceSetting: appearanceSetting, viewModel: viewModel)
case .design(_):
case .askBefore:
assertionFailure("Not required here")
case .design:
// only for appearance and open links
assertionFailure("Wrong Setting!")
case .openLinksIn(let openLinkSetting):
case let .language(setting):
configureLanguageSetting(setting, viewModel: viewModel)
case let .openLinksIn(openLinkSetting):
configureOpenLinkSetting(openLinkSetting: openLinkSetting, viewModel: viewModel)
}
}
@ -45,5 +50,26 @@ class GeneralSettingSelectionCell: UITableViewCell {
contentConfiguration = content
}
private func configureLanguageSetting(_ setting: GeneralSetting.Language, viewModel: GeneralSettingsViewModel) {
tintColor = Asset.Colors.Brand.blurple.color
accessoryType = .disclosureIndicator
var content = defaultContentConfiguration()
content.prefersSideBySideTextAndSecondaryText = true
content.text = setting.title
if let text = LanguagePicker.availableLanguages().first(where: { $0.localeId == UserDefaults.shared.defaultPostLanguage })?.exonym {
content.secondaryAttributedText = NSAttributedString(
string: text,
attributes: [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)),
.foregroundColor: Asset.Colors.inactive.color
]
)
}
contentConfiguration = content
}
}

View File

@ -27,8 +27,21 @@ class GeneralSettingToggleTableViewCell: ToggleTableViewCell {
self.setting = setting
switch setting {
case .appearance(_), .openLinksIn(_):
assertionFailure("Only for Design")
case .appearance, .openLinksIn, .language:
assertionFailure("Not required here")
case let .askBefore(askBefore):
label.text = askBefore.title
switch askBefore {
case .postingWithoutAltText:
toggle.isOn = UserDefaults.shared.askBeforePostingWithoutAltText
case .unfollowingSomeone:
toggle.isOn = UserDefaults.shared.askBeforeUnfollowingSomeone
case .boostingAPost:
toggle.isOn = UserDefaults.shared.askBeforeBoostingAPost
case .deletingAPost:
toggle.isOn = UserDefaults.shared.askBeforeDeletingAPost
}
case .design(let designSetting):
label.text = designSetting.title

View File

@ -10,15 +10,21 @@ struct GeneralSettingsSection: Hashable {
enum GeneralSettingsSectionType: Hashable {
case appearance
case askBefore
case design
case language
case links
var sectionTitle: String {
switch self {
case .appearance:
return L10n.Scene.Settings.General.Appearance.sectionTitle
case .askBefore:
return L10n.Scene.Settings.General.AskBefore.sectionTitle
case .design:
return L10n.Scene.Settings.General.Design.sectionTitle
case .language:
return L10n.Scene.Settings.General.Language.sectionTitle
case .links:
return L10n.Scene.Settings.General.Links.sectionTitle
}
@ -28,7 +34,9 @@ enum GeneralSettingsSectionType: Hashable {
enum GeneralSetting: Hashable {
case appearance(Appearance)
case askBefore(AskBefore)
case design(Design)
case language(Language)
case openLinksIn(OpenLinksIn)
enum Appearance: Int, CaseIterable {
@ -51,6 +59,27 @@ enum GeneralSetting: Hashable {
.init(rawValue: rawValue) ?? .unspecified
}
}
enum AskBefore: Hashable {
case postingWithoutAltText
case unfollowingSomeone
case boostingAPost
case deletingAPost
var title: String {
switch self {
case .postingWithoutAltText:
return L10n.Scene.Settings.General.AskBefore.postingWithoutAltText
case .unfollowingSomeone:
return L10n.Scene.Settings.General.AskBefore.unfollowingSomeone
case .boostingAPost:
return L10n.Scene.Settings.General.AskBefore.boostingAPost
case .deletingAPost:
return L10n.Scene.Settings.General.AskBefore.deletingAPost
}
}
}
enum Design: Hashable {
case showAnimations
@ -62,6 +91,17 @@ enum GeneralSetting: Hashable {
}
}
}
enum Language: Hashable {
case defaultPostLanguage
var title: String {
switch self {
case .defaultPostLanguage:
return L10n.Scene.Settings.General.Language.defaultPostLanguage
}
}
}
enum OpenLinksIn: Hashable, CaseIterable {
case mastodon

View File

@ -2,17 +2,25 @@
import UIKit
import MastodonSDK
import MastodonCore
import CoreDataStack
import MastodonLocalization
import MastodonUI
struct GeneralSettingsViewModel {
var selectedAppearence: GeneralSetting.Appearance
var playAnimations: Bool
var selectedOpenLinks: GeneralSetting.OpenLinksIn
var askBeforePostingWithoutAltText: Bool
var askBeforeUnfollowingSomeone: Bool
var askBeforeBoostingAPost: Bool
var askBeforeDeletingAPost: Bool
var defaultPostLanguage: String
}
protocol GeneralSettingsViewControllerDelegate: AnyObject {
func save(_ viewController: UIViewController, setting: Setting, viewModel: GeneralSettingsViewModel)
func showLanguagePicker(_ viewModel: GeneralSettingsViewModel, onLanguageSelected: @escaping OnLanguageSelected)
}
class GeneralSettingsViewController: UIViewController {
@ -27,7 +35,7 @@ class GeneralSettingsViewController: UIViewController {
let sections: [GeneralSettingsSection]
init(setting: Setting) {
init(appContext: AppContext, setting: Setting) {
tableView = UITableView(frame: .zero, style: .insetGrouped)
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.register(GeneralSettingSelectionCell.self, forCellReuseIdentifier: GeneralSettingSelectionCell.reuseIdentifier)
@ -39,9 +47,18 @@ class GeneralSettingsViewController: UIViewController {
.appearance(.dark),
.appearance(.system)
]),
GeneralSettingsSection(type: .askBefore, entries: [
.askBefore(.postingWithoutAltText),
.askBefore(.unfollowingSomeone),
.askBefore(.boostingAPost),
.askBefore(.deletingAPost)
]),
GeneralSettingsSection(type: .design, entries: [
.design(.showAnimations)
]),
GeneralSettingsSection(type: .language, entries: [
.language(.defaultPostLanguage)
]),
GeneralSettingsSection(type: .links, entries: [
.openLinksIn(.mastodon),
.openLinksIn(.browser),
@ -58,7 +75,12 @@ class GeneralSettingsViewController: UIViewController {
viewModel = GeneralSettingsViewModel(
selectedAppearence: GeneralSetting.Appearance(rawValue: UserDefaults.shared.customUserInterfaceStyle.rawValue) ?? .system,
playAnimations: playAnimations,
selectedOpenLinks: openLinksIn
selectedOpenLinks: openLinksIn,
askBeforePostingWithoutAltText: UserDefaults.shared.askBeforePostingWithoutAltText,
askBeforeUnfollowingSomeone: UserDefaults.shared.askBeforeUnfollowingSomeone,
askBeforeBoostingAPost: UserDefaults.shared.askBeforeBoostingAPost,
askBeforeDeletingAPost: UserDefaults.shared.askBeforeDeletingAPost,
defaultPostLanguage: UserDefaults.shared.defaultPostLanguage
)
self.setting = setting
@ -75,6 +97,12 @@ class GeneralSettingsViewController: UIViewController {
selectionCell.configure(with: .appearance(setting), viewModel: self.viewModel)
cell = selectionCell
case .askBefore(let setting):
guard let toggleCell = tableView.dequeueReusableCell(withIdentifier: GeneralSettingToggleTableViewCell.reuseIdentifier, for: indexPath) as? GeneralSettingToggleTableViewCell else { fatalError("WTF? Wrong Cell!") }
toggleCell.configure(with: .askBefore(setting), viewModel: self.viewModel)
toggleCell.delegate = self
cell = toggleCell
case .design(let setting):
guard let toggleCell = tableView.dequeueReusableCell(withIdentifier: GeneralSettingToggleTableViewCell.reuseIdentifier, for: indexPath) as? GeneralSettingToggleTableViewCell else { fatalError("WTF? Wrong Cell!") }
@ -82,6 +110,11 @@ class GeneralSettingsViewController: UIViewController {
toggleCell.delegate = self
cell = toggleCell
case let .language(setting):
guard let selectionCell = tableView.dequeueReusableCell(withIdentifier: GeneralSettingSelectionCell.reuseIdentifier, for: indexPath) as? GeneralSettingSelectionCell else { fatalError("WTF? Wrong Cell!") }
selectionCell.configure(with: .language(setting), viewModel: self.viewModel)
cell = selectionCell
case .openLinksIn(let setting):
guard let selectionCell = tableView.dequeueReusableCell(withIdentifier: GeneralSettingSelectionCell.reuseIdentifier, for: indexPath) as? GeneralSettingSelectionCell else { fatalError("WTF? Wrong Cell!") }
@ -126,25 +159,42 @@ extension GeneralSettingsViewController: UITableViewDelegate {
let section = sections[indexPath.section].entries[indexPath.row]
switch section {
case .appearance(let appearanceOption):
viewModel.selectedAppearence = appearanceOption
case .appearance(let appearanceOption):
viewModel.selectedAppearence = appearanceOption
if let snapshot = tableViewDataSource?.snapshot() {
tableViewDataSource?.applySnapshotUsingReloadData(snapshot)
}
case .askBefore(let askBefore):
guard let cell = tableView.cellForRow(at: indexPath) as? GeneralSettingToggleTableViewCell else { return}
let newValue = (cell.toggle.isOn == false)
cell.toggle.setOn(newValue, animated: true)
toggle(cell, setting: .askBefore(askBefore), isOn: newValue)
case .design(let design):
guard let cell = tableView.cellForRow(at: indexPath) as? GeneralSettingToggleTableViewCell else { return}
let newValue = (cell.toggle.isOn == false)
cell.toggle.setOn(newValue, animated: true)
toggle(cell, setting: .design(design), isOn: newValue)
case .language:
delegate?.showLanguagePicker(viewModel) { [weak self] language in
guard let self else { return }
viewModel.defaultPostLanguage = language
UserDefaults.shared.defaultPostLanguage = language
if let snapshot = tableViewDataSource?.snapshot() {
tableViewDataSource?.applySnapshotUsingReloadData(snapshot)
}
case .design(let design):
guard let cell = tableView.cellForRow(at: indexPath) as? GeneralSettingToggleTableViewCell else { return}
}
case .openLinksIn(let openLinksInOption):
viewModel.selectedOpenLinks = openLinksInOption
let newValue = (cell.toggle.isOn == false)
cell.toggle.setOn(newValue, animated: true)
toggle(cell, setting: .design(design), isOn: newValue)
case .openLinksIn(let openLinksInOption):
viewModel.selectedOpenLinks = openLinksInOption
if let snapshot = tableViewDataSource?.snapshot() {
tableViewDataSource?.applySnapshotUsingReloadData(snapshot)
}
if let snapshot = tableViewDataSource?.snapshot() {
tableViewDataSource?.applySnapshotUsingReloadData(snapshot)
}
}
tableView.deselectRow(at: indexPath, animated: true)
@ -155,13 +205,24 @@ extension GeneralSettingsViewController: UITableViewDelegate {
extension GeneralSettingsViewController: GeneralSettingToggleTableViewCellDelegate {
func toggle(_ cell: GeneralSettingToggleTableViewCell, setting: GeneralSetting, isOn: Bool) {
switch setting {
case .appearance(_), .openLinksIn(_):
assertionFailure("No toggle")
case .design(let designSetting):
switch designSetting {
case .showAnimations:
viewModel.playAnimations = isOn
}
case .appearance, .openLinksIn, .language:
assertionFailure("No toggle")
case let .askBefore(askBefore):
switch askBefore {
case .postingWithoutAltText:
UserDefaults.shared.askBeforePostingWithoutAltText = isOn
case .unfollowingSomeone:
UserDefaults.shared.askBeforeUnfollowingSomeone = isOn
case .boostingAPost:
UserDefaults.shared.askBeforeBoostingAPost = isOn
case .deletingAPost:
UserDefaults.shared.askBeforeDeletingAPost = isOn
}
case let .design(designSetting):
switch designSetting {
case .showAnimations:
viewModel.playAnimations = isOn
}
}
delegate?.save(self, setting: self.setting, viewModel: viewModel)

View File

@ -0,0 +1,19 @@
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
import MastodonLocalization
import MastodonUI
import SwiftUI
class LanguagePickerViewController: UIHostingController<LanguagePicker> {
private let onLanguageSelected: OnLanguageSelected
init(onLanguageSelected: @escaping OnLanguageSelected) {
self.onLanguageSelected = onLanguageSelected
super.init(rootView: LanguagePicker(selectedLanguage: UserDefaults.shared.defaultPostLanguage, onSelect:self.onLanguageSelected))
title = L10n.Scene.Settings.General.Language.defaultPostLanguage
}
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -7,6 +7,7 @@ import CoreDataStack
import MastodonSDK
import Combine
import MetaTextKit
import MastodonUI
protocol SettingsCoordinatorDelegate: AnyObject {
func logout(_ settingsCoordinator: SettingsCoordinator)
@ -57,9 +58,10 @@ extension SettingsCoordinator: SettingsViewControllerDelegate {
func didSelect(_ viewController: UIViewController, entry: SettingsEntry) {
switch entry {
case .general:
let generalSettingsViewController = GeneralSettingsViewController(setting: setting)
let generalSettingsViewController = GeneralSettingsViewController(appContext: appContext, setting: setting)
generalSettingsViewController.delegate = self
navigationController.pushViewController(generalSettingsViewController, animated: true)
case .notifications:
@ -144,6 +146,11 @@ extension SettingsCoordinator: GeneralSettingsViewControllerDelegate {
UserDefaults.shared.preferredStaticAvatar = viewModel.playAnimations == false
UserDefaults.shared.preferredUsingDefaultBrowser = viewModel.selectedOpenLinks == .browser
}
func showLanguagePicker(_ viewModel: GeneralSettingsViewModel, onLanguageSelected: @escaping OnLanguageSelected) {
let viewController = LanguagePickerViewController(onLanguageSelected: onLanguageSelected)
navigationController.pushViewController(viewController, animated: true)
}
}
//MARK: - NotificationSettingsViewControllerDelegate

View File

@ -47,7 +47,7 @@ extension UserTableViewCell {
}
}
extension UserTableViewCellDelegate where Self: NeedsDependency & AuthContextProvider {
extension UserTableViewCellDelegate where Self: ViewControllerWithDependencies & AuthContextProvider {
func userView(_ view: UserView, didTapButtonWith state: UserView.ButtonState, for account: Mastodon.Entity.Account, me: Mastodon.Entity.Account?) {
Task {
await MainActor.run { view.setButtonState(.loading) }

View File

@ -110,7 +110,7 @@ final class SuggestionAccountViewModel: NSObject {
.store(in: &disposeBag)
}
func followAllSuggestedAccounts(_ dependency: NeedsDependency & AuthContextProvider, presentedOn: UIViewController?, completion: (() -> Void)? = nil) {
func followAllSuggestedAccounts(_ dependency: ViewControllerWithDependencies & AuthContextProvider, presentedOn: UIViewController?, completion: (() -> Void)? = nil) {
let tmpAccounts = accounts.compactMap { $0.account }

View File

@ -0,0 +1,35 @@
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
import Foundation
extension UserDefaults {
@objc public dynamic var askBeforePostingWithoutAltText: Bool {
get {
return object(forKey: #function) as? Bool ?? true
}
set { self[#function] = newValue }
}
@objc public dynamic var askBeforeUnfollowingSomeone: Bool {
get {
return object(forKey: #function) as? Bool ?? true
}
set { self[#function] = newValue }
}
@objc public dynamic var askBeforeBoostingAPost: Bool {
get {
return object(forKey: #function) as? Bool ?? true
}
set { self[#function] = newValue }
}
@objc public dynamic var askBeforeDeletingAPost: Bool {
get {
return object(forKey: #function) as? Bool ?? true
}
set { self[#function] = newValue }
}
}

View File

@ -0,0 +1,14 @@
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
import Foundation
extension UserDefaults {
@objc public dynamic var defaultPostLanguage: String {
get {
return object(forKey: #function) as? String ?? Locale.current.language.languageCode?.identifier ?? "en"
}
set { self[#function] = newValue }
}
}

View File

@ -10,4 +10,5 @@ import Foundation
public enum AppError: Error {
case badRequest
case badAuthentication
case unexpected
}

View File

@ -20,6 +20,18 @@ public enum L10n {
return L10n.tr("Localizable", "Common.Alerts.BlockDomain.Title", String(describing: p1), fallback: "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain and any of your followers from that domain will be removed.")
}
}
public enum BoostAPost {
/// Boost
public static let boost = L10n.tr("Localizable", "Common.Alerts.BoostAPost.Boost", fallback: "Boost")
/// Cancel
public static let cancel = L10n.tr("Localizable", "Common.Alerts.BoostAPost.Cancel", fallback: "Cancel")
/// Boost Post?
public static let titleBoost = L10n.tr("Localizable", "Common.Alerts.BoostAPost.TitleBoost", fallback: "Boost Post?")
/// Unboost Post?
public static let titleUnboost = L10n.tr("Localizable", "Common.Alerts.BoostAPost.TitleUnboost", fallback: "Unboost Post?")
/// Unboost
public static let unboost = L10n.tr("Localizable", "Common.Alerts.BoostAPost.Unboost", fallback: "Unboost")
}
public enum CleanCache {
/// Successfully cleaned %@ cache.
public static func message(_ p1: Any) -> String {
@ -46,6 +58,19 @@ public enum L10n {
/// Edit Profile Error
public static let title = L10n.tr("Localizable", "Common.Alerts.EditProfileFailure.Title", fallback: "Edit Profile Error")
}
public enum MediaMissingAltText {
/// Cancel
public static let cancel = L10n.tr("Localizable", "Common.Alerts.MediaMissingAltText.Cancel", fallback: "Cancel")
/// %d of your images are missing alt text.
/// Post Anyway?
public static func message(_ p1: Int) -> String {
return L10n.tr("Localizable", "Common.Alerts.MediaMissingAltText.Message", p1, fallback: "%d of your images are missing alt text.\nPost Anyway?")
}
/// Post
public static let post = L10n.tr("Localizable", "Common.Alerts.MediaMissingAltText.Post", fallback: "Post")
/// Media Missing Alt Text
public static let title = L10n.tr("Localizable", "Common.Alerts.MediaMissingAltText.Title", fallback: "Media Missing Alt Text")
}
public enum PublishPostFailure {
/// Failed to publish the post.
/// Please check your internet connection.
@ -89,6 +114,16 @@ public enum L10n {
/// Note
public static let title = L10n.tr("Localizable", "Common.Alerts.TranslationFailed.Title", fallback: "Note")
}
public enum UnfollowUser {
/// Cancel
public static let cancel = L10n.tr("Localizable", "Common.Alerts.UnfollowUser.Cancel", fallback: "Cancel")
/// Unfollow %@
public static func title(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Alerts.UnfollowUser.Title", String(describing: p1), fallback: "Unfollow %@")
}
/// Unfollow
public static let unfollow = L10n.tr("Localizable", "Common.Alerts.UnfollowUser.Unfollow", fallback: "Unfollow")
}
public enum VoteFailure {
/// The poll has ended
public static let pollEnded = L10n.tr("Localizable", "Common.Alerts.VoteFailure.PollEnded", fallback: "The poll has ended")
@ -1459,12 +1494,30 @@ public enum L10n {
/// Use Device Appearance
public static let system = L10n.tr("Localizable", "Scene.Settings.General.Appearance.System", fallback: "Use Device Appearance")
}
public enum AskBefore {
/// Boosting a Post
public static let boostingAPost = L10n.tr("Localizable", "Scene.Settings.General.AskBefore.BoostingAPost", fallback: "Boosting a Post")
/// Deleting a Post
public static let deletingAPost = L10n.tr("Localizable", "Scene.Settings.General.AskBefore.DeletingAPost", fallback: "Deleting a Post")
/// Posting without Alt Text
public static let postingWithoutAltText = L10n.tr("Localizable", "Scene.Settings.General.AskBefore.PostingWithoutAltText", fallback: "Posting without Alt Text")
/// Ask before
public static let sectionTitle = L10n.tr("Localizable", "Scene.Settings.General.AskBefore.SectionTitle", fallback: "Ask before…")
/// Unfollowing Someone
public static let unfollowingSomeone = L10n.tr("Localizable", "Scene.Settings.General.AskBefore.UnfollowingSomeone", fallback: "Unfollowing Someone")
}
public enum Design {
/// Design
public static let sectionTitle = L10n.tr("Localizable", "Scene.Settings.General.Design.SectionTitle", fallback: "Design")
/// Play Animated Avatars and Emoji
public static let showAnimations = L10n.tr("Localizable", "Scene.Settings.General.Design.ShowAnimations", fallback: "Play Animated Avatars and Emoji")
}
public enum Language {
/// Default Post Language
public static let defaultPostLanguage = L10n.tr("Localizable", "Scene.Settings.General.Language.DefaultPostLanguage", fallback: "Default Post Language")
/// Language
public static let sectionTitle = L10n.tr("Localizable", "Scene.Settings.General.Language.SectionTitle", fallback: "Language")
}
public enum Links {
/// Open in Browser
public static let openInBrowser = L10n.tr("Localizable", "Scene.Settings.General.Links.OpenInBrowser", fallback: "Open in Browser")

View File

@ -25,6 +25,18 @@ Please check your internet connection.";
"Common.Alerts.TranslationFailed.Title" = "Note";
"Common.Alerts.VoteFailure.PollEnded" = "The poll has ended";
"Common.Alerts.VoteFailure.Title" = "Vote Failure";
"Common.Alerts.MediaMissingAltText.Title" = "Media Missing Alt Text";
"Common.Alerts.MediaMissingAltText.Message" = "%d of your images are missing alt text.\nPost Anyway?";
"Common.Alerts.MediaMissingAltText.Cancel" = "Cancel";
"Common.Alerts.MediaMissingAltText.Post" = "Post";
"Common.Alerts.BoostAPost.TitleBoost" = "Boost Post?";
"Common.Alerts.BoostAPost.TitleUnboost" = "Unboost Post?";
"Common.Alerts.BoostAPost.Cancel" = "Cancel";
"Common.Alerts.BoostAPost.Boost" = "Boost";
"Common.Alerts.BoostAPost.Unboost" = "Unboost";
"Common.Alerts.UnfollowUser.Title" = "Unfollow %@";
"Common.Alerts.UnfollowUser.Cancel" = "Cancel";
"Common.Alerts.UnfollowUser.Unfollow" = "Unfollow";
"Common.Controls.Actions.Add" = "Add";
"Common.Controls.Actions.Back" = "Back";
"Common.Controls.Actions.BlockDomain" = "Block %@";
@ -503,8 +515,15 @@ uploaded to Mastodon.";
"Scene.Settings.General.Appearance.Light" = "Light";
"Scene.Settings.General.Appearance.SectionTitle" = "Appearance";
"Scene.Settings.General.Appearance.System" = "Use Device Appearance";
"Scene.Settings.General.AskBefore.SectionTitle" = "Ask before…";
"Scene.Settings.General.AskBefore.PostingWithoutAltText" = "Posting without Alt Text";
"Scene.Settings.General.AskBefore.UnfollowingSomeone" = "Unfollowing Someone";
"Scene.Settings.General.AskBefore.BoostingAPost" = "Boosting a Post";
"Scene.Settings.General.AskBefore.DeletingAPost" = "Deleting a Post";
"Scene.Settings.General.Design.SectionTitle" = "Design";
"Scene.Settings.General.Design.ShowAnimations" = "Play Animated Avatars and Emoji";
"Scene.Settings.General.Language.SectionTitle" = "Language";
"Scene.Settings.General.Language.DefaultPostLanguage" = "Default Post Language";
"Scene.Settings.General.Links.OpenInBrowser" = "Open in Browser";
"Scene.Settings.General.Links.OpenInMastodon" = "Open in Mastodon";
"Scene.Settings.General.Links.SectionTitle" = "Links";
@ -568,4 +587,4 @@ uploaded to Mastodon.";
"Widget.MultipleFollowers.ConfigurationDescription" = "Show number of followers for multiple accounts.";
"Widget.MultipleFollowers.ConfigurationDisplayName" = "Multiple followers";
"Widget.MultipleFollowers.MockUser.AccountName" = "another@follower.social";
"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower";
"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower";

View File

@ -45,7 +45,7 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
public let authContext: AuthContext
public let input: Input
public let sizeLimit: SizeLimit
@Published var caption = ""
@Published public internal(set) var caption = ""
@Published public private(set) var isCaptionEditable = true
let isEditing: Bool

View File

@ -185,7 +185,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
let recentLanguages = context.settingService.currentSetting.value?.recentLanguages ?? []
self.recentLanguages = recentLanguages
self.language = recentLanguages.first ?? Locale.current.language.languageCode?.identifier ?? "en"
self.language = UserDefaults.shared.defaultPostLanguage
super.init()
// end init

View File

@ -32,7 +32,7 @@ extension ComposeContentToolbarView {
@Published var isAttachmentButtonEnabled = false
@Published var isPollButtonEnabled = false
@Published var language = Locale.current.language.languageCode?.identifier ?? "en"
@Published var language = UserDefaults.shared.defaultPostLanguage
@Published var recentLanguages: [String] = []
@Published public var maxTextInputLimit = 500

View File

@ -142,7 +142,7 @@ struct ComposeContentToolbarView: View {
}
.frame(width: 48, height: 48)
.popover(isPresented: $showingLanguagePicker) {
let picker = LanguagePicker { newLanguage in
let picker = LanguagePickerNavigationView(selectedLanguage: viewModel.language) { newLanguage in
viewModel.language = newLanguage
didChangeLanguage = true
showingLanguagePicker = false

View File

@ -3,11 +3,11 @@
import Foundation
// Consider replacing this with Locale.Language when dropping iOS 15
struct Language: Identifiable {
let endonym: String
let exonym: String
let id: String
let localeId: String?
public struct Language: Identifiable {
public let endonym: String
public let exonym: String
public let id: String
public let localeId: String?
init(endonym: String, exonym: String, id: String, localeId: String?) {
self.endonym = endonym

View File

@ -2,15 +2,53 @@
import MastodonLocalization
import SwiftUI
import MastodonAsset
struct LanguagePicker: View {
let onSelect: (String) -> Void
public typealias OnLanguageSelected = (String) -> Void
struct LanguagePickerNavigationView: View {
public init(selectedLanguage: String, onSelect: @escaping OnLanguageSelected) {
self._selectedLanguage = State(initialValue: selectedLanguage)
self.onSelect = onSelect
}
@Environment(\.dismiss) private var dismiss
@State private var selectedLanguage: String
private let onSelect: OnLanguageSelected
var body: some View {
NavigationView {
LanguagePicker(selectedLanguage: selectedLanguage) { onSelect($0) }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.Common.Controls.Actions.cancel) {
dismiss()
}
}
}
.navigationTitle(L10n.Scene.Compose.Language.title)
.navigationBarTitleDisplayMode(.inline)
}.navigationViewStyle(.stack)
}
}
public struct LanguagePicker: View {
public init(selectedLanguage: String, onSelect: @escaping OnLanguageSelected) {
self._selectedLanguage = State(initialValue: selectedLanguage)
self.onSelect = onSelect
}
@State private var selectedLanguage: String
private let onSelect: OnLanguageSelected
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@State private var query = ""
@State private var languages: [Language] = {
@State private var languages: [Language] = availableLanguages()
public static func availableLanguages() -> [Language] {
let locales = Locale.availableIdentifiers.map(Locale.init(identifier:))
var languages: [String: Language] = [:]
for locale in locales {
@ -23,10 +61,10 @@ struct LanguagePicker: View {
}
}
return languages.values.sorted(using: KeyPathComparator(\.id))
}()
var body: some View {
NavigationView {
}
public var body: some View {
ScrollViewReader { proxy in
let filteredLanguages = query.isEmpty ? languages : languages.filter { $0.contains(query) }
List(filteredLanguages) { lang in
let endonym = Text(lang.endonym)
@ -36,31 +74,41 @@ struct LanguagePicker: View {
}
return Text("")
}()
Button(action: { onSelect(lang.id) }) {
ViewThatFits(in: .horizontal) {
HStack(spacing: 0) { endonym; Text(" "); exonym }
VStack(alignment: .leading) { endonym; exonym }
Button(action: {
selectedLanguage = lang.id
onSelect(lang.id)
}) {
HStack {
ViewThatFits(in: .horizontal) {
HStack(spacing: 0) { endonym; Text(" "); exonym }
VStack(alignment: .leading) { endonym; exonym }
}
if lang.id == selectedLanguage {
Spacer()
Image(systemName: "checkmark")
.foregroundStyle(Asset.Colors.Brand.blurple.swiftUIColor)
}
}
}
.tint(.primary)
.accessibilityLabel(Text(lang.label))
}.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.Common.Controls.Actions.cancel) {
dismiss()
}
}
.id(lang.id)
}
.listStyle(.plain)
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
.navigationTitle(L10n.Scene.Compose.Language.title)
.navigationBarTitleDisplayMode(.inline)
}.navigationViewStyle(.stack)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { // when scrolling to quickly it'll overlap with other drawcycles and mess up the position :-(
if let selectedIndex = filteredLanguages.first(where: { $0.id == selectedLanguage }) {
proxy.scrollTo(selectedIndex.id, anchor: .center)
}
}
}
}
}
}
struct SwiftUIView_Previews: PreviewProvider {
static var previews: some View {
LanguagePicker(onSelect: { _ in })
LanguagePicker(selectedLanguage: "en", onSelect: { _ in })
}
}