diff --git a/Localization/app.json b/Localization/app.json index 9d8ce91b4..b09fe959c 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -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", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b920f0fc8..7c53a0fba 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 = ""; }; 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = ""; }; 2A506CF5292D040100059C37 /* HashtagTimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderView.swift; sourceTree = ""; }; + 2A631AE72B8C9F6600FE0778 /* LanguagePickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguagePickerViewController.swift; sourceTree = ""; }; 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 = ""; }; @@ -1474,6 +1476,14 @@ path = FollowedTags; sourceTree = ""; }; + 2A631AE62B8C9F5900FE0778 /* Language */ = { + isa = PBXGroup; + children = ( + 2A631AE72B8C9F6600FE0778 /* LanguagePickerViewController.swift */, + ); + path = Language; + sourceTree = ""; + }; 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 */, diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift index b024cfa96..eebe23454 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift @@ -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() + } + } + } } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift index b10fb0e44..f927e8f0e 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Reblog.swift @@ -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() diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift index 63d52a515..00dedb0ad 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -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 + ) + } + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+UserView.swift b/Mastodon/Protocol/Provider/DataSourceFacade+UserView.swift index de70e6304..f9edfd57e 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+UserView.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+UserView.swift @@ -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 { diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 71c797a9c..926cdc4d5 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -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) - } } diff --git a/Mastodon/Scene/Settings/General Settings/GeneralSettingSelectionCell.swift b/Mastodon/Scene/Settings/General Settings/GeneralSettingSelectionCell.swift index 76b1a3838..39f4938d6 100644 --- a/Mastodon/Scene/Settings/General Settings/GeneralSettingSelectionCell.swift +++ b/Mastodon/Scene/Settings/General Settings/GeneralSettingSelectionCell.swift @@ -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 + } } diff --git a/Mastodon/Scene/Settings/General Settings/GeneralSettingToggleTableViewCell.swift b/Mastodon/Scene/Settings/General Settings/GeneralSettingToggleTableViewCell.swift index fd431b23b..5775fdcf5 100644 --- a/Mastodon/Scene/Settings/General Settings/GeneralSettingToggleTableViewCell.swift +++ b/Mastodon/Scene/Settings/General Settings/GeneralSettingToggleTableViewCell.swift @@ -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 diff --git a/Mastodon/Scene/Settings/General Settings/GeneralSettings.swift b/Mastodon/Scene/Settings/General Settings/GeneralSettings.swift index 5e0c4b466..868e32810 100644 --- a/Mastodon/Scene/Settings/General Settings/GeneralSettings.swift +++ b/Mastodon/Scene/Settings/General Settings/GeneralSettings.swift @@ -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 diff --git a/Mastodon/Scene/Settings/General Settings/GeneralSettingsViewController.swift b/Mastodon/Scene/Settings/General Settings/GeneralSettingsViewController.swift index db33b12b3..9586af09c 100644 --- a/Mastodon/Scene/Settings/General Settings/GeneralSettingsViewController.swift +++ b/Mastodon/Scene/Settings/General Settings/GeneralSettingsViewController.swift @@ -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) diff --git a/Mastodon/Scene/Settings/General Settings/Language/LanguagePickerViewController.swift b/Mastodon/Scene/Settings/General Settings/Language/LanguagePickerViewController.swift new file mode 100644 index 000000000..fcbece814 --- /dev/null +++ b/Mastodon/Scene/Settings/General Settings/Language/LanguagePickerViewController.swift @@ -0,0 +1,19 @@ +// Copyright © 2024 Mastodon gGmbH. All rights reserved. + +import MastodonLocalization +import MastodonUI +import SwiftUI + +class LanguagePickerViewController: UIHostingController { + 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") + } +} diff --git a/Mastodon/Scene/Settings/SettingsCoordinator.swift b/Mastodon/Scene/Settings/SettingsCoordinator.swift index 4be5f27d9..c465ca139 100644 --- a/Mastodon/Scene/Settings/SettingsCoordinator.swift +++ b/Mastodon/Scene/Settings/SettingsCoordinator.swift @@ -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 diff --git a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift index a0c4a9bd1..34fff4ef4 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell+ViewModel.swift @@ -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) } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index 389da8427..f2ebb3fb9 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -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 } diff --git a/MastodonSDK/Sources/MastodonCommon/Preference/Preference+AskBefore.swift b/MastodonSDK/Sources/MastodonCommon/Preference/Preference+AskBefore.swift new file mode 100644 index 000000000..1e5b9aeb2 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCommon/Preference/Preference+AskBefore.swift @@ -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 } + } + +} diff --git a/MastodonSDK/Sources/MastodonCommon/Preference/Preference+Language.swift b/MastodonSDK/Sources/MastodonCommon/Preference/Preference+Language.swift new file mode 100644 index 000000000..f9d3a13f7 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCommon/Preference/Preference+Language.swift @@ -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 } + } + +} diff --git a/MastodonSDK/Sources/MastodonCore/AppError.swift b/MastodonSDK/Sources/MastodonCore/AppError.swift index a8aea55b9..a46b4c719 100644 --- a/MastodonSDK/Sources/MastodonCore/AppError.swift +++ b/MastodonSDK/Sources/MastodonCore/AppError.swift @@ -10,4 +10,5 @@ import Foundation public enum AppError: Error { case badRequest case badAuthentication + case unexpected } diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index 71fbac7c4..b3872e8f3 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -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") diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index ed725a9d5..ee4bd817a 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -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"; \ No newline at end of file +"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower"; diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift index cf802e0b7..b277fa86a 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift index cf70443f5..a01cfa0cf 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift index e830d04d3..bb550185c 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift index a5af2c10f..324fcdebc 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/Language.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/Language.swift index c24b788f8..63a2eba4b 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/Language.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/Language.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/LanguagePicker.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/LanguagePicker.swift index 2f63a03d5..aa13e9ee5 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/LanguagePicker.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/LanguagePicker.swift @@ -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 }) } }