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", "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.", "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" "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": { "controls": {
@ -751,10 +769,21 @@
"light": "Light", "light": "Light",
"system": "Use Device Appearance" "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": { "design": {
"section_title": "Design", "section_title": "Design",
"show_animations": "Play Animated Avatars and Emoji" "show_animations": "Play Animated Avatars and Emoji"
}, },
"language": {
"section_title": "Language",
"default_post_language": "Default Post Language"
},
"links": { "links": {
"section_title": "Links", "section_title": "Links",
"open_in_mastodon": "Open in Mastodon", "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 */; }; 2A409F832B5955290044E472 /* MastodonStatusThreadViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A409F822B5955290044E472 /* MastodonStatusThreadViewModel+State.swift */; };
2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; }; 2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; };
2A506CF6292D040100059C37 /* HashtagTimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.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 */; }; 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, ); }; }; 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 */; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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; }; 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>"; }; 2A71F53D296DBDA80049F54A /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
@ -1474,6 +1476,14 @@
path = FollowedTags; path = FollowedTags;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
2A631AE62B8C9F5900FE0778 /* Language */ = {
isa = PBXGroup;
children = (
2A631AE72B8C9F6600FE0778 /* LanguagePickerViewController.swift */,
);
path = Language;
sourceTree = "<group>";
};
2A71F53C296DBDA80049F54A /* OpenInActionExtension */ = { 2A71F53C296DBDA80049F54A /* OpenInActionExtension */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -1947,6 +1957,7 @@
D8F917042A4B0657008A5370 /* General Settings */ = { D8F917042A4B0657008A5370 /* General Settings */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
2A631AE62B8C9F5900FE0778 /* Language */,
D8318A832A4468A800C0FB73 /* GeneralSettingsViewController.swift */, D8318A832A4468A800C0FB73 /* GeneralSettingsViewController.swift */,
D8F917052A4B0791008A5370 /* GeneralSettings.swift */, D8F917052A4B0791008A5370 /* GeneralSettings.swift */,
D8F917102A4C6B40008A5370 /* GeneralSettingToggleTableViewCell.swift */, D8F917102A4C6B40008A5370 /* GeneralSettingToggleTableViewCell.swift */,
@ -3931,6 +3942,7 @@
DB6B7500272FF73800C70B6E /* UserTableViewCell.swift in Sources */, DB6B7500272FF73800C70B6E /* UserTableViewCell.swift in Sources */,
DB1D842E26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift in Sources */, DB1D842E26552C4D000346B3 /* StatusTableViewControllerNavigateable.swift in Sources */,
DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */, DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */,
2A631AE82B8C9F6600FE0778 /* LanguagePickerViewController.swift in Sources */,
DBB45B5B27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift in Sources */, DBB45B5B27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift in Sources */,
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
DB4932B926F31AD300EF46D4 /* BadgeButton.swift in Sources */, DB4932B926F31AD300EF46D4 /* BadgeButton.swift in Sources */,

View File

@ -12,25 +12,67 @@ import MastodonSDK
import MastodonLocalization import MastodonLocalization
extension DataSourceFacade { extension DataSourceFacade {
@MainActor
static func responseToUserFollowAction( static func responseToUserFollowAction(
dependency: NeedsDependency & AuthContextProvider, dependency: ViewControllerWithDependencies & AuthContextProvider,
account: Mastodon.Entity.Account account: Mastodon.Entity.Account
) async throws -> Mastodon.Entity.Relationship { ) async throws -> Mastodon.Entity.Relationship {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator() let authBox = dependency.authContext.mastodonAuthenticationBox
await selectionFeedbackGenerator.selectionChanged() 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( let response = try await dependency.context.apiService.toggleFollow(
account: account, account: account,
authenticationBox: dependency.authContext.mastodonAuthenticationBox authenticationBox: dependency.authContext.mastodonAuthenticationBox
).value ).value
dependency.context.authenticationService.fetchFollowingAndBlockedAsync() dependency.context.authenticationService.fetchFollowingAndBlockedAsync()
NotificationCenter.default.post(name: .relationshipChanged, object: nil, userInfo: [ NotificationCenter.default.post(name: .relationshipChanged, object: nil, userInfo: [
UserInfoKey.relationship: response 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 MastodonCore
import MastodonUI import MastodonUI
import MastodonSDK import MastodonSDK
import MastodonLocalization
extension DataSourceFacade { extension DataSourceFacade {
@MainActor @MainActor
static func responseToStatusReblogAction( static func responseToStatusReblogAction(
provider: DataSourceProvider & AuthContextProvider, provider: DataSourceProvider & AuthContextProvider,
status: MastodonStatus 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 { ) async throws {
let selectionFeedbackGenerator = UISelectionFeedbackGenerator() let selectionFeedbackGenerator = UISelectionFeedbackGenerator()
selectionFeedbackGenerator.selectionChanged() selectionFeedbackGenerator.selectionChanged()

View File

@ -295,29 +295,28 @@ extension DataSourceFacade {
transition: .activityViewControllerPresent(animated: true, completion: nil) transition: .activityViewControllerPresent(animated: true, completion: nil)
) )
case .deleteStatus: case .deleteStatus:
let alertController = UIAlertController( if UserDefaults.shared.askBeforeDeletingAPost {
title: L10n.Common.Alerts.DeletePost.title, let alertController = UIAlertController(
message: L10n.Common.Alerts.DeletePost.message, title: L10n.Common.Alerts.DeletePost.title,
preferredStyle: .alert message: L10n.Common.Alerts.DeletePost.message,
) preferredStyle: .alert
let confirmAction = UIAlertAction( )
title: L10n.Common.Controls.Actions.delete, let confirmAction = UIAlertAction(
style: .destructive title: L10n.Common.Controls.Actions.delete,
) { [weak dependency] _ in style: .destructive
guard let dependency = dependency else { return } ) { [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 } guard let status = menuContext.statusViewModel?.originalStatus else { return }
Task { performDeletion(of: status, with: dependency)
try await DataSourceFacade.responseToDeleteStatus(
dependency: dependency,
status: status
)
} // end Task
} }
alertController.addAction(confirmAction)
let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel)
alertController.addAction(cancelAction)
dependency.present(alertController, animated: true)
case .translateStatus: case .translateStatus:
guard let status = menuContext.statusViewModel?.originalStatus else { return } 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 { extension DataSourceFacade {
static func responseToUserViewButtonAction( static func responseToUserViewButtonAction(
dependency: NeedsDependency & AuthContextProvider, dependency: ViewControllerWithDependencies & AuthContextProvider,
account: Mastodon.Entity.Account, account: Mastodon.Entity.Account,
buttonState: UserView.ButtonState buttonState: UserView.ButtonState
) async throws { ) async throws {

View File

@ -195,6 +195,12 @@ extension ComposeViewController {
extension ComposeViewController { extension ComposeViewController {
private var mediaAttachmentViewModelsWithoutCaption: [AttachmentViewModel] {
get {
composeContentViewModel.attachmentViewModels.filter({ $0.caption.isEmpty })
}
}
@objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) {
guard composeContentViewModel.shouldDismiss else { guard composeContentViewModel.shouldDismiss else {
showDismissConfirmAlertController() showDismissConfirmAlertController()
@ -215,12 +221,30 @@ extension ComposeViewController {
return 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 { do {
let statusPublisher = try composeContentViewModel.statusPublisher() 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( viewModel.context.publisherService.enqueue(
statusPublisher: statusPublisher, statusPublisher: statusPublisher,
authContext: viewModel.authContext authContext: viewModel.authContext
@ -246,6 +270,28 @@ extension ComposeViewController {
return 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 { do {
guard let editStatusPublisher = try composeContentViewModel.statusEditPublisher() else { return } guard let editStatusPublisher = try composeContentViewModel.statusEditPublisher() else { return }
viewModel.context.publisherService.enqueue( viewModel.context.publisherService.enqueue(
@ -259,7 +305,6 @@ extension ComposeViewController {
} }
dismiss(animated: true, completion: nil) dismiss(animated: true, completion: nil)
} }
} }

View File

@ -2,18 +2,23 @@
import UIKit import UIKit
import MastodonAsset import MastodonAsset
import MastodonUI
class GeneralSettingSelectionCell: UITableViewCell { class GeneralSettingSelectionCell: UITableViewCell {
static let reuseIdentifier = "GeneralSettingSelectionCell" static let reuseIdentifier = "GeneralSettingSelectionCell"
func configure(with setting: GeneralSetting, viewModel: GeneralSettingsViewModel) { func configure(with setting: GeneralSetting, viewModel: GeneralSettingsViewModel) {
switch setting { switch setting {
case .appearance(let appearanceSetting): case let .appearance(appearanceSetting):
configureAppearanceSetting(appearanceSetting: appearanceSetting, viewModel: viewModel) configureAppearanceSetting(appearanceSetting: appearanceSetting, viewModel: viewModel)
case .design(_): case .askBefore:
assertionFailure("Not required here")
case .design:
// only for appearance and open links // only for appearance and open links
assertionFailure("Wrong Setting!") assertionFailure("Wrong Setting!")
case .openLinksIn(let openLinkSetting): case let .language(setting):
configureLanguageSetting(setting, viewModel: viewModel)
case let .openLinksIn(openLinkSetting):
configureOpenLinkSetting(openLinkSetting: openLinkSetting, viewModel: viewModel) configureOpenLinkSetting(openLinkSetting: openLinkSetting, viewModel: viewModel)
} }
} }
@ -45,5 +50,26 @@ class GeneralSettingSelectionCell: UITableViewCell {
contentConfiguration = content 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 self.setting = setting
switch setting { switch setting {
case .appearance(_), .openLinksIn(_): case .appearance, .openLinksIn, .language:
assertionFailure("Only for Design") 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): case .design(let designSetting):
label.text = designSetting.title label.text = designSetting.title

View File

@ -10,15 +10,21 @@ struct GeneralSettingsSection: Hashable {
enum GeneralSettingsSectionType: Hashable { enum GeneralSettingsSectionType: Hashable {
case appearance case appearance
case askBefore
case design case design
case language
case links case links
var sectionTitle: String { var sectionTitle: String {
switch self { switch self {
case .appearance: case .appearance:
return L10n.Scene.Settings.General.Appearance.sectionTitle return L10n.Scene.Settings.General.Appearance.sectionTitle
case .askBefore:
return L10n.Scene.Settings.General.AskBefore.sectionTitle
case .design: case .design:
return L10n.Scene.Settings.General.Design.sectionTitle return L10n.Scene.Settings.General.Design.sectionTitle
case .language:
return L10n.Scene.Settings.General.Language.sectionTitle
case .links: case .links:
return L10n.Scene.Settings.General.Links.sectionTitle return L10n.Scene.Settings.General.Links.sectionTitle
} }
@ -28,7 +34,9 @@ enum GeneralSettingsSectionType: Hashable {
enum GeneralSetting: Hashable { enum GeneralSetting: Hashable {
case appearance(Appearance) case appearance(Appearance)
case askBefore(AskBefore)
case design(Design) case design(Design)
case language(Language)
case openLinksIn(OpenLinksIn) case openLinksIn(OpenLinksIn)
enum Appearance: Int, CaseIterable { enum Appearance: Int, CaseIterable {
@ -51,6 +59,27 @@ enum GeneralSetting: Hashable {
.init(rawValue: rawValue) ?? .unspecified .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 { enum Design: Hashable {
case showAnimations 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 { enum OpenLinksIn: Hashable, CaseIterable {
case mastodon case mastodon

View File

@ -2,17 +2,25 @@
import UIKit import UIKit
import MastodonSDK import MastodonSDK
import MastodonCore
import CoreDataStack import CoreDataStack
import MastodonLocalization import MastodonLocalization
import MastodonUI
struct GeneralSettingsViewModel { struct GeneralSettingsViewModel {
var selectedAppearence: GeneralSetting.Appearance var selectedAppearence: GeneralSetting.Appearance
var playAnimations: Bool var playAnimations: Bool
var selectedOpenLinks: GeneralSetting.OpenLinksIn var selectedOpenLinks: GeneralSetting.OpenLinksIn
var askBeforePostingWithoutAltText: Bool
var askBeforeUnfollowingSomeone: Bool
var askBeforeBoostingAPost: Bool
var askBeforeDeletingAPost: Bool
var defaultPostLanguage: String
} }
protocol GeneralSettingsViewControllerDelegate: AnyObject { protocol GeneralSettingsViewControllerDelegate: AnyObject {
func save(_ viewController: UIViewController, setting: Setting, viewModel: GeneralSettingsViewModel) func save(_ viewController: UIViewController, setting: Setting, viewModel: GeneralSettingsViewModel)
func showLanguagePicker(_ viewModel: GeneralSettingsViewModel, onLanguageSelected: @escaping OnLanguageSelected)
} }
class GeneralSettingsViewController: UIViewController { class GeneralSettingsViewController: UIViewController {
@ -27,7 +35,7 @@ class GeneralSettingsViewController: UIViewController {
let sections: [GeneralSettingsSection] let sections: [GeneralSettingsSection]
init(setting: Setting) { init(appContext: AppContext, setting: Setting) {
tableView = UITableView(frame: .zero, style: .insetGrouped) tableView = UITableView(frame: .zero, style: .insetGrouped)
tableView.translatesAutoresizingMaskIntoConstraints = false tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.register(GeneralSettingSelectionCell.self, forCellReuseIdentifier: GeneralSettingSelectionCell.reuseIdentifier) tableView.register(GeneralSettingSelectionCell.self, forCellReuseIdentifier: GeneralSettingSelectionCell.reuseIdentifier)
@ -39,9 +47,18 @@ class GeneralSettingsViewController: UIViewController {
.appearance(.dark), .appearance(.dark),
.appearance(.system) .appearance(.system)
]), ]),
GeneralSettingsSection(type: .askBefore, entries: [
.askBefore(.postingWithoutAltText),
.askBefore(.unfollowingSomeone),
.askBefore(.boostingAPost),
.askBefore(.deletingAPost)
]),
GeneralSettingsSection(type: .design, entries: [ GeneralSettingsSection(type: .design, entries: [
.design(.showAnimations) .design(.showAnimations)
]), ]),
GeneralSettingsSection(type: .language, entries: [
.language(.defaultPostLanguage)
]),
GeneralSettingsSection(type: .links, entries: [ GeneralSettingsSection(type: .links, entries: [
.openLinksIn(.mastodon), .openLinksIn(.mastodon),
.openLinksIn(.browser), .openLinksIn(.browser),
@ -58,7 +75,12 @@ class GeneralSettingsViewController: UIViewController {
viewModel = GeneralSettingsViewModel( viewModel = GeneralSettingsViewModel(
selectedAppearence: GeneralSetting.Appearance(rawValue: UserDefaults.shared.customUserInterfaceStyle.rawValue) ?? .system, selectedAppearence: GeneralSetting.Appearance(rawValue: UserDefaults.shared.customUserInterfaceStyle.rawValue) ?? .system,
playAnimations: playAnimations, 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 self.setting = setting
@ -75,6 +97,12 @@ class GeneralSettingsViewController: UIViewController {
selectionCell.configure(with: .appearance(setting), viewModel: self.viewModel) selectionCell.configure(with: .appearance(setting), viewModel: self.viewModel)
cell = selectionCell 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): case .design(let setting):
guard let toggleCell = tableView.dequeueReusableCell(withIdentifier: GeneralSettingToggleTableViewCell.reuseIdentifier, for: indexPath) as? GeneralSettingToggleTableViewCell else { fatalError("WTF? Wrong Cell!") } 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 toggleCell.delegate = self
cell = toggleCell 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): case .openLinksIn(let setting):
guard let selectionCell = tableView.dequeueReusableCell(withIdentifier: GeneralSettingSelectionCell.reuseIdentifier, for: indexPath) as? GeneralSettingSelectionCell else { fatalError("WTF? Wrong Cell!") } 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] let section = sections[indexPath.section].entries[indexPath.row]
switch section { switch section {
case .appearance(let appearanceOption): case .appearance(let appearanceOption):
viewModel.selectedAppearence = 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() { if let snapshot = tableViewDataSource?.snapshot() {
tableViewDataSource?.applySnapshotUsingReloadData(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) if let snapshot = tableViewDataSource?.snapshot() {
cell.toggle.setOn(newValue, animated: true) tableViewDataSource?.applySnapshotUsingReloadData(snapshot)
}
toggle(cell, setting: .design(design), isOn: newValue)
case .openLinksIn(let openLinksInOption):
viewModel.selectedOpenLinks = openLinksInOption
if let snapshot = tableViewDataSource?.snapshot() {
tableViewDataSource?.applySnapshotUsingReloadData(snapshot)
}
} }
tableView.deselectRow(at: indexPath, animated: true) tableView.deselectRow(at: indexPath, animated: true)
@ -155,13 +205,24 @@ extension GeneralSettingsViewController: UITableViewDelegate {
extension GeneralSettingsViewController: GeneralSettingToggleTableViewCellDelegate { extension GeneralSettingsViewController: GeneralSettingToggleTableViewCellDelegate {
func toggle(_ cell: GeneralSettingToggleTableViewCell, setting: GeneralSetting, isOn: Bool) { func toggle(_ cell: GeneralSettingToggleTableViewCell, setting: GeneralSetting, isOn: Bool) {
switch setting { switch setting {
case .appearance(_), .openLinksIn(_): case .appearance, .openLinksIn, .language:
assertionFailure("No toggle") assertionFailure("No toggle")
case .design(let designSetting): case let .askBefore(askBefore):
switch designSetting { switch askBefore {
case .showAnimations: case .postingWithoutAltText:
viewModel.playAnimations = isOn 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) 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 MastodonSDK
import Combine import Combine
import MetaTextKit import MetaTextKit
import MastodonUI
protocol SettingsCoordinatorDelegate: AnyObject { protocol SettingsCoordinatorDelegate: AnyObject {
func logout(_ settingsCoordinator: SettingsCoordinator) func logout(_ settingsCoordinator: SettingsCoordinator)
@ -57,9 +58,10 @@ extension SettingsCoordinator: SettingsViewControllerDelegate {
func didSelect(_ viewController: UIViewController, entry: SettingsEntry) { func didSelect(_ viewController: UIViewController, entry: SettingsEntry) {
switch entry { switch entry {
case .general: case .general:
let generalSettingsViewController = GeneralSettingsViewController(setting: setting)
let generalSettingsViewController = GeneralSettingsViewController(appContext: appContext, setting: setting)
generalSettingsViewController.delegate = self generalSettingsViewController.delegate = self
navigationController.pushViewController(generalSettingsViewController, animated: true) navigationController.pushViewController(generalSettingsViewController, animated: true)
case .notifications: case .notifications:
@ -144,6 +146,11 @@ extension SettingsCoordinator: GeneralSettingsViewControllerDelegate {
UserDefaults.shared.preferredStaticAvatar = viewModel.playAnimations == false UserDefaults.shared.preferredStaticAvatar = viewModel.playAnimations == false
UserDefaults.shared.preferredUsingDefaultBrowser = viewModel.selectedOpenLinks == .browser 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 //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?) { func userView(_ view: UserView, didTapButtonWith state: UserView.ButtonState, for account: Mastodon.Entity.Account, me: Mastodon.Entity.Account?) {
Task { Task {
await MainActor.run { view.setButtonState(.loading) } await MainActor.run { view.setButtonState(.loading) }

View File

@ -110,7 +110,7 @@ final class SuggestionAccountViewModel: NSObject {
.store(in: &disposeBag) .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 } 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 { public enum AppError: Error {
case badRequest case badRequest
case badAuthentication 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.") 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 { public enum CleanCache {
/// Successfully cleaned %@ cache. /// Successfully cleaned %@ cache.
public static func message(_ p1: Any) -> String { public static func message(_ p1: Any) -> String {
@ -46,6 +58,19 @@ public enum L10n {
/// Edit Profile Error /// Edit Profile Error
public static let title = L10n.tr("Localizable", "Common.Alerts.EditProfileFailure.Title", fallback: "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 { public enum PublishPostFailure {
/// Failed to publish the post. /// Failed to publish the post.
/// Please check your internet connection. /// Please check your internet connection.
@ -89,6 +114,16 @@ public enum L10n {
/// Note /// Note
public static let title = L10n.tr("Localizable", "Common.Alerts.TranslationFailed.Title", fallback: "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 { public enum VoteFailure {
/// The poll has ended /// The poll has ended
public static let pollEnded = L10n.tr("Localizable", "Common.Alerts.VoteFailure.PollEnded", fallback: "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 /// Use Device Appearance
public static let system = L10n.tr("Localizable", "Scene.Settings.General.Appearance.System", fallback: "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 { public enum Design {
/// Design /// Design
public static let sectionTitle = L10n.tr("Localizable", "Scene.Settings.General.Design.SectionTitle", fallback: "Design") public static let sectionTitle = L10n.tr("Localizable", "Scene.Settings.General.Design.SectionTitle", fallback: "Design")
/// Play Animated Avatars and Emoji /// Play Animated Avatars and Emoji
public static let showAnimations = L10n.tr("Localizable", "Scene.Settings.General.Design.ShowAnimations", fallback: "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 { public enum Links {
/// Open in Browser /// Open in Browser
public static let openInBrowser = L10n.tr("Localizable", "Scene.Settings.General.Links.OpenInBrowser", fallback: "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.TranslationFailed.Title" = "Note";
"Common.Alerts.VoteFailure.PollEnded" = "The poll has ended"; "Common.Alerts.VoteFailure.PollEnded" = "The poll has ended";
"Common.Alerts.VoteFailure.Title" = "Vote Failure"; "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.Add" = "Add";
"Common.Controls.Actions.Back" = "Back"; "Common.Controls.Actions.Back" = "Back";
"Common.Controls.Actions.BlockDomain" = "Block %@"; "Common.Controls.Actions.BlockDomain" = "Block %@";
@ -503,8 +515,15 @@ uploaded to Mastodon.";
"Scene.Settings.General.Appearance.Light" = "Light"; "Scene.Settings.General.Appearance.Light" = "Light";
"Scene.Settings.General.Appearance.SectionTitle" = "Appearance"; "Scene.Settings.General.Appearance.SectionTitle" = "Appearance";
"Scene.Settings.General.Appearance.System" = "Use Device 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.SectionTitle" = "Design";
"Scene.Settings.General.Design.ShowAnimations" = "Play Animated Avatars and Emoji"; "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.OpenInBrowser" = "Open in Browser";
"Scene.Settings.General.Links.OpenInMastodon" = "Open in Mastodon"; "Scene.Settings.General.Links.OpenInMastodon" = "Open in Mastodon";
"Scene.Settings.General.Links.SectionTitle" = "Links"; "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.ConfigurationDescription" = "Show number of followers for multiple accounts.";
"Widget.MultipleFollowers.ConfigurationDisplayName" = "Multiple followers"; "Widget.MultipleFollowers.ConfigurationDisplayName" = "Multiple followers";
"Widget.MultipleFollowers.MockUser.AccountName" = "another@follower.social"; "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 authContext: AuthContext
public let input: Input public let input: Input
public let sizeLimit: SizeLimit public let sizeLimit: SizeLimit
@Published var caption = "" @Published public internal(set) var caption = ""
@Published public private(set) var isCaptionEditable = true @Published public private(set) var isCaptionEditable = true
let isEditing: Bool let isEditing: Bool

View File

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

View File

@ -32,7 +32,7 @@ extension ComposeContentToolbarView {
@Published var isAttachmentButtonEnabled = false @Published var isAttachmentButtonEnabled = false
@Published var isPollButtonEnabled = 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 var recentLanguages: [String] = []
@Published public var maxTextInputLimit = 500 @Published public var maxTextInputLimit = 500

View File

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

View File

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

View File

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