diff --git a/Localization/app.json b/Localization/app.json index 31d7b6ea6..df9f234ea 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -101,7 +101,8 @@ "translate_post": { "title": "Translate from %s", "unknown_language": "Unknown" - } + }, + "edit_post": "Edit" }, "tabs": { "home": "Home", @@ -193,7 +194,20 @@ "expand_image_hint": "Expands the image. Double-tap and hold to show actions", "expand_gif_hint": "Expands the GIF. Double-tap and hold to show actions", "expand_video_hint": "Shows the video player. Double-tap and hold to show actions" + }, + "posted_via_application": "%s via %s", + "buttons": { + "reblogs_title": "Reblogs", + "favorites_title": "Favorites", + "edit_history_title": "Edit History", + "edit_history_detail": "Last edit %s" + }, + "edited_at_timestamp_prefix": "Edited %s", + "edit_history": { + "title": "Edit History", + "original_post": "Original Post · %s" } + }, "friendship": { "follow": "Follow", @@ -437,7 +451,8 @@ "compose": { "title": { "new_post": "New Post", - "new_reply": "New Reply" + "new_reply": "New Reply", + "edit_post": "Edit Post" }, "media_selection": { "camera": "Take Photo", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index e92b8fe42..5eb6546a4 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 2A1FE47E2938C11200784BF1 /* Collection+IsNotEmpty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */; }; 2A33062D2987DBFA001D4C51 /* FollowersCountHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A33062C2987DBFA001D4C51 /* FollowersCountHistory.swift */; }; 2A33AB662982C4AF008A7FB1 /* FollowersCountWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A33AB652982C4AF008A7FB1 /* FollowersCountWidgetView.swift */; }; + 2A3D9B7E29A8F33A00F30313 /* StatusHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3D9B7D29A8F33A00F30313 /* StatusHistoryView.swift */; }; 2A3F6FE3292ECB5E002E6DA7 /* FollowedTagsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */; }; 2A3F6FE5292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */; }; 2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; }; @@ -134,6 +135,8 @@ 9E44C7202967AD17004B2A72 /* MastodonSDKDynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 9E44C71F2967AD17004B2A72 /* MastodonSDKDynamic */; }; 9E44C7222967AD17004B2A72 /* MastodonSDKDynamic in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 9E44C71F2967AD17004B2A72 /* MastodonSDKDynamic */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; C24C97032922F30500BAE8CB /* RefreshControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24C97022922F30500BAE8CB /* RefreshControl.swift */; }; + D808B94C296ECFDC0031EB1E /* StatusEditHistoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D808B94B296ECFDC0031EB1E /* StatusEditHistoryViewModel.swift */; }; + D808B94E296EFBBA0031EB1E /* StatusEditHistoryTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D808B94D296EFBBA0031EB1E /* StatusEditHistoryTableViewCell.swift */; }; D8099078294BC8A30050219F /* PrivacyTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8099077294BC8A30050219F /* PrivacyTableViewController.swift */; }; D809907A294BC9390050219F /* PrivacyTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8099079294BC9390050219F /* PrivacyTableViewCell.swift */; }; D809907C294D25510050219F /* PrivacyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D809907B294D25510050219F /* PrivacyViewModel.swift */; }; @@ -145,6 +148,8 @@ D8A6AB6C291C5136003AB663 /* MastodonLoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A6AB6B291C5136003AB663 /* MastodonLoginViewController.swift */; }; D8A6FE5B293244B500666A47 /* WelcomeContentPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A6FE5A293244B500666A47 /* WelcomeContentPage.swift */; }; D8A6FE5F29324BBC00666A47 /* WelcomeContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A6FE5E29324BBC00666A47 /* WelcomeContentCollectionViewCell.swift */; }; + D8E5C346296DAB84007E76A7 /* DataSourceFacade+Status+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */; }; + D8E5C349296DB8A3007E76A7 /* StatusEditHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */; }; DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = DB0009A926AEE5DC009B9D2D /* Intents.intentdefinition */; settings = {ATTRIBUTES = (codegen, ); }; }; DB0009A726AEE5DC009B9D2D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = DB0009A926AEE5DC009B9D2D /* Intents.intentdefinition */; }; DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; @@ -615,6 +620,7 @@ 2A33062C2987DBFA001D4C51 /* FollowersCountHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountHistory.swift; sourceTree = ""; }; 2A33625329759B4200481A90 /* OpenInActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenInActionExtension.entitlements; sourceTree = ""; }; 2A33AB652982C4AF008A7FB1 /* FollowersCountWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountWidgetView.swift; sourceTree = ""; }; + 2A3D9B7D29A8F33A00F30313 /* StatusHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusHistoryView.swift; sourceTree = ""; }; 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewModel.swift; sourceTree = ""; }; 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsTableViewCell.swift; sourceTree = ""; }; 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = ""; }; @@ -769,6 +775,8 @@ C3789232A52F43529CA67E95 /* Pods-MastodonIntent.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonIntent.asdk - debug.xcconfig"; path = "Target Support Files/Pods-MastodonIntent/Pods-MastodonIntent.asdk - debug.xcconfig"; sourceTree = ""; }; CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.debug.xcconfig"; sourceTree = ""; }; + D808B94B296ECFDC0031EB1E /* StatusEditHistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryViewModel.swift; sourceTree = ""; }; + D808B94D296EFBBA0031EB1E /* StatusEditHistoryTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryTableViewCell.swift; sourceTree = ""; }; D8099077294BC8A30050219F /* PrivacyTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyTableViewController.swift; sourceTree = ""; }; D8099079294BC9390050219F /* PrivacyTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyTableViewCell.swift; sourceTree = ""; }; D809907B294D25510050219F /* PrivacyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViewModel.swift; sourceTree = ""; }; @@ -786,6 +794,8 @@ D8A6FE6429325F5900666A47 /* StringsConvertor */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = StringsConvertor; sourceTree = ""; }; D8A6FE6529325F5900666A47 /* ios-infoPlist.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "ios-infoPlist.json"; sourceTree = ""; }; D8A6FE6629325F5900666A47 /* Localizable.stringsdict */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; path = Localizable.stringsdict; sourceTree = ""; }; + D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Status+History.swift"; sourceTree = ""; }; + D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryViewController.swift; sourceTree = ""; }; DB0009A826AEE5DC009B9D2D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Base.lproj/Intents.intentdefinition; sourceTree = ""; }; DB0009AD26AEE5E4009B9D2D /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Intents.strings; sourceTree = ""; }; DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; @@ -1824,6 +1834,17 @@ path = Localization; sourceTree = ""; }; + D8E5C347296DB896007E76A7 /* Edit History */ = { + isa = PBXGroup; + children = ( + D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */, + D808B94B296ECFDC0031EB1E /* StatusEditHistoryViewModel.swift */, + D808B94D296EFBBA0031EB1E /* StatusEditHistoryTableViewCell.swift */, + 2A3D9B7D29A8F33A00F30313 /* StatusHistoryView.swift */, + ); + path = "Edit History"; + sourceTree = ""; + }; DB01409B25C40BB600F9F3CF /* Onboarding */ = { isa = PBXGroup; children = ( @@ -2378,6 +2399,7 @@ DB603110279EB38500A935FE /* DataSourceFacade+Mute.swift */, DB603112279EBEBA00A935FE /* DataSourceFacade+Block.swift */, DB0FCB7327956939006C02E2 /* DataSourceFacade+Status.swift */, + D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */, DB63F778279ABF9C00455B82 /* DataSourceFacade+Reblog.swift */, DB63F77A279ACAE500455B82 /* DataSourceFacade+Favorite.swift */, DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */, @@ -2566,6 +2588,7 @@ DB938EEB2623F52600E5B6C1 /* Thread */ = { isa = PBXGroup; children = ( + D8E5C347296DB896007E76A7 /* Edit History */, DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */, DB0FCB75279571C5006C02E2 /* ThreadViewController+DataSourceProvider.swift */, DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */, @@ -3583,6 +3606,7 @@ DBDFF1902805543100557A48 /* DiscoveryPostsViewController.swift in Sources */, DB697DD9278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, + D808B94E296EFBBA0031EB1E /* StatusEditHistoryTableViewCell.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */, DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */, @@ -3619,6 +3643,7 @@ DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */, DB5B54A62833BE0000DEF8B2 /* UserListViewModel+State.swift in Sources */, DB0617ED277F02C50030EE79 /* OnboardingNavigationController.swift in Sources */, + D808B94C296ECFDC0031EB1E /* StatusEditHistoryViewModel.swift in Sources */, DB0617F527855AB90030EE79 /* ServerRuleSection.swift in Sources */, DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, @@ -3685,6 +3710,7 @@ 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */, DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */, + D8E5C349296DB8A3007E76A7 /* StatusEditHistoryViewController.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */, DB5B54A12833A89600DEF8B2 /* FamiliarFollowersViewController+DataSourceProvider.swift in Sources */, @@ -3795,11 +3821,13 @@ DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */, 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, DBEFCD7B282A162400C0ABEA /* ReportReasonView.swift in Sources */, + D8E5C346296DAB84007E76A7 /* DataSourceFacade+Status+History.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB63F77B279ACAE500455B82 /* DataSourceFacade+Favorite.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */, DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */, + 2A3D9B7E29A8F33A00F30313 /* StatusHistoryView.swift in Sources */, 6213AF5E2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift in Sources */, DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */, DBF156E22702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 64ef05778..33adf175e 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -157,9 +157,11 @@ extension SceneCoordinator { // compose case compose(viewModel: ComposeViewModel) + case editStatus(viewModel: ComposeViewModel) // thread case thread(viewModel: ThreadViewModel) + case editHistory(viewModel: StatusEditHistoryViewModel) // Hashtag Timeline case hashtagTimeline(viewModel: HashtagTimelineViewModel) @@ -273,7 +275,7 @@ extension SceneCoordinator { @MainActor @discardableResult - func present(scene: Scene, from sender: UIViewController?, transition: Transition) -> UIViewController? { + func present(scene: Scene, from sender: UIViewController? = nil, transition: Transition) -> UIViewController? { guard let viewController = get(scene: scene) else { return nil } @@ -430,13 +432,15 @@ private extension SceneCoordinator { _viewController.viewModel = viewModel viewController = _viewController case .compose(let viewModel): - let _viewController = ComposeViewController() - _viewController.viewModel = viewModel + let _viewController = ComposeViewController(viewModel: viewModel) viewController = _viewController case .thread(let viewModel): let _viewController = ThreadViewController() _viewController.viewModel = viewModel viewController = _viewController + case .editHistory(let viewModel): + let editHistoryViewController = StatusEditHistoryViewController(viewModel: viewModel) + viewController = editHistoryViewController case .hashtagTimeline(let viewModel): let _viewController = HashtagTimelineViewController() _viewController.viewModel = viewModel @@ -536,6 +540,9 @@ private extension SceneCoordinator { let _viewController = SettingsViewController() _viewController.viewModel = viewModel viewController = _viewController + case .editStatus(let viewModel): + let composeViewController = ComposeViewController(viewModel: viewModel) + viewController = composeViewController } setupDependency(for: viewController as? NeedsDependency) diff --git a/Mastodon/Diffable/Status/StatusSection.swift b/Mastodon/Diffable/Status/StatusSection.swift index ac02273e9..e134a17cb 100644 --- a/Mastodon/Diffable/Status/StatusSection.swift +++ b/Mastodon/Diffable/Status/StatusSection.swift @@ -167,6 +167,8 @@ extension StatusSection { let managedObjectContext = context.managedObjectContext statusView.pollTableViewDiffableDataSource = UITableViewDiffableDataSource(tableView: statusView.pollTableView) { tableView, indexPath, item in switch item { + case .history: + return nil case .option(let record): // Fix cell reuse animation issue let cell: PollOptionTableViewCell = { @@ -188,9 +190,11 @@ extension StatusSection { // trigger update if needs let needsUpdatePoll: Bool = { // check first option in poll to trigger update poll only once - guard option.index == 0 else { return false } + guard + let poll = option.poll, + option.index == 0 + else { return false } - let poll = option.poll guard !poll.expired else { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): poll expired. Skip update poll \(poll.id)") return false @@ -213,7 +217,8 @@ extension StatusSection { }() if needsUpdatePoll { - let pollRecord: ManagedObjectRecord = .init(objectID: option.poll.objectID) + guard let poll = option.poll else { return } + let pollRecord: ManagedObjectRecord = .init(objectID: poll.objectID) Task { [weak context] in guard let context = context else { return } _ = try await context.apiService.poll( @@ -232,6 +237,33 @@ extension StatusSection { } } +extension StatusSection { + + public static func setupStatusPollHistoryDataSource( + context: AppContext, + authContext: AuthContext, + statusView: StatusView + ) { + statusView.pollTableViewDiffableDataSource = UITableViewDiffableDataSource(tableView: statusView.pollTableView) { tableView, indexPath, item in + switch item { + case .option: + return nil + case let .history(option): + // Fix cell reuse animation issue + let cell: PollOptionTableViewCell = { + let _cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PollOptionTableViewCell.self) + "@\(indexPath.row)#\(indexPath.section)") as? PollOptionTableViewCell + _cell?.prepareForReuse() + return _cell ?? PollOptionTableViewCell() + }() + + cell.pollOptionView.configure(historyPollOption: option) + + return cell + } + } + } +} + extension StatusSection { static func configure( diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status+History.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status+History.swift new file mode 100644 index 000000000..be48d726c --- /dev/null +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status+History.swift @@ -0,0 +1,17 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import MastodonCore +import MastodonSDK +import CoreDataStack + +extension DataSourceFacade { + public static func getEditHistory( + forStatus status: Status, + provider: NeedsDependency & AuthContextProvider + ) async throws -> [Mastodon.Entity.StatusEdit] { + let reponse = try await provider.context.apiService.getHistory(forStatusID: status.id, authenticationBox: provider.authContext.mastodonAuthenticationBox) + + return reponse.value + } +} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift index 5872fe5b5..ad8c44105 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -117,6 +117,7 @@ extension DataSourceFacade { let composeViewModel = ComposeViewModel( context: provider.context, authContext: provider.authContext, + composeContext: .composeStatus, destination: .reply(parent: status) ) _ = provider.coordinator.present( @@ -373,6 +374,21 @@ extension DataSourceFacade { alertController.addAction(UIAlertAction(title: L10n.Common.Alerts.TranslationFailed.button, style: .default)) dependency.present(alertController, animated: true) } + case .editStatus: + + guard let status = menuContext.status?.object(in: dependency.context.managedObjectContext) else { return } + + let statusSource = try await dependency.context.apiService.getStatusSource( + forStatusID: status.id, + authenticationBox: dependency.authContext.mastodonAuthenticationBox + ).value + + let editStatusViewModel = ComposeViewModel( + context: dependency.coordinator.appContext, + authContext: dependency.authContext, + composeContext: .editStatus(status: status, statusSource: statusSource), + destination: .topLevel) + _ = dependency.coordinator.present(scene: .editStatus(viewModel: editStatusViewModel), transition: .modal(animated: true)) } } // end func } diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift index 833389638..6b71d7b9c 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift @@ -233,6 +233,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte scene: .compose(viewModel: ComposeViewModel( context: self.context, authContext: self.authContext, + composeContext: .composeStatus, destination: .topLevel, initialContent: L10n.Common.Controls.Status.linkViaUser(url.absoluteString, "@" + (statusView.viewModel.authorUsername ?? "")) )), @@ -318,7 +319,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte try await managedObjectContext.performChanges { guard let pollOption = pollOption.object(in: managedObjectContext) else { return } - let poll = pollOption.poll + guard let poll = pollOption.poll else { return } _poll = .init(objectID: poll.objectID) _isMultiple = poll.multiple @@ -357,8 +358,10 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte // restore voting state try await managedObjectContext.performChanges { - guard let pollOption = pollOption.object(in: managedObjectContext) else { return } - let poll = pollOption.poll + guard + let pollOption = pollOption.object(in: managedObjectContext), + let poll = pollOption.poll + else { return } poll.update(isVoting: false) } } @@ -652,6 +655,24 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte ) } // end Task } + + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, statusMetricView: StatusMetricView, showEditHistory button: UIButton) { + Task { + + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await self.item(from: source), + case let .status(status) = item else { + assertionFailure("only works for status data provider") + return + } + + guard let status = status.object(in: context.managedObjectContext), + let edits = status.editHistory?.sorted(by: { $0.createdAt > $1.createdAt }) else { return } + + let viewModel = StatusEditHistoryViewModel(status: status, edits: edits, appContext: context, authContext: authContext) + _ = await coordinator.present(scene: .editHistory(viewModel: viewModel), from: self, transition: .show) + } + } } // MARK: a11y diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift index 0e0a24c9b..b8cf026d4 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewControllerNavigateable.swift @@ -100,6 +100,7 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid let composeViewModel = ComposeViewModel( context: self.context, authContext: authContext, + composeContext: .composeStatus, destination: .reply(parent: status) ) _ = self.coordinator.present( diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index fa5a1ef37..23cf1768b 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -19,7 +19,6 @@ import MastodonLocalization import MastodonSDK final class ComposeViewController: UIViewController, NeedsDependency { - static let minAutoCompleteVisibleHeight: CGFloat = 100 weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } @@ -29,13 +28,34 @@ final class ComposeViewController: UIViewController, NeedsDependency { var viewModel: ComposeViewModel! let logger = Logger(subsystem: "ComposeViewController", category: "logic") - + + init(viewModel: ComposeViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + lazy var composeContentViewModel: ComposeContentViewModel = { + + let composeContext: ComposeContentViewModel.ComposeContext + let initialContent: String + + switch viewModel.composeContext { + case .composeStatus: + composeContext = .composeStatus + initialContent = viewModel.initialContent + case .editStatus(let status, let statusSource): + composeContext = .editStatus(status: status, statusSource: statusSource) + initialContent = statusSource.text + } + return ComposeContentViewModel( context: context, authContext: viewModel.authContext, + composeContext: composeContext, destination: viewModel.destination, - initialContent: viewModel.initialContent + initialContent: initialContent ) }() private(set) lazy var composeContentViewController: ComposeContentViewController = { @@ -46,16 +66,38 @@ final class ComposeViewController: UIViewController, NeedsDependency { private(set) lazy var cancelBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) - let publishButton: UIButton = { + private lazy var publishButton: UIButton = { let button = RoundedEdgesButton(type: .custom) button.cornerRadius = 10 button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) + button.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) return button }() + + private lazy var saveButton: UIButton = { + let button = RoundedEdgesButton(type: .custom) + button.cornerRadius = 10 + button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) + button.setTitle(L10n.Common.Controls.Actions.save, for: .normal) + button.addTarget(self, action: #selector(ComposeViewController.publishStatusEdit(_:)), for: .touchUpInside) + return button + }() + + private(set) lazy var saveBarButtonItem: UIBarButtonItem = { + configurePublishButtonApperance(button: saveButton) + let shadowBackgroundContainer = ShadowBackgroundContainer() + saveButton.translatesAutoresizingMaskIntoConstraints = false + shadowBackgroundContainer.addSubview(saveButton) + saveButton.pinToParent() + let barButtonItem = UIBarButtonItem(customView: shadowBackgroundContainer) + return barButtonItem + }() + private(set) lazy var publishBarButtonItem: UIBarButtonItem = { - configurePublishButtonApperance() + configurePublishButtonApperance(button: publishButton) let shadowBackgroundContainer = ShadowBackgroundContainer() publishButton.translatesAutoresizingMaskIntoConstraints = false shadowBackgroundContainer.addSubview(publishButton) @@ -63,12 +105,13 @@ final class ComposeViewController: UIViewController, NeedsDependency { let barButtonItem = UIBarButtonItem(customView: shadowBackgroundContainer) return barButtonItem }() - private func configurePublishButtonApperance() { - publishButton.adjustsImageWhenHighlighted = false - publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color), for: .normal) - publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color.withAlphaComponent(0.5)), for: .highlighted) - publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) - publishButton.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal) + + private func configurePublishButtonApperance(button: UIButton) { + button.adjustsImageWhenHighlighted = false + button.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color), for: .normal) + button.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color.withAlphaComponent(0.5)), for: .highlighted) + button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) + button.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal) } deinit { @@ -83,18 +126,17 @@ extension ComposeViewController { super.viewDidLoad() navigationItem.leftBarButtonItem = cancelBarButtonItem - navigationItem.rightBarButtonItem = publishBarButtonItem viewModel.traitCollectionDidChangePublisher .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } guard self.traitCollection.userInterfaceIdiom == .pad else { return } - let items = [self.publishBarButtonItem] - self.navigationItem.rightBarButtonItems = items + self.navigationItem.rightBarButtonItem = self.rightBarButtonItemForCurrentContext } .store(in: &disposeBag) - publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) - + + navigationItem.rightBarButtonItem = rightBarButtonItemForCurrentContext + addChild(composeContentViewController) composeContentViewController.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(composeContentViewController.view) @@ -119,8 +161,14 @@ extension ComposeViewController { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - - configurePublishButtonApperance() + + switch viewModel.composeContext { + case .composeStatus: + configurePublishButtonApperance(button: publishButton) + case .editStatus: + configurePublishButtonApperance(button: saveButton) + } + viewModel.traitCollectionDidChangePublisher.send() } @@ -141,6 +189,14 @@ extension ComposeViewController { present(alertController, animated: true, completion: nil) } + private var rightBarButtonItemForCurrentContext: UIBarButtonItem { + switch viewModel.composeContext { + case .composeStatus: + return publishBarButtonItem + case .editStatus: + return saveBarButtonItem + } + } } extension ComposeViewController { @@ -155,8 +211,7 @@ extension ComposeViewController { } @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + do { try composeContentViewModel.checkAttachmentPrecondition() } catch { @@ -185,7 +240,34 @@ extension ComposeViewController { dismiss(animated: true, completion: nil) } - + + @objc + private func publishStatusEdit(_ sender: Any) { + do { + try composeContentViewModel.checkAttachmentPrecondition() + } catch { + let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) + alertController.addAction(okAction) + _ = coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil)) + return + } + + do { + guard let editStatusPublisher = try composeContentViewModel.statusEditPublisher() else { return } + viewModel.context.publisherService.enqueue( + statusPublisher: editStatusPublisher, + authContext: viewModel.authContext + ) + } catch { + let alertController = UIAlertController.standardAlert(of: error) + present(alertController, animated: true) + return + } + + dismiss(animated: true, completion: nil) + + } } extension ComposeViewController { diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 0dcdd9a2d..8992ac7a6 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -19,7 +19,12 @@ import MastodonMeta import MastodonUI final class ComposeViewModel { - + + enum Context { + case composeStatus + case editStatus(status: Status, statusSource: Mastodon.Entity.StatusSource) + } + let logger = Logger(subsystem: "ComposeViewModel", category: "ViewModel") var disposeBag = Set() @@ -29,6 +34,7 @@ final class ComposeViewModel { // input let context: AppContext let authContext: AuthContext + let composeContext: Context let destination: ComposeContentViewModel.Destination let initialContent: String @@ -42,6 +48,7 @@ final class ComposeViewModel { init( context: AppContext, authContext: AuthContext, + composeContext: ComposeViewModel.Context, destination: ComposeContentViewModel.Destination, initialContent: String = "" ) { @@ -49,18 +56,23 @@ final class ComposeViewModel { self.authContext = authContext self.destination = destination self.initialContent = initialContent + self.composeContext = composeContext // end init - self.title = { + let title: String + + switch composeContext { + case .composeStatus: switch destination { - case .topLevel: return L10n.Scene.Compose.Title.newPost - case .reply: return L10n.Scene.Compose.Title.newReply + case .topLevel: + title = L10n.Scene.Compose.Title.newPost + case .reply: + title = L10n.Scene.Compose.Title.newReply } - }() + case .editStatus(_, _): + title = L10n.Scene.Compose.Title.editPost + } + + self.title = title } - - deinit { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 75d9029e2..7e7feb3bc 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -211,6 +211,7 @@ extension HashtagTimelineViewController { let composeViewModel = ComposeViewModel( context: context, authContext: viewModel.authContext, + composeContext: .composeStatus, destination: .topLevel, initialContent: hashtag ) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 7ecbe18b3..7e22c346f 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -200,6 +200,12 @@ extension HomeTimelineViewController { } .store(in: &disposeBag) + context.publisherService.statusPublishResult.sink { result in + if case .success(.edit) = result { + self.viewModel.hasPendingStatusEditReload = true + } + }.store(in: &disposeBag) + context.publisherService.$currentPublishProgress .receive(on: DispatchQueue.main) .sink { [weak self] progress in diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index 3d59b3403..24cf2258a 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -91,7 +91,7 @@ extension HomeTimelineViewModel { } let hasChanges = newSnapshot.itemIdentifiers != oldSnapshot.itemIdentifiers - if !hasChanges { + if !hasChanges && !self.hasPendingStatusEditReload { self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): snapshot not changes") self.didLoadLatest.send() return @@ -117,6 +117,7 @@ extension HomeTimelineViewModel { tableView.setContentOffset(contentOffset, animated: false) self.didLoadLatest.send() self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): applied new snapshot") + self.hasPendingStatusEditReload = false } // end Task } .store(in: &disposeBag) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 73ee99174..7319409e5 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -35,6 +35,7 @@ final class HomeTimelineViewModel: NSObject { @Published var lastAutomaticFetchTimestamp: Date? = nil @Published var scrollPositionRecord: ScrollPositionRecord? = nil @Published var displaySettingBarButtonItem = true + @Published var hasPendingStatusEditReload = false weak var tableView: UITableView? weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index fa10bed9b..6ebeec898 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -558,6 +558,7 @@ extension ProfileViewController { let composeViewModel = ComposeViewModel( context: context, authContext: viewModel.authContext, + composeContext: .composeStatus, destination: .topLevel, initialContent: mention ) diff --git a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift index e09a0efbd..a205320c8 100644 --- a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift @@ -386,9 +386,10 @@ extension MainTabBarController { let composeViewModel = ComposeViewModel( context: context, authContext: authContext, + composeContext: .composeStatus, destination: .topLevel ) - _ = coordinator.present(scene: .compose(viewModel: composeViewModel), from: nil, transition: .modal(animated: true, completion: nil)) + _ = coordinator.present(scene: .compose(viewModel: composeViewModel), transition: .modal(animated: true, completion: nil)) } private func touchedTab(by sender: UIGestureRecognizer) -> Tab? { @@ -815,6 +816,7 @@ extension MainTabBarController { let composeViewModel = ComposeViewModel( context: context, authContext: authContext, + composeContext: .composeStatus, destination: .topLevel ) _ = coordinator.present(scene: .compose(viewModel: composeViewModel), from: nil, transition: .modal(animated: true, completion: nil)) diff --git a/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift b/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift index 0ffcd4c8b..2d5a80200 100644 --- a/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift +++ b/Mastodon/Scene/Root/Sidebar/SidebarViewController.swift @@ -227,6 +227,7 @@ extension SidebarViewController: UICollectionViewDelegate { let composeViewModel = ComposeViewModel( context: context, authContext: authContext, + composeContext: .composeStatus, destination: .topLevel ) _ = coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) diff --git a/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift b/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift index cbc7c61f8..7f4192df5 100644 --- a/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/PollOptionView+Configuration.swift @@ -14,6 +14,11 @@ import MastodonUI extension PollOptionView { public func configure(pollOption option: PollOption) { + guard let poll = option.poll, let status = poll.status else { + assertionFailure("PollOption to be configured is expected to be part of Poll with Status") + return + } + viewModel.objects.insert(option) // background @@ -33,7 +38,7 @@ extension PollOptionView { .store(in: &disposeBag) // percentage Publishers.CombineLatest( - option.poll.publisher(for: \.votersCount), + poll.publisher(for: \.votersCount), option.publisher(for: \.votesCount) ) .map { pollVotersCount, optionVotesCount -> Double? in @@ -43,15 +48,15 @@ extension PollOptionView { .assign(to: \.percentage, on: viewModel) .store(in: &disposeBag) // $isExpire - option.poll.publisher(for: \.expired) + poll.publisher(for: \.expired) .assign(to: \.isExpire, on: viewModel) .store(in: &disposeBag) // isMultiple - viewModel.isMultiple = option.poll.multiple + viewModel.isMultiple = poll.multiple let optionIndex = option.index - let authorDomain = option.poll.status.author.domain - let authorID = option.poll.status.author.id + let authorDomain = status.author.domain + let authorID = status.author.id // isSelect, isPollVoted, isMyPoll Publishers.CombineLatest4( option.publisher(for: \.poll), @@ -60,7 +65,7 @@ extension PollOptionView { viewModel.$authContext ) .sink { [weak self] poll, optionVotedBy, isSelected, authContext in - guard let self = self else { return } + guard let self = self, let poll = poll else { return } let domain = authContext?.mastodonAuthenticationBox.domain ?? "" let userID = authContext?.mastodonAuthenticationBox.userID ?? "" @@ -109,3 +114,30 @@ extension PollOptionView { .store(in: &disposeBag) } } + +extension PollOptionView { + public func configure(historyPollOption option: StatusEdit.Poll.Option) { + // background + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.viewModel.roundedBackgroundViewColor = theme.systemElevatedBackgroundColor + } + .store(in: &disposeBag) + // metaContent + viewModel.metaContent = PlaintextMetaContent(string: option.title) + // show left-hand-side dots, otherwise view looks "incomplete" + viewModel.selectState = .off + // appearance + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.checkmarkBackgroundView.backgroundColor = UIColor(dynamicProvider: { trailtCollection in + return trailtCollection.userInterfaceStyle == .light ? .white : theme.tableViewCellSelectionBackgroundColor + }) + } + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift index f21e0573c..17ff907df 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift @@ -37,6 +37,7 @@ protocol StatusTableViewCellDelegate: AnyObject, AutoGenerateProtocolDelegate { func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, statusMetricView: StatusMetricView, reblogButtonDidPressed button: UIButton) func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton) + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, statusMetricView: StatusMetricView, showEditHistory button: UIButton) func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, cardControl: StatusCardControl, didTapURL url: URL) func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, cardControlMenu: StatusCardControl) -> UIMenu? func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, accessibilityActivate: Void) @@ -104,6 +105,10 @@ extension StatusViewDelegate where Self: StatusViewContainerTableViewCell { delegate?.tableViewCell(self, statusView: statusView, statusMetricView: statusMetricView, favoriteButtonDidPressed: button) } + func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, showEditHistory button: UIButton) { + delegate?.tableViewCell(self, statusView: statusView, statusMetricView: statusMetricView, showEditHistory: button) + } + func statusView(_ statusView: StatusView, cardControl: StatusCardControl, didTapURL url: URL) { delegate?.tableViewCell(self, statusView: statusView, cardControl: cardControl, didTapURL: url) } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift index 6de60c66b..c8a1fa4ce 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusThreadRootTableViewCell.swift @@ -112,8 +112,8 @@ extension StatusThreadRootTableViewCell { statusView.mediaGridContainerView, statusView.pollTableView, statusView.pollStatusStackView, - statusView.actionToolbarContainer - // statusMetricView is intentionally excluded + statusView.actionToolbarContainer, + statusView.statusMetricView, ] if statusView.viewModel.isContentReveal { diff --git a/Mastodon/Scene/Thread/Edit History/StatusEditHistoryTableViewCell.swift b/Mastodon/Scene/Thread/Edit History/StatusEditHistoryTableViewCell.swift new file mode 100644 index 000000000..06520462d --- /dev/null +++ b/Mastodon/Scene/Thread/Edit History/StatusEditHistoryTableViewCell.swift @@ -0,0 +1,103 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import UIKit +import MastodonUI +import MastodonSDK +import CoreDataStack +import MastodonAsset + +class StatusEditHistoryTableViewCell: UITableViewCell { + var containerViewLeadingLayoutConstraint: NSLayoutConstraint! + var containerViewTrailingLayoutConstraint: NSLayoutConstraint! + + static let identifier = "StatusEditHistoryTableViewCell" + static let verticalMargin: CGFloat = 12 + static let horizontalMargin: CGFloat = 16 + + let dateLabel: UILabel + let statusHistoryView: StatusHistoryView + private let grayBackground: UIView + var statusViewBottomConstraint: NSLayoutConstraint? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + dateLabel = UILabel() + dateLabel.translatesAutoresizingMaskIntoConstraints = false + dateLabel.textColor = Asset.Colors.Label.secondary.color + dateLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + + statusHistoryView = StatusHistoryView() + statusHistoryView.translatesAutoresizingMaskIntoConstraints = false + + grayBackground = UIView() + grayBackground.translatesAutoresizingMaskIntoConstraints = false + grayBackground.backgroundColor = Asset.Scene.EditHistory.statusBackground.color + grayBackground.layer.borderWidth = 1 + grayBackground.layer.borderColor = Asset.Scene.EditHistory.statusBackgroundBorder.color.cgColor + grayBackground.applyCornerRadius(radius: 8) + + super.init(style: style, reuseIdentifier: reuseIdentifier) + + isAccessibilityElement = true + + selectionStyle = .none + grayBackground.addSubview(statusHistoryView) + contentView.addSubview(dateLabel) + contentView.addSubview(grayBackground) + + setupContainerViewMarginConstraints() + setupConstraints() + updateContainerViewMarginConstraints() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func setupConstraints() { + statusViewBottomConstraint = statusHistoryView.bottomAnchor.constraint(equalTo: grayBackground.bottomAnchor, constant: -Self.verticalMargin) + let constraints = [ + dateLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + dateLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + dateLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + + grayBackground.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: Self.verticalMargin), + grayBackground.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + grayBackground.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + grayBackground.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Self.verticalMargin), + + statusHistoryView.topAnchor.constraint(equalTo: grayBackground.topAnchor, constant: Self.verticalMargin), + statusHistoryView.leadingAnchor.constraint(equalTo: grayBackground.leadingAnchor), + statusHistoryView.trailingAnchor.constraint(equalTo: grayBackground.trailingAnchor), + statusViewBottomConstraint, + ].compactMap { $0 } + + NSLayoutConstraint.activate(constraints) + } + + func configure(status: Status, statusEdit: StatusEdit, dateText: String) { + dateLabel.text = dateText + statusHistoryView.statusView.configure(status: status, statusEdit: statusEdit) + } + + override func prepareForReuse() { + statusHistoryView.prepareForReuse() + super.prepareForReuse() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + updateContainerViewMarginConstraints() + } + + override var accessibilityLabel: String? { + get { + (dateLabel.text ?? "") + ", " + (statusHistoryView.statusView.accessibilityLabel ?? "") + } + set {} + } +} + +// MARK: - AdaptiveContainerMarginTableViewCell +extension StatusEditHistoryTableViewCell: AdaptiveContainerMarginTableViewCell { + var containerView: StatusHistoryView { + statusHistoryView + } +} diff --git a/Mastodon/Scene/Thread/Edit History/StatusEditHistoryViewController.swift b/Mastodon/Scene/Thread/Edit History/StatusEditHistoryViewController.swift new file mode 100644 index 000000000..c58ebc8d6 --- /dev/null +++ b/Mastodon/Scene/Thread/Edit History/StatusEditHistoryViewController.swift @@ -0,0 +1,80 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import UIKit +import MastodonSDK +import CoreDataStack +import MastodonCore +import MastodonLocalization + +class StatusEditHistoryViewController: UIViewController { + + private let tableView: UITableView + + var tableViewDataSource: UITableViewDiffableDataSource? + var viewModel: StatusEditHistoryViewModel + private let dateFormatter: DateFormatter + + init(viewModel: StatusEditHistoryViewModel) { + + self.viewModel = viewModel + dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .short + + tableView = UITableView(frame: .zero) + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.separatorStyle = .none + tableView.register(StatusEditHistoryTableViewCell.self, forCellReuseIdentifier: StatusEditHistoryTableViewCell.identifier) + + super.init(nibName: nil, bundle: nil) + + let tableViewDataSource = UITableViewDiffableDataSource(tableView: tableView) {tableView, indexPath, itemIdentifier in + guard let cell = tableView.dequeueReusableCell(withIdentifier: StatusEditHistoryTableViewCell.identifier, for: indexPath) as? StatusEditHistoryTableViewCell else { + fatalError("Wrong cell") + } + + let statusEdit = viewModel.edits[indexPath.row] + let dateText: String + + if statusEdit == viewModel.edits.last { + dateText = L10n.Common.Controls.Status.EditHistory.originalPost(self.dateFormatter.string(from: statusEdit.createdAt)) + } else { + dateText = self.dateFormatter.string(from: statusEdit.createdAt) + } + + viewModel.prepareCell(cell, in: tableView) + cell.configure(status: viewModel.status, statusEdit: statusEdit, dateText: dateText) + + return cell + } + + tableView.dataSource = tableViewDataSource + tableView.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor + self.tableViewDataSource = tableViewDataSource + + + view.addSubview(tableView) + + view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor + setupConstraints() + + title = L10n.Common.Controls.Status.EditHistory.title + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func setupConstraints() { + let constraints = tableView.pinTo(to: view) + NSLayoutConstraint.activate(constraints) + } + + override func viewDidLoad() { + super.viewDidLoad() + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(viewModel.edits) + + tableViewDataSource?.apply(snapshot) + } +} diff --git a/Mastodon/Scene/Thread/Edit History/StatusEditHistoryViewModel.swift b/Mastodon/Scene/Thread/Edit History/StatusEditHistoryViewModel.swift new file mode 100644 index 000000000..a5d778bec --- /dev/null +++ b/Mastodon/Scene/Thread/Edit History/StatusEditHistoryViewModel.swift @@ -0,0 +1,26 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import CoreDataStack +import MastodonCore +import MastodonUI +import UIKit + +struct StatusEditHistoryViewModel { + let status: Status + let edits: [StatusEdit] + + let appContext: AppContext + let authContext: AuthContext + + func prepareCell(_ cell: StatusEditHistoryTableViewCell, in tableView: UITableView) { + StatusSection.setupStatusPollHistoryDataSource( + context: appContext, + authContext: authContext, + statusView: cell.statusHistoryView.statusView + ) + + cell.statusHistoryView.statusView.frame.size.width = tableView.frame.width - cell.containerViewHorizontalMargin + cell.statusViewBottomConstraint?.constant = cell.statusHistoryView.statusView.mediaContainerView.isHidden ? -StatusEditHistoryTableViewCell.verticalMargin : 0 + } +} diff --git a/Mastodon/Scene/Thread/Edit History/StatusHistoryView.swift b/Mastodon/Scene/Thread/Edit History/StatusHistoryView.swift new file mode 100644 index 000000000..f04b760c3 --- /dev/null +++ b/Mastodon/Scene/Thread/Edit History/StatusHistoryView.swift @@ -0,0 +1,44 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import UIKit +import MastodonUI + +class StatusHistoryView: UIView { + let statusView = StatusView() + + private var statusViewLeadingConstraint: NSLayoutConstraint! + private var statusViewTrailingConstraint: NSLayoutConstraint! + + init() { + super.init(frame: .zero) + statusView.translatesAutoresizingMaskIntoConstraints = false + statusView.setup(style: .editHistory) + addSubview(statusView) + + statusViewLeadingConstraint = statusView.leadingAnchor.constraint(equalTo: leadingAnchor) + statusViewTrailingConstraint = statusView.trailingAnchor.constraint(equalTo: trailingAnchor) + + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: topAnchor), + statusView.bottomAnchor.constraint(equalTo: bottomAnchor), + statusViewLeadingConstraint, + statusViewTrailingConstraint + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func prepareForReuse() { + statusView.prepareForReuse() + } +} + +extension StatusHistoryView: AdaptiveContainerView { + func updateContainerViewComponentsLayoutMarginsRelativeArrangementBehavior(isEnabled: Bool) { + statusView.updateContainerViewComponentsLayoutMarginsRelativeArrangementBehavior(isEnabled: isEnabled) + statusViewLeadingConstraint.constant = isEnabled ? 0 : StatusEditHistoryTableViewCell.horizontalMargin + statusViewTrailingConstraint.constant = isEnabled ? 0 : -StatusEditHistoryTableViewCell.horizontalMargin + } +} diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index 1b386aa6a..c18e41bca 100644 --- a/Mastodon/Scene/Thread/ThreadViewController.swift +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -117,6 +117,7 @@ extension ThreadViewController { let composeViewModel = ComposeViewModel( context: context, authContext: viewModel.authContext, + composeContext: .composeStatus, destination: .reply(parent: threadContext.status) ) _ = coordinator.present( diff --git a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift index 850c6bab2..d1814436f 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift @@ -115,7 +115,7 @@ extension ThreadViewModel { } let hasChanges = newSnapshot.itemIdentifiers != oldSnapshot.itemIdentifiers - if !hasChanges { + if !hasChanges && !self.hasPendingStatusEditReload { self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): snapshot not changes") return } else { @@ -140,6 +140,7 @@ extension ThreadViewModel { newSnapshot: newSnapshot, difference: difference ) + self.hasPendingStatusEditReload = false } // end Task } .store(in: &disposeBag) diff --git a/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift index 87175cf55..c11da082c 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift @@ -66,7 +66,7 @@ extension ThreadViewModel.LoadThreadState { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let viewModel, let stateMachine else { return } guard let threadContext = viewModel.threadContext else { stateMachine.enter(Fail.self) @@ -79,11 +79,14 @@ extension ThreadViewModel.LoadThreadState { statusID: threadContext.statusID, authenticationBox: viewModel.authContext.mastodonAuthenticationBox ) - + await enter(state: NoMore.self) // assert(!Thread.isMainThread) // await Task.sleep(1_000_000_000) // 1s delay to prevent UI render issue + + let statusHistory = try await viewModel.context.apiService.getHistory(forStatusID: threadContext.statusID, + authenticationBox: viewModel.authContext.mastodonAuthenticationBox) viewModel.mastodonStatusThreadViewModel.appendAncestor( domain: threadContext.domain, diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift index c845ce64c..74afa44dd 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -33,6 +33,7 @@ class ThreadViewModel { var diffableDataSource: UITableViewDiffableDataSource? @Published var root: StatusItem.Thread? @Published var threadContext: ThreadContext? + @Published var hasPendingStatusEditReload = false private(set) lazy var loadThreadStateMachine: GKStateMachine = { let stateMachine = GKStateMachine(states: [ @@ -95,6 +96,15 @@ class ThreadViewModel { }() } .store(in: &disposeBag) + + context.publisherService + .statusPublishResult + .sink { [weak self] value in + if case let Result.success(result) = value, case StatusPublishResult.edit = result { + self?.hasPendingStatusEditReload = true + } + } + .store(in: &disposeBag) } deinit { diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 3e4669901..44501a1dd 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -226,6 +226,7 @@ extension SceneDelegate { let composeViewModel = ComposeViewModel( context: AppContext.shared, authContext: authContext, + composeContext: .composeStatus, destination: .topLevel ) _ = coordinator?.present(scene: .compose(viewModel: composeViewModel), from: nil, transition: .modal(animated: true, completion: nil)) diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion index b2d4c7f6e..16fd80d20 100644 --- a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - CoreData 7.xcdatamodel + CoreData 8.xcdatamodel diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 8.xcdatamodel/contents b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 8.xcdatamodel/contents new file mode 100644 index 000000000..fe1fcd98a --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 8.xcdatamodel/contents @@ -0,0 +1,292 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 51df98e36..d06acf9d8 100644 --- a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -276,25 +276,4 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Poll.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Poll.swift index a237f5399..6be59ea57 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Poll.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Poll.swift @@ -38,7 +38,7 @@ public final class Poll: NSManagedObject { @NSManaged public private(set) var isVoting: Bool // one-to-one relationship - @NSManaged public private(set) var status: Status + @NSManaged public private(set) var status: Status? // one-to-many relationship @NSManaged public private(set) var options: Set @@ -324,3 +324,9 @@ extension Poll: AutoUpdatableObject { } } } + +public extension Set { + func sortedByIndex() -> [PollOption] { + sorted(by: { lhs, rhs in lhs.index < rhs.index }) + } +} diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PollOption.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PollOption.swift index 2799dd0a0..7c0ef9e3c 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PollOption.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/PollOption.swift @@ -27,7 +27,8 @@ public final class PollOption: NSManagedObject { @NSManaged public private(set) var isSelected: Bool // many-to-one relationship - @NSManaged public private(set) var poll: Poll + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var poll: Poll? // many-to-many relationship @NSManaged public private(set) var votedBy: Set? @@ -125,19 +126,22 @@ extension PollOption: AutoGenerateProperty { public let votesCount: Int64 public let createdAt: Date public let updatedAt: Date + public let poll: Poll? public init( index: Int64, title: String, votesCount: Int64, createdAt: Date, - updatedAt: Date + updatedAt: Date, + poll: Poll? ) { self.index = index self.title = title self.votesCount = votesCount self.createdAt = createdAt self.updatedAt = updatedAt + self.poll = poll } } @@ -147,12 +151,14 @@ extension PollOption: AutoGenerateProperty { self.votesCount = property.votesCount self.createdAt = property.createdAt self.updatedAt = property.updatedAt + self.poll = property.poll } public func update(property: Property) { update(title: property.title) update(votesCount: property.votesCount) update(updatedAt: property.updatedAt) + update(poll: property.poll) } // sourcery:end } @@ -183,6 +189,11 @@ extension PollOption: AutoUpdatableObject { self.isSelected = isSelected } } + public func update(poll: Poll?) { + if self.poll != poll { + self.poll = poll + } + } // sourcery:end public func update(voted: Bool, by: MastodonUser) { diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift index 08f58ca3f..b9fcf3a3c 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/Status.swift @@ -32,6 +32,10 @@ public final class Status: NSManagedObject { // sourcery: autoUpdatableObject, autoGenerateProperty @NSManaged public private(set) var createdAt: Date + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public private(set) var editedAt: Date? + // sourcery: autoUpdatableObject, autoGenerateProperty @NSManaged public private(set) var content: String @@ -53,7 +57,8 @@ public final class Status: NSManagedObject { // sourcery: autoUpdatableObject @NSManaged public private(set) var isSensitiveToggled: Bool - + + // sourcery: autoGenerateRelationship @NSManaged public private(set) var application: Application? // Informational @@ -104,6 +109,8 @@ public final class Status: NSManagedObject { @NSManaged public private(set) var replyFrom: Set @NSManaged public private(set) var notifications: Set @NSManaged public private(set) var searchHistories: Set + + @NSManaged public private(set) var editHistory: Set? // sourcery: autoUpdatableObject, autoGenerateProperty @NSManaged public private(set) var updatedAt: Date @@ -176,8 +183,8 @@ extension Status { didAccessValue(forKey: keyPath) do { guard let data = _data else { return [] } - let emojis = try JSONDecoder().decode([MastodonMention].self, from: data) - return emojis + let mentions = try JSONDecoder().decode([MastodonMention].self, from: data) + return mentions } catch { assertionFailure(error.localizedDescription) return [] @@ -269,6 +276,7 @@ extension Status: AutoGenerateProperty { public let id: String public let uri: String public let createdAt: Date + public let editedAt: Date? public let content: String public let visibility: MastodonVisibility public let sensitive: Bool @@ -293,6 +301,7 @@ extension Status: AutoGenerateProperty { id: String, uri: String, createdAt: Date, + editedAt: Date?, content: String, visibility: MastodonVisibility, sensitive: Bool, @@ -316,6 +325,7 @@ extension Status: AutoGenerateProperty { self.id = id self.uri = uri self.createdAt = createdAt + self.editedAt = editedAt self.content = content self.visibility = visibility self.sensitive = sensitive @@ -342,6 +352,7 @@ extension Status: AutoGenerateProperty { self.id = property.id self.uri = property.uri self.createdAt = property.createdAt + self.editedAt = property.editedAt self.content = property.content self.visibility = property.visibility self.sensitive = property.sensitive @@ -363,6 +374,7 @@ extension Status: AutoGenerateProperty { public func update(property: Property) { update(createdAt: property.createdAt) + update(editedAt: property.editedAt) update(content: property.content) update(visibility: property.visibility) update(sensitive: property.sensitive) @@ -391,17 +403,20 @@ extension Status: AutoGenerateRelationship { // Generated using Sourcery // DO NOT EDIT public struct Relationship { + public let application: Application? public let author: MastodonUser public let reblog: Status? public let poll: Poll? public let card: Card? public init( + application: Application?, author: MastodonUser, reblog: Status?, poll: Poll?, card: Card? ) { + self.application = application self.author = author self.reblog = reblog self.poll = poll @@ -410,6 +425,7 @@ extension Status: AutoGenerateRelationship { } public func configure(relationship: Relationship) { + self.application = relationship.application self.author = relationship.author self.reblog = relationship.reblog self.poll = relationship.poll @@ -429,6 +445,11 @@ extension Status: AutoUpdatableObject { self.createdAt = createdAt } } + public func update(editedAt: Date?) { + if self.editedAt != editedAt { + self.editedAt = editedAt + } + } public func update(content: String) { if self.content != content { self.content = content @@ -587,6 +608,10 @@ extension Status: AutoUpdatableObject { public func update(isReveal: Bool) { revealedAt = isReveal ? Date() : nil } + + public func update(editHistory: Set) { + self.editHistory = editHistory + } } extension Status { diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/StatusEdit.swift b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/StatusEdit.swift new file mode 100644 index 000000000..1cb7aa1a4 --- /dev/null +++ b/MastodonSDK/Sources/CoreDataStack/Entity/Mastodon/StatusEdit.swift @@ -0,0 +1,226 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import CoreData + +public final class StatusEdit: NSManagedObject { + public final class Poll: NSObject, Codable { + public final class Option: NSObject, Codable { + public let title: String + + public init(title: String) { + self.title = title + } + } + public let options: [Option] + + public init(options: [Option]) { + self.options = options + } + } + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public var createdAt: Date + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public var content: String + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public var sensitive: Bool + + // sourcery: autoUpdatableObject, autoGenerateProperty + @NSManaged public var spoilerText: String? + + // MARK: - AutoGenerateProperty + // sourcery:inline:StatusEdit.AutoGenerateProperty + + // Generated using Sourcery + // DO NOT EDIT + public struct Property { + public let createdAt: Date + public let content: String + public let sensitive: Bool + public let spoilerText: String? + public let emojis: [MastodonEmoji] + public let attachments: [MastodonAttachment] + public let poll: Poll? + + public init( + createdAt: Date, + content: String, + sensitive: Bool, + spoilerText: String?, + emojis: [MastodonEmoji], + attachments: [MastodonAttachment], + poll: Poll? + ) { + self.createdAt = createdAt + self.content = content + self.sensitive = sensitive + self.spoilerText = spoilerText + self.emojis = emojis + self.attachments = attachments + self.poll = poll + } + } + + public func configure(property: Property) { + self.createdAt = property.createdAt + self.content = property.content + self.sensitive = property.sensitive + self.spoilerText = property.spoilerText + self.emojis = property.emojis + self.attachments = property.attachments + self.poll = property.poll + } + + public func update(property: Property) { + update(createdAt: property.createdAt) + update(content: property.content) + update(sensitive: property.sensitive) + update(spoilerText: property.spoilerText) + update(emojis: property.emojis) + update(attachments: property.attachments) + update(poll: property.poll) + } + // sourcery:end + + // sourcery: autoUpdatableObject, autoGenerateProperty + @objc public var emojis: [MastodonEmoji] { + get { + let keyPath = #keyPath(StatusEdit.emojis) + willAccessValue(forKey: keyPath) + let _data = primitiveValue(forKey: keyPath) as? Data + didAccessValue(forKey: keyPath) + do { + guard let data = _data else { return [] } + let emojis = try JSONDecoder().decode([MastodonEmoji].self, from: data) + return emojis + } catch { + assertionFailure(error.localizedDescription) + return [] + } + } + set { + let keyPath = #keyPath(StatusEdit.emojis) + let data = try? JSONEncoder().encode(newValue) + willChangeValue(forKey: keyPath) + setPrimitiveValue(data, forKey: keyPath) + didChangeValue(forKey: keyPath) + } + } +} + +extension StatusEdit { + // sourcery: autoUpdatableObject, autoGenerateProperty + @objc public var attachments: [MastodonAttachment] { + get { + let keyPath = #keyPath(StatusEdit.attachments) + willAccessValue(forKey: keyPath) + let _data = primitiveValue(forKey: keyPath) as? Data + didAccessValue(forKey: keyPath) + do { + guard let data = _data else { return [] } + let attachments = try JSONDecoder().decode([MastodonAttachment].self, from: data) + return attachments + } catch { + assertionFailure(error.localizedDescription) + return [] + } + } + set { + let keyPath = #keyPath(StatusEdit.attachments) + let data = try? JSONEncoder().encode(newValue) + willChangeValue(forKey: keyPath) + setPrimitiveValue(data, forKey: keyPath) + didChangeValue(forKey: keyPath) + } + } + +} + +extension StatusEdit { + // sourcery: autoUpdatableObject, autoGenerateProperty + @objc public var poll: Poll? { + get { + let keyPath = #keyPath(StatusEdit.poll) + willAccessValue(forKey: keyPath) + let _data = primitiveValue(forKey: keyPath) as? Data + didAccessValue(forKey: keyPath) + do { + guard let data = _data else { return nil } + let poll = try JSONDecoder().decode(Poll.self, from: data) + return poll + } catch { + return nil + } + } + set { + let keyPath = #keyPath(StatusEdit.poll) + let data = try? JSONEncoder().encode(newValue) + willChangeValue(forKey: keyPath) + setPrimitiveValue(data, forKey: keyPath) + didChangeValue(forKey: keyPath) + } + } + +} + +extension StatusEdit: Managed { + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> StatusEdit { + let object: StatusEdit = context.insertObject() + + object.configure(property: property) + + return object + } +} + +extension StatusEdit: AutoUpdatableObject { + // sourcery:inline:StatusEdit.AutoUpdatableObject + + // Generated using Sourcery + // DO NOT EDIT + public func update(createdAt: Date) { + if self.createdAt != createdAt { + self.createdAt = createdAt + } + } + public func update(content: String) { + if self.content != content { + self.content = content + } + } + public func update(sensitive: Bool) { + if self.sensitive != sensitive { + self.sensitive = sensitive + } + } + public func update(spoilerText: String?) { + if self.spoilerText != spoilerText { + self.spoilerText = spoilerText + } + } + public func update(emojis: [MastodonEmoji]) { + if self.emojis != emojis { + self.emojis = emojis + } + } + public func update(attachments: [MastodonAttachment]) { + if self.attachments != attachments { + self.attachments = attachments + } + } + public func update(poll: Poll?) { + if self.poll != poll { + self.poll = poll + } + } + // sourcery:end + +} + diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json index fe0e4dbc2..56b7a4860 100644 --- a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json @@ -11,6 +11,24 @@ } }, "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.300", + "blue" : "0.737", + "green" : "0.765", + "red" : "0.765" + } + }, + "idiom" : "universal" } ], "info" : { diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/selection.highlight.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/selection.highlight.colorset/Contents.json new file mode 100644 index 000000000..6e8b13111 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Colors/selection.highlight.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xD5", + "green" : "0xD1", + "red" : "0xD1" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x3C", + "green" : "0x3A", + "red" : "0x3A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Edit History/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Edit History/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Edit History/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Edit History/Edit.imageset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Edit History/Edit.imageset/Contents.json new file mode 100644 index 000000000..a6eaf64d9 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Edit History/Edit.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "edit.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Edit History/Edit.imageset/edit.pdf b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Edit History/Edit.imageset/edit.pdf new file mode 100644 index 000000000..64acb7618 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Edit History/Edit.imageset/edit.pdf @@ -0,0 +1,93 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 1.999985 1.609241 cm +0.411765 0.400000 0.521569 scn +18.951868 19.342661 m +17.554346 20.740183 15.288496 20.740116 13.891058 19.342512 c +1.941028 7.391070 l +1.534704 6.984698 1.249101 6.473557 1.115997 5.914522 c +0.020410 1.313066 l +-0.039914 1.059704 0.035522 0.793184 0.219685 0.609020 c +0.403848 0.424858 0.670367 0.349422 0.923730 0.409746 c +5.524981 1.505281 l +6.084182 1.638426 6.595463 1.924147 7.001908 2.330639 c +18.952013 14.282148 l +20.349335 15.679634 20.349274 17.945255 18.951868 19.342661 c +h +14.951780 18.281914 m +15.763443 19.093672 17.079496 19.093712 17.891207 18.282001 c +18.702848 17.470360 18.702887 16.154438 17.891291 15.342747 c +16.999950 14.451301 l +14.060611 17.390640 l +14.951780 18.281914 l +h +13.000013 16.329916 m +15.939351 13.390577 l +5.941185 3.391237 l +5.731036 3.181063 5.466681 3.033333 5.177550 2.964491 c +1.761908 2.151243 l +2.575206 5.567090 l +2.644022 5.856114 2.791680 6.120377 3.001751 6.330473 c +13.000013 16.329916 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 1034 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001124 00000 n +0000001147 00000 n +0000001320 00000 n +0000001394 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1453 +%%EOF \ No newline at end of file diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Edit History/StatusBackground.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Edit History/StatusBackground.colorset/Contents.json new file mode 100644 index 000000000..ccb2ce0d8 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Edit History/StatusBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF9", + "green" : "0xF5", + "red" : "0xF5" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2C", + "green" : "0x1B", + "red" : "0x1B" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Edit History/StatusBackgroundBorder.colorset/Contents.json b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Edit History/StatusBackgroundBorder.colorset/Contents.json new file mode 100644 index 000000000..8e57b0812 --- /dev/null +++ b/MastodonSDK/Sources/MastodonAsset/Assets.xcassets/Scene/Edit History/StatusBackgroundBorder.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xDE", + "green" : "0xD1", + "red" : "0xD1" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x12", + "green" : "0x0D", + "red" : "0x0D" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift index ff00721ee..8286cb146 100644 --- a/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift +++ b/MastodonSDK/Sources/MastodonAsset/Generated/Assets.swift @@ -103,6 +103,7 @@ public enum Asset { public static let disabled = ColorAsset(name: "Colors/disabled") public static let inactive = ColorAsset(name: "Colors/inactive") public static let mediaTypeIndicotor = ColorAsset(name: "Colors/media.type.indicotor") + public static let selectionHighlight = ColorAsset(name: "Colors/selection.highlight") public static let successGreen = ColorAsset(name: "Colors/success.green") public static let systemOrange = ColorAsset(name: "Colors/system.orange") } @@ -164,6 +165,11 @@ public enum Asset { public enum Discovery { public static let profileCardBackground = ColorAsset(name: "Scene/Discovery/profile.card.background") } + public enum EditHistory { + public static let edit = ImageAsset(name: "Scene/Edit History/Edit") + public static let statusBackground = ColorAsset(name: "Scene/Edit History/StatusBackground") + public static let statusBackgroundBorder = ColorAsset(name: "Scene/Edit History/StatusBackgroundBorder") + } public enum Notification { public static let confirmFollowRequestButtonBackground = ColorAsset(name: "Scene/Notification/confirm.follow.request.button.background") public static let deleteFollowRequestButtonBackground = ColorAsset(name: "Scene/Notification/delete.follow.request.button.background") diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Attachment+Array.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Attachment+Array.swift new file mode 100644 index 000000000..ad2a6250e --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Attachment+Array.swift @@ -0,0 +1,51 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import CoreDataStack +import MastodonMeta +import MastodonSDK + +extension [Mastodon.Entity.Attachment]? { + public var mastodonAttachments: [MastodonAttachment] { + guard let mediaAttachments = self else { return [] } + + let attachments = mediaAttachments.compactMap { media -> MastodonAttachment? in + guard let kind = media.attachmentKind + else { return nil } + + let width: Int; + let height: Int; + let durationMS: Int?; + + if let meta = media.meta, + let original = meta.original, + let originalWidth = original.width, + let originalHeight = original.height { + width = originalWidth // audio has width/height + height = originalHeight + durationMS = original.duration.map { Int($0 * 1000) } + } + else { + // In case metadata field is missing, use default values. + width = 32; + height = 32; + durationMS = nil; + } + + return MastodonAttachment( + id: media.id, + kind: kind, + size: CGSize(width: width, height: height), + focus: nil, // TODO: + blurhash: media.blurhash, + assetURL: media.url, + previewURL: media.previewURL, + textURL: media.textURL, + durationMS: durationMS, + altDescription: media.description + ) + } + + return attachments + } +} diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/PollOption+Property.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/PollOption+Property.swift index 4fa62979e..6de8343d4 100644 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/PollOption+Property.swift +++ b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/PollOption+Property.swift @@ -11,6 +11,7 @@ import CoreDataStack extension PollOption.Property { public init( + poll: Poll, index: Int, entity: Mastodon.Entity.Poll.Option, networkDate: Date @@ -20,7 +21,8 @@ extension PollOption.Property { title: entity.title, votesCount: Int64(entity.votesCount ?? 0), createdAt: networkDate, - updatedAt: networkDate + updatedAt: networkDate, + poll: poll ) } } diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Status+Property.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Status+Property.swift index 9e92e0d2c..2d054c83b 100644 --- a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Status+Property.swift +++ b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/Status+Property.swift @@ -18,6 +18,7 @@ extension Status.Property { id: entity.id, uri: entity.uri, createdAt: entity.createdAt, + editedAt: entity.editedAt, content: entity.content ?? "", visibility: entity.mastodonVisibility, sensitive: entity.sensitive ?? false, @@ -48,46 +49,7 @@ extension Mastodon.Entity.Status { extension Mastodon.Entity.Status { public var mastodonAttachments: [MastodonAttachment] { - guard let mediaAttachments = mediaAttachments else { return [] } - - let attachments = mediaAttachments.compactMap { media -> MastodonAttachment? in - guard let kind = media.attachmentKind - else { return nil } - - let width: Int; - let height: Int; - let durationMS: Int?; - - if let meta = media.meta, - let original = meta.original, - let originalWidth = original.width, - let originalHeight = original.height { - width = originalWidth // audio has width/height - height = originalHeight - durationMS = original.duration.map { Int($0 * 1000) } - } - else { - // In case metadata field is missing, use default values. - width = 32; - height = 32; - durationMS = nil; - } - - return MastodonAttachment( - id: media.id, - kind: kind, - size: CGSize(width: width, height: height), - focus: nil, // TODO: - blurhash: media.blurhash, - assetURL: media.url, - previewURL: media.previewURL, - textURL: media.textURL, - durationMS: durationMS, - altDescription: media.description - ) - } - - return attachments + mediaAttachments.mastodonAttachments } } diff --git a/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/StatusEdit+Property.swift b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/StatusEdit+Property.swift new file mode 100644 index 000000000..e32d45795 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Extension/CoreDataStack/StatusEdit+Property.swift @@ -0,0 +1,24 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import CoreDataStack +import MastodonSDK + +extension StatusEdit.Property { + init(entity: Mastodon.Entity.StatusEdit) { + self.init( + createdAt: entity.createdAt, + content: entity.content, + sensitive: entity.sensitive, + spoilerText: entity.spoilerText, + emojis: entity.mastodonEmojis, + attachments: entity.mastodonAttachments, + poll: entity.poll.map { StatusEdit.Poll(options: $0.options.map { StatusEdit.Poll.Option(title: $0.title) } ) } ) + } +} + +extension Mastodon.Entity.StatusEdit { + public var mastodonAttachments: [MastodonAttachment] { + mediaAttachments.mastodonAttachments + } +} diff --git a/MastodonSDK/Sources/MastodonCore/Model/Poll/PollComposeItem.swift b/MastodonSDK/Sources/MastodonCore/Model/Poll/PollComposeItem.swift index 384cb49d2..3594ac8af 100644 --- a/MastodonSDK/Sources/MastodonCore/Model/Poll/PollComposeItem.swift +++ b/MastodonSDK/Sources/MastodonCore/Model/Poll/PollComposeItem.swift @@ -86,6 +86,26 @@ extension PollComposeItem { case .sevenDays: return 60 * 60 * 24 * 7 } } + + public init(closestDateToExpiry date: Date) { + let expiresInSeconds = Int(date.timeIntervalSince(.now)) + switch expiresInSeconds { + case _ where expiresInSeconds <= Self.thirtyMinutes.seconds: + self = .thirtyMinutes + case _ where expiresInSeconds > Self.thirtyMinutes.seconds && expiresInSeconds <= Self.oneHour.seconds: + self = .oneHour + case _ where expiresInSeconds > Self.oneHour.seconds && expiresInSeconds <= Self.sixHours.seconds: + self = .sixHours + case _ where expiresInSeconds > Self.sixHours.seconds && expiresInSeconds <= Self.oneDay.seconds: + self = .oneDay + case _ where expiresInSeconds > Self.oneDay.seconds && expiresInSeconds <= Self.threeDays.seconds: + self = .threeDays + case _ where expiresInSeconds > Self.threeDays.seconds && expiresInSeconds <= Self.sevenDays.seconds: + self = .sevenDays + default: + self = .oneDay + } + } } } } diff --git a/MastodonSDK/Sources/MastodonCore/Model/Poll/PollItem.swift b/MastodonSDK/Sources/MastodonCore/Model/Poll/PollItem.swift index b21a45b2d..367422752 100644 --- a/MastodonSDK/Sources/MastodonCore/Model/Poll/PollItem.swift +++ b/MastodonSDK/Sources/MastodonCore/Model/Poll/PollItem.swift @@ -11,4 +11,5 @@ import CoreDataStack public enum PollItem: Hashable { case option(record: ManagedObjectRecord) + case history(option: StatusEdit.Poll.Option) } diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Poll.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Poll.swift index 6f7eb60c2..6e9d5a436 100644 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Poll.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Poll.swift @@ -59,30 +59,16 @@ extension Persistence.Poll { ) -> PersistResult { if let old = fetch(in: managedObjectContext, context: context) { - merge(poll: old, context: context) + merge(in: managedObjectContext, poll: old, context: context) return PersistResult( poll: old, isNewInsertion: false ) } else { - let options: [PollOption] = context.entity.options.enumerated().map { i, entity in - let optionResult = Persistence.PollOption.persist( - in: managedObjectContext, - context: Persistence.PollOption.PersistContext( - index: i, - entity: entity, - me: context.me, - networkDate: context.networkDate - ) - ) - return optionResult.option - } - let poll = create( in: managedObjectContext, context: context ) - poll.attach(options: options) return PersistResult( poll: poll, @@ -124,11 +110,12 @@ extension Persistence.Poll { into: managedObjectContext, property: property ) - update(poll: poll, context: context) + update(in: managedObjectContext, poll: poll, context: context) return poll } public static func merge( + in managedObjectContext: NSManagedObjectContext, poll: Poll, context: PersistContext ) { @@ -139,10 +126,11 @@ extension Persistence.Poll { networkDate: context.networkDate ) poll.update(property: property) - update(poll: poll, context: context) + update(in: managedObjectContext, poll: poll, context: context) } public static func update( + in managedObjectContext: NSManagedObjectContext, poll: Poll, context: PersistContext ) { @@ -153,6 +141,7 @@ extension Persistence.Poll { option: option, context: Persistence.PollOption.PersistContext( index: Int(option.index), + poll: poll, entity: entity, me: context.me, networkDate: context.networkDate @@ -173,7 +162,53 @@ extension Persistence.Poll { } } + // update options + if needsPollOptionsUpdate(context: context, poll: poll) { + // options differ, update them + for option in poll.options { + option.update(poll: nil) + managedObjectContext.delete(option) + } + var attachableOptions = [PollOption]() + for (index, option) in context.entity.options.enumerated() { + attachableOptions.append( + Persistence.PollOption.create( + in: managedObjectContext, + context: Persistence.PollOption.PersistContext( + index: index, + poll: poll, + entity: option, + me: context.me, + networkDate: context.networkDate + ) + ) + ) + } + poll.attach(options: attachableOptions) + } + poll.update(updatedAt: context.networkDate) } + private static func needsPollOptionsUpdate(context: PersistContext, poll: Poll) -> Bool { + let entityPollOptions = context.entity.options.map { (title: $0.title, votes: $0.votesCount) } + let pollOptions = poll.options.sortedByIndex().map { (title: $0.title, votes: Int($0.votesCount)) } + + guard entityPollOptions.count == pollOptions.count else { + // poll definitely needs to be updated due to differences in count of options + return true + } + + for (entityPollOption, pollOption) in zip(entityPollOptions, pollOptions) { + guard entityPollOption.title == pollOption.title else { + // update poll because at least one title differs + return true + } + guard entityPollOption.votes == pollOption.votes else { + // update poll because at least one vote count differs + return true + } + } + return false + } } diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+PollOption.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+PollOption.swift index a872d7edb..d26447276 100644 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+PollOption.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+PollOption.swift @@ -15,6 +15,7 @@ extension Persistence.PollOption { public struct PersistContext { public let index: Int + public let poll: Poll public let entity: Mastodon.Entity.Poll.Option public let me: MastodonUser? public let networkDate: Date @@ -22,11 +23,13 @@ extension Persistence.PollOption { public init( index: Int, + poll: Poll, entity: Mastodon.Entity.Poll.Option, me: MastodonUser?, networkDate: Date ) { self.index = index + self.poll = poll self.entity = entity self.me = me self.networkDate = networkDate @@ -66,6 +69,7 @@ extension Persistence.PollOption { context: PersistContext ) -> PollOption { let property = PollOption.Property( + poll: context.poll, index: context.index, entity: context.entity, networkDate: context.networkDate @@ -81,6 +85,7 @@ extension Persistence.PollOption { ) { guard context.networkDate > option.updatedAt else { return } let property = PollOption.Property( + poll: context.poll, index: context.index, entity: context.entity, networkDate: context.networkDate diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Status.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Status.swift index aa5eb8546..dbdfd26c0 100644 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Status.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+Status.swift @@ -120,8 +120,10 @@ extension Persistence.Status { ) ) let author = authorResult.user + let application: Application? = createApplication(in: managedObjectContext, context: .init(entity: context.entity)) let relationship = Status.Relationship( + application: application, author: author, reblog: reblog, poll: poll, @@ -197,7 +199,9 @@ extension Persistence.Status { ) status.update(property: property) if let poll = status.poll, let entity = context.entity.poll { - Persistence.Poll.merge( + // update poll + Persistence.Poll.update( + in: managedObjectContext, poll: poll, context: Persistence.Poll.PersistContext( domain: context.domain, @@ -206,6 +210,40 @@ extension Persistence.Status { networkDate: context.networkDate ) ) + } else if let entity = context.entity.poll { + // add poll + let result = Persistence.Poll.createOrMerge( + in: managedObjectContext, + context: Persistence.Poll.PersistContext( + domain: context.domain, + entity: entity, + me: context.me, + networkDate: context.networkDate + ) + ) + + status.configure( + relationship: + Status.Relationship( + application: status.application, + author: status.author, + reblog: status.reblog, + poll: result.poll, + card: status.card + ) + ) + } else if status.poll != nil, context.entity.poll == nil { + // remove poll + status.configure( + relationship: + Status.Relationship( + application: status.application, + author: status.author, + reblog: status.reblog, + poll: nil, + card: status.card + ) + ) } if status.card == nil, context.entity.card != nil { @@ -243,5 +281,21 @@ extension Persistence.Status { context.entity.favourited.flatMap { status.update(liked: $0, by: user) } } } - + + private static func createApplication( + in managedObjectContext: NSManagedObjectContext, + context: MastodonApplication.PersistContext + ) -> Application? { + guard let application = context.entity.application else { return nil } + + let persistedApplication = Application.insert(into: managedObjectContext, property: .init(name: application.name, website: application.website, vapidKey: application.vapidKey)) + + return persistedApplication + } + + enum MastodonApplication { + public struct PersistContext { + let entity: Mastodon.Entity.Status + } + } } diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+StatusEdit.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+StatusEdit.swift new file mode 100644 index 000000000..be28ec63c --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence+StatusEdit.swift @@ -0,0 +1,48 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import CoreData +import CoreDataStack +import MastodonSDK + +extension Persistence.StatusEdit { + + public static func createOrMerge( + in managedObjectContext: NSManagedObjectContext, + statusEdits: [Mastodon.Entity.StatusEdit], + forStatus status: Status + ) { + guard statusEdits.isEmpty == false else { return } + + // remove all edits for status + + if let editHistory = status.editHistory { + for statusEdit in Array(editHistory) { + managedObjectContext.delete(statusEdit) + } + } + status.update(editHistory: Set()) + let persistedEdits = create(in: managedObjectContext, statusEdits: statusEdits, forStatus: status) + status.update(editHistory: Set(persistedEdits)) + } + + public static func create( + in managedObjectContext: NSManagedObjectContext, + statusEdits: [Mastodon.Entity.StatusEdit], + forStatus status: Status + ) -> [StatusEdit] { + + var entries: [StatusEdit] = [] + + for statusEdit in statusEdits { + let property = StatusEdit.Property(createdAt: statusEdit.createdAt, content: statusEdit.content, sensitive: statusEdit.sensitive, spoilerText: statusEdit.spoilerText, emojis: statusEdit.mastodonEmojis, attachments: statusEdit.mastodonAttachments, poll: statusEdit.poll.map { StatusEdit.Poll(options: $0.options.map { StatusEdit.Poll.Option(title: $0.title) } ) }) + let statusEditEntry = StatusEdit.insert(into: managedObjectContext, property: property) + + entries.append(statusEditEntry) + } + + status.update(editHistory: Set(entries)) + + return entries + } +} + diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift index 3a36dec41..9142e8b51 100644 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift @@ -20,6 +20,7 @@ extension Persistence { public enum Tag { } public enum SearchHistory { } public enum Notification { } + public enum StatusEdit {} } extension Persistence { diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Protocol/MastodonEmojiContainer.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Protocol/MastodonEmojiContainer.swift index e3bb62f63..7273d507f 100644 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Protocol/MastodonEmojiContainer.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Protocol/MastodonEmojiContainer.swift @@ -24,3 +24,4 @@ extension MastodonEmojiContainer { extension Mastodon.Entity.Account: MastodonEmojiContainer { } extension Mastodon.Entity.Status: MastodonEmojiContainer { } +extension Mastodon.Entity.StatusEdit: MastodonEmojiContainer { } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status+History.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status+History.swift new file mode 100644 index 000000000..88cf91f9e --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Status+History.swift @@ -0,0 +1,102 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import MastodonSDK +import CoreDataStack + +extension APIService { + + public func getStatusSource( + forStatusID statusID: Status.ID, + authenticationBox: MastodonAuthenticationBox) async throws -> Mastodon.Response.Content { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization + + let response = try await Mastodon.API.Statuses.statusSource( + forStatusID: statusID, + session: session, + domain: domain, + authorization: authorization).singleOutput() + + return response + } + + public func getHistory( + forStatusID statusID: Status.ID, + authenticationBox: MastodonAuthenticationBox) async throws -> Mastodon.Response.Content<[Mastodon.Entity.StatusEdit]> { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization + + let response = try await Mastodon.API.Statuses.editHistory( + forStatusID: statusID, + session: session, + domain: domain, + authorization: authorization).singleOutput() + + guard response.value.isEmpty == false else { return response } + + let managedObjectContext = self.backgroundManagedObjectContext + + try await managedObjectContext.performChanges { + // get status + guard let status = Status.fetch(in: managedObjectContext, configurationBlock: { + $0.predicate = Status.predicate(domain: domain, id: statusID) + }).first else { return } + + Persistence.StatusEdit.createOrMerge(in: managedObjectContext, + statusEdits: response.value, + forStatus: status) + } + + return response + } + + public func publishStatusEdit( + forStatusID statusID: Status.ID, + editStatusQuery: Mastodon.API.Statuses.EditStatusQuery, + authenticationBox: MastodonAuthenticationBox) async throws -> Mastodon.Response.Content { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization + + let response = try await Mastodon.API.Statuses.editStatus( + forStatusID: statusID, + editStatusQuery: editStatusQuery, + session: session, + domain: domain, + authorization: authorization).singleOutput() + + let responseHistory = try await Mastodon.API.Statuses.editHistory( + forStatusID: statusID, + session: session, + domain: domain, + authorization: authorization + ).singleOutput() + + #if !APP_EXTENSION + let managedObjectContext = self.backgroundManagedObjectContext + + try await managedObjectContext.performChanges { + let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user + let status = Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: domain, + entity: response.value, + me: me, + statusCache: nil, + userCache: nil, + networkDate: response.networkDate + ) + ) + + Persistence.StatusEdit.createOrMerge( + in: managedObjectContext, + statusEdits: responseHistory.value, + forStatus: status.status + ) + } + #endif + + return response + } +} diff --git a/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublishResult.swift b/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublishResult.swift index 63b8650f2..6ff7d1d9e 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublishResult.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublishResult.swift @@ -9,5 +9,6 @@ import Foundation import MastodonSDK public enum StatusPublishResult { - case mastodon(Mastodon.Response.Content) + case post(Mastodon.Response.Content) + case edit(Mastodon.Response.Content) } diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index 5361a3f95..5edada3df 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -132,6 +132,8 @@ public enum L10n { public static let done = L10n.tr("Localizable", "Common.Controls.Actions.Done", fallback: "Done") /// Edit public static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit", fallback: "Edit") + /// Edit + public static let editPost = L10n.tr("Localizable", "Common.Controls.Actions.EditPost", fallback: "Edit") /// Find people to follow public static let findPeople = L10n.tr("Localizable", "Common.Controls.Actions.FindPeople", fallback: "Find people to follow") /// Manually search instead @@ -290,6 +292,10 @@ public enum L10n { public enum Status { /// Content Warning public static let contentWarning = L10n.tr("Localizable", "Common.Controls.Status.ContentWarning", fallback: "Content Warning") + /// Edited %@ + public static func editedAtTimestampPrefix(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.EditedAtTimestampPrefix", String(describing: p1), fallback: "Edited %@") + } /// %@ via %@ public static func linkViaUser(_ p1: Any, _ p2: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Status.LinkViaUser", String(describing: p1), String(describing: p2), fallback: "%@ via %@") @@ -298,6 +304,10 @@ public enum L10n { public static let loadEmbed = L10n.tr("Localizable", "Common.Controls.Status.LoadEmbed", fallback: "Load Embed") /// Tap anywhere to reveal public static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning", fallback: "Tap anywhere to reveal") + /// %@ via %@ + public static func postedViaApplication(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.PostedViaApplication", String(describing: p1), String(describing: p2), fallback: "%@ via %@") + } /// Sensitive Content public static let sensitiveContent = L10n.tr("Localizable", "Common.Controls.Status.SensitiveContent", fallback: "Sensitive Content") /// Show Post @@ -340,6 +350,26 @@ public enum L10n { /// Undo reblog public static let unreblog = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unreblog", fallback: "Undo reblog") } + public enum Buttons { + /// Last edit %@ + public static func editHistoryDetail(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Buttons.EditHistoryDetail", String(describing: p1), fallback: "Last edit %@") + } + /// Edit History + public static let editHistoryTitle = L10n.tr("Localizable", "Common.Controls.Status.Buttons.EditHistoryTitle", fallback: "Edit History") + /// Favorites + public static let favoritesTitle = L10n.tr("Localizable", "Common.Controls.Status.Buttons.FavoritesTitle", fallback: "Favorites") + /// Reblogs + public static let reblogsTitle = L10n.tr("Localizable", "Common.Controls.Status.Buttons.ReblogsTitle", fallback: "Reblogs") + } + public enum EditHistory { + /// Original Post · %@ + public static func originalPost(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.EditHistory.OriginalPost", String(describing: p1), fallback: "Original Post · %@") + } + /// Edit History + public static let title = L10n.tr("Localizable", "Common.Controls.Status.EditHistory.Title", fallback: "Edit History") + } public enum Media { /// %@, attachment %d of %d public static func accessibilityLabel(_ p1: Any, _ p2: Int, _ p3: Int) -> String { @@ -629,6 +659,8 @@ public enum L10n { public static let title = L10n.tr("Localizable", "Scene.Compose.Poll.Title", fallback: "Poll") } public enum Title { + /// Edit Post + public static let editPost = L10n.tr("Localizable", "Scene.Compose.Title.EditPost", fallback: "Edit Post") /// New Post public static let newPost = L10n.tr("Localizable", "Scene.Compose.Title.NewPost", fallback: "New Post") /// New Reply diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index cb05f4bfd..ef1a38c5a 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -65,6 +65,7 @@ Please check your internet connection."; "Common.Controls.Actions.TakePhoto" = "Take Photo"; "Common.Controls.Actions.TranslatePost.Title" = "Translate from %@"; "Common.Controls.Actions.TranslatePost.UnknownLanguage" = "Unknown"; +"Common.Controls.Actions.EditPost" = "Edit"; "Common.Controls.Actions.TryAgain" = "Try Again"; "Common.Controls.Actions.UnblockDomain" = "Unblock %@"; "Common.Controls.Friendship.Block" = "Block"; @@ -147,6 +148,14 @@ Please check your internet connection."; "Common.Controls.Status.Visibility.Private" = "Only their followers can see this post."; "Common.Controls.Status.Visibility.PrivateFromMe" = "Only my followers can see this post."; "Common.Controls.Status.Visibility.Unlisted" = "Everyone can see this post but not display in the public timeline."; +"Common.Controls.Status.PostedViaApplication" = "%@ via %@"; +"Common.Controls.Status.Buttons.ReblogsTitle" = "Reblogs"; +"Common.Controls.Status.Buttons.FavoritesTitle" = "Favorites"; +"Common.Controls.Status.Buttons.EditHistoryTitle" = "Edit History"; +"Common.Controls.Status.Buttons.EditHistoryDetail" = "Last edit %@"; +"Common.Controls.Status.EditedAtTimestampPrefix" = "Edited %@"; +"Common.Controls.Status.EditHistory.Title" = "Edit History"; +"Common.Controls.Status.EditHistory.OriginalPost" = "Original Post · %@"; "Common.Controls.Tabs.Home" = "Home"; "Common.Controls.Tabs.Notifications" = "Notifications"; "Common.Controls.Tabs.Profile" = "Profile"; @@ -229,6 +238,7 @@ uploaded to Mastodon."; "Scene.Compose.ReplyingToUser" = "replying to %@"; "Scene.Compose.Title.NewPost" = "New Post"; "Scene.Compose.Title.NewReply" = "New Reply"; +"Scene.Compose.Title.EditPost" = "Edit Post"; "Scene.Compose.Visibility.Direct" = "Only people I mention"; "Scene.Compose.Visibility.Private" = "Followers only"; "Scene.Compose.Visibility.Public" = "Public"; diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift index 1bb4014df..861b48c15 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift @@ -70,7 +70,7 @@ extension Mastodon.API.Media { .eraseToAnyPublisher() } - public struct UploadMediaQuery: PostQuery, PutQuery { + public struct UploadMediaQuery: PostQuery { public let file: Mastodon.Query.MediaAttachment? public let thumbnail: Mastodon.Query.MediaAttachment? public let description: String? diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+FavoriteBy.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+FavoriteBy.swift index 5d5747d13..b5f4ddfc0 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+FavoriteBy.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+FavoriteBy.swift @@ -36,7 +36,7 @@ extension Mastodon.API.Statuses { public static func favoriteBy( session: URLSession, domain: String, - statusID: Mastodon.Entity.Poll.ID, + statusID: Mastodon.Entity.Status.ID, query: FavoriteByQuery, authorization: Mastodon.API.OAuth.Authorization? ) -> AnyPublisher, Error> { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+StatusHistory.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+StatusHistory.swift new file mode 100644 index 000000000..096233c32 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+StatusHistory.swift @@ -0,0 +1,166 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import Combine + +extension Mastodon.API.Statuses { + private static func historyEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("statuses") + .appendingPathComponent(statusID) + .appendingPathComponent("history") + } + + private static func statusSourceEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("statuses") + .appendingPathComponent(statusID) + .appendingPathComponent("source") + } + + public static func statusSource( + forStatusID statusID: Mastodon.Entity.Status.ID, + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let url = statusSourceEndpointURL(domain: domain, statusID: statusID) + let request = Mastodon.API.get(url: url, authorization: authorization) + + return session.dataTaskPublisher(for: request) + .tryMap { (data: Data, response: URLResponse) in + let value = try Mastodon.API.decode(type: Mastodon.Entity.StatusSource.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + + } + + + /// Get all known versions of a status, including the initial and current states. + /// + /// - Since: 3.5.0 + /// + /// # Last Update + /// + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/#history) + /// + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `StatusEdit` nested in the response + public static func editHistory( + forStatusID statusID: Mastodon.Entity.Status.ID, + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + + let url = historyEndpointURL(domain: domain, statusID: statusID) + let request = Mastodon.API.get(url: url, authorization: authorization) + + return session.dataTaskPublisher(for: request) + .tryMap { (data: Data, response: URLResponse) in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.StatusEdit].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Edit a given status to change its text, sensitivity, media attachments, or poll. Note that editing a poll’s options will reset the votes. + /// + /// - Since: 3.5.0 + /// - Version: 4.0.0 + /// # Last Update + /// 2021/3/18 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/#edit) + /// + /// - Parameters: + /// - statusID: ID of the status that is to be edited + /// - editStatusQuery: Basically the edits (Status, Emoji, Media...), is a `EditStatusQuery` + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token + /// - Returns: `AnyPublisher` that contains the updated `Status` nested in the response + public static func editStatus( + forStatusID statusID: Mastodon.Entity.Status.ID, + editStatusQuery: EditStatusQuery, + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let url = statusEndpointURL(domain: domain, statusID: statusID) + let request = Mastodon.API.put(url: url, query: editStatusQuery, authorization: authorization) + + return session.dataTaskPublisher(for: request) + .tryMap { (data: Data, response: URLResponse) in + let editedStatus = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: editedStatus, response: response) + } + .eraseToAnyPublisher() + } +} + +extension Mastodon.API.Statuses { + public struct EditStatusQuery: Codable, PutQuery { + public let status: String? + public let mediaIDs: [String]? + public let pollOptions: [String]? + public let pollExpiresIn: Int? + public let pollMultipleAnswers: Bool? + public let sensitive: Bool? + public let spoilerText: String? + public let visibility: Mastodon.Entity.Status.Visibility? + public let language: String? + + public init( + status: String?, + mediaIDs: [String]?, + pollOptions: [String]?, + pollExpiresIn: Int?, + pollMultipleAnswers: Bool?, + sensitive: Bool?, + spoilerText: String?, + visibility: Mastodon.Entity.Status.Visibility?, + language: String? + ) { + self.status = status + self.mediaIDs = mediaIDs + self.pollOptions = pollOptions + self.pollExpiresIn = pollExpiresIn + self.pollMultipleAnswers = pollMultipleAnswers + self.sensitive = sensitive + self.spoilerText = spoilerText + self.visibility = visibility + self.language = language + } + + var contentType: String? { + return Self.multipartContentType() + } + + var body: Data? { + var data = Data() + + status.flatMap { data.append(Data.multipart(key: "status", value: $0)) } + for mediaID in mediaIDs ?? [] { + data.append(Data.multipart(key: "media_ids[]", value: mediaID)) + } + for pollOption in pollOptions ?? [] { + data.append(Data.multipart(key: "poll[options][]", value: pollOption)) + } + pollExpiresIn.flatMap { data.append(Data.multipart(key: "poll[expires_in]", value: $0)) } + sensitive.flatMap { data.append(Data.multipart(key: "sensitive", value: $0)) } + spoilerText.flatMap { data.append(Data.multipart(key: "spoiler_text", value: $0)) } + visibility.flatMap { data.append(Data.multipart(key: "visibility", value: $0.rawValue)) } + language.flatMap { data.append(Data.multipart(key: "language", value: $0)) } + + data.append(Data.multipartEnd()) + return data + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index f85d50bd0..d0c9d39af 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -133,7 +133,7 @@ extension Mastodon.API { static func get( url: URL, - query: GetQuery?, + query: GetQuery? = nil, authorization: OAuth.Authorization? ) -> URLRequest { return buildRequest(url: url, method: .GET, query: query, authorization: authorization) @@ -157,7 +157,7 @@ extension Mastodon.API { static func put( url: URL, - query: PutQuery?, + query: PutQuery? = nil, authorization: OAuth.Authorization? ) -> URLRequest { return buildRequest(url: url, method: .PUT, query: query, authorization: authorization) diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift index f863a90c6..66b9667a2 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift @@ -25,6 +25,7 @@ extension Mastodon.Entity { public let id: ID public let uri: String public let createdAt: Date + public let editedAt: Date? public let account: Account public let content: String? // will be optional when delete status @@ -65,6 +66,7 @@ extension Mastodon.Entity { case id case uri case createdAt = "created_at" + case editedAt = "edited_at" case account case content diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+StatusEdit.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+StatusEdit.swift new file mode 100644 index 000000000..eb092a4c6 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+StatusEdit.swift @@ -0,0 +1,44 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation + +extension Mastodon.Entity { + + /// StatusEdit + /// + /// - Since: 0.1.0 + /// - Version: 3.5.0 + /// # Last Update + /// 2022/12/14 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/statusedit/) + public class StatusEdit: Codable { + public class Poll: Codable { + public class Option: Codable { + public let title: String + } + public let options: [Option] + public let title: String? + } + + public let content: String + public let spoilerText: String? + public let sensitive: Bool + public let createdAt: Date + public let account: Account + public let poll: Poll? + public let mediaAttachments: [Attachment]? + public let emojis: [Emoji]? + + enum CodingKeys: String, CodingKey { + case content + case spoilerText = "spoiler_text" + case sensitive + case createdAt = "created_at" + case account + case poll + case mediaAttachments = "media_attachments" + case emojis + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+StatusSource.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+StatusSource.swift new file mode 100644 index 000000000..d0e5c54b7 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+StatusSource.swift @@ -0,0 +1,17 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation + +extension Mastodon.Entity { + public struct StatusSource: Codable { + public let id: String + public let text: String + public let spoilerText: String + + enum CodingKeys: String, CodingKey { + case id + case text + case spoilerText = "spoiler_text" + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift index 7e27eb50a..6affedf1d 100644 --- a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift @@ -48,7 +48,7 @@ extension GetQuery { protocol PostQuery: RequestQuery { } extension PostQuery { - // By default a `PostQuery` does not has query items + // By default a `PostQuery` does not have query items var queryItems: [URLQueryItem]? { nil } } @@ -58,10 +58,15 @@ protocol PatchQuery: RequestQuery { } // PUT protocol PutQuery: RequestQuery { } +extension PutQuery { + // By default a `PutQuery` does not have query items + var queryItems: [URLQueryItem]? { nil } +} + // DELETE protocol DeleteQuery: RequestQuery { } extension DeleteQuery { - // By default a `DeleteQuery` does not has query items + // By default a `DeleteQuery` does not have query items var queryItems: [URLQueryItem]? { nil } } diff --git a/MastodonSDK/Sources/MastodonUI/Protocol/StatusCompatible.swift b/MastodonSDK/Sources/MastodonUI/Protocol/StatusCompatible.swift new file mode 100644 index 000000000..7ae2a932d --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Protocol/StatusCompatible.swift @@ -0,0 +1,27 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import CoreDataStack + +public protocol StatusCompatible { + var reblog: Status? { get } + var attachments: [MastodonAttachment] { get } + var isMediaSensitive: Bool { get } + var isSensitiveToggled: Bool { get } +} + +extension Status: StatusCompatible {} + +extension StatusEdit: StatusCompatible { + public var reblog: Status? { + nil + } + + public var isMediaSensitive: Bool { + sensitive + } + + public var isSensitiveToggled: Bool { + true + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift index 713ee4d2a..b44ba1bff 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift @@ -71,6 +71,7 @@ public struct AttachmentView: View { .lineLimit(1) } .padding(EdgeInsets(top: 6, leading: 0, bottom: 10, trailing: 0)) + .disabled(!viewModel.isCaptionEditable) } } ) diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+DragAndDrop.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+DragAndDrop.swift index 269b836bc..a319aec95 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+DragAndDrop.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+DragAndDrop.swift @@ -44,7 +44,7 @@ extension AttachmentViewModel: NSItemProviderWriting { switch input { case .image: typeIdentifiers.append(UTType.png.identifier) - case .url(let url): + case .url(let url), .mastodonAssetUrl(let url, _): let _uti = UTType(filenameExtension: url.pathExtension) if let uti = _uti { if uti.conforms(to: .image) { diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Load.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Load.swift index 02cd60be9..31c4a59d9 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Load.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Load.swift @@ -28,6 +28,8 @@ extension AttachmentViewModel { } catch { throw error } + case .mastodonAssetUrl(let url, _): + return try await Self.loadMastodonAsset(url: url) case .pickerResult(let pickerResult): do { let output = try await AttachmentViewModel.load(itemProvider: pickerResult.itemProvider) @@ -45,6 +47,14 @@ extension AttachmentViewModel { } } + private static func loadMastodonAsset(url: URL) async throws -> Output { + guard !url.isFileURL else { + throw AttachmentError.invalidAttachmentType + } + let (imageData, _) = try await URLSession.shared.data(from: url) + return .image(imageData, imageKind: AssetType(imageData) == .png ? .png : .jpg) + } + private static func load(url: URL) async throws -> Output { guard let uti = UTType(filenameExtension: url.pathExtension) else { throw AttachmentError.invalidAttachmentType diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift index 607c581ed..aeedb6b51 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift @@ -66,7 +66,10 @@ extension AttachmentViewModel { let authContext: AuthContext } - public typealias UploadResult = Mastodon.Entity.Attachment + public enum UploadResult { + case uploadedMastodonAttachment(Mastodon.Entity.Attachment) + case exists + } } extension AttachmentViewModel { @@ -194,7 +197,7 @@ extension AttachmentViewModel { // escape here progress.completedUnitCount = progress.totalUnitCount - return attachmentStatusResponse.value + return .uploadedMastodonAttachment(attachmentStatusResponse.value) } else { AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): attachment processing. Retry \(waitProcessRetryCount)/\(waitProcessRetryLimit)") @@ -207,7 +210,7 @@ extension AttachmentViewModel { } else { AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment success: \(attachmentUploadResponse.value.url ?? "")") - return attachmentUploadResponse.value + return .uploadedMastodonAttachment(attachmentUploadResponse.value) } } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift index a2e902ead..64441303d 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift @@ -50,7 +50,8 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable public let input: Input public let sizeLimit: SizeLimit @Published var caption = "" - + @Published public private(set) var isCaptionEditable = true + // output @Published public private(set) var output: Output? @Published public private(set) var thumbnail: UIImage? // original size image thumbnail @@ -137,6 +138,17 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable do { var output = try await load(input: input) + switch input { + case .mastodonAssetUrl: + self.isCaptionEditable = false + self.uploadState = .finish + self.output = output + self.uploadResult = .exists + return + default: + break + } + switch output { case .image(let data, _): self.output = output @@ -253,6 +265,7 @@ extension AttachmentViewModel { public enum Input: Hashable { case image(UIImage) case url(URL) + case mastodonAssetUrl(URL, String) case pickerResult(PHPickerResult) case itemProvider(NSItemProvider) } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift index f3efb9957..1f88a0bab 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift @@ -83,11 +83,6 @@ public final class ComposeContentViewController: UIViewController { ) return view }() - - deinit { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - } extension ComposeContentViewController { @@ -331,6 +326,7 @@ extension ComposeContentViewController { viewModel.$isEmojiActive.assign(to: &composeContentToolbarViewModel.$isEmojiActive) viewModel.$isContentWarningActive.assign(to: &composeContentToolbarViewModel.$isContentWarningActive) viewModel.$visibility.assign(to: &composeContentToolbarViewModel.$visibility) + viewModel.$isVisibilityButtonEnabled.assign(to: &composeContentToolbarViewModel.$isVisibilityButtonEnabled) viewModel.$maxTextInputLimit.assign(to: &composeContentToolbarViewModel.$maxTextInputLimit) viewModel.$contentWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWeightedLength) viewModel.$contentWarningWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWarningWeightedLength) diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UITextViewDelegate.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UITextViewDelegate.swift index cdf322a38..7813aa63a 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UITextViewDelegate.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UITextViewDelegate.swift @@ -37,7 +37,6 @@ extension ComposeContentViewModel: UITextViewDelegate { return } let backedString = metaText.backedString - logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(backedString)") // configure auto completion setupAutoComplete(for: textView) diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift index 128623b84..4e5c217ba 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift @@ -21,6 +21,11 @@ public protocol ComposeContentViewModelDelegate: AnyObject { } public final class ComposeContentViewModel: NSObject, ObservableObject { + + public enum ComposeContext { + case composeStatus + case editStatus(status: Status, statusSource: Mastodon.Entity.StatusSource) + } let logger = Logger(subsystem: "ComposeContentViewModel", category: "ViewModel") @@ -32,6 +37,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { // input let context: AppContext + let composeContext: ComposeContext let destination: Destination weak var delegate: ComposeContentViewModelDelegate? @@ -111,7 +117,8 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { // visibility @Published public var visibility: Mastodon.Entity.Status.Visibility - + @Published public var isVisibilityButtonEnabled = false + // language @Published public var language: String @Published public private(set) var recentLanguages: [String] @@ -141,12 +148,14 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { public init( context: AppContext, authContext: AuthContext, + composeContext: ComposeContext, destination: Destination, initialContent: String ) { self.context = context self.authContext = authContext self.destination = destination + self.composeContext = composeContext self.visibility = { // default private when user locked var visibility: Mastodon.Entity.Status.Visibility = { @@ -179,10 +188,30 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { } return visibility }() + self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel( for: authContext.mastodonAuthenticationBox.domain ) + if case let ComposeContext.editStatus(status, _) = composeContext { + if status.isContentSensitive { + isContentWarningActive = true + contentWarning = status.spoilerText ?? "" + } + if let poll = status.poll { + isPollActive = !poll.expired + pollMultipleConfigurationOption = poll.multiple + if let pollExpiresAt = poll.expiresAt { + pollExpireConfigurationOption = .init(closestDateToExpiry: pollExpiresAt) + } + pollOptions = poll.options.sortedByIndex().map { + let option = PollComposeItem.Option() + option.text = $0.title + return option + } + } + } + let recentLanguages = context.settingService.currentSetting.value?.recentLanguages ?? [] self.recentLanguages = recentLanguages self.language = recentLanguages.first ?? Locale.current.languageCode ?? "en" @@ -259,6 +288,28 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { // TODO: more limit } + switch composeContext { + case .composeStatus: + self.isVisibilityButtonEnabled = true + case let .editStatus(status, _): + if let visibility = Mastodon.Entity.Status.Visibility(rawValue: status.visibility.rawValue) { + self.visibility = visibility + } + self.isVisibilityButtonEnabled = false + self.attachmentViewModels = status.attachments.compactMap { + guard let assetURL = $0.assetURL, let url = URL(string: assetURL) else { return nil } + let attachmentViewModel = AttachmentViewModel( + api: context.apiService, + authContext: authContext, + input: .mastodonAssetUrl(url, $0.id), + sizeLimit: sizeLimit, + delegate: self + ) + attachmentViewModel.caption = $0.altDescription ?? "" + return attachmentViewModel + } + } + bind() } @@ -557,7 +608,57 @@ extension ComposeContentViewModel { visibility: visibility, language: language ) - } // end func publisher() + } + + + // MastodonEditStatusPublisher + public func statusEditPublisher() throws -> StatusPublisher? { + let authContext = self.authContext + guard case let .editStatus(status, _) = composeContext else { return nil } + + // author + let managedObjectContext = self.context.managedObjectContext + var _author: ManagedObjectRecord? + managedObjectContext.performAndWait { + _author = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: managedObjectContext)?.user.asRecord + } + guard let author = _author else { + throw AppError.badAuthentication + } + + // poll + _ = try { + guard isPollActive else { return } + let isAllNonEmpty = pollOptions + .map { $0.text } + .allSatisfy { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + guard isAllNonEmpty else { + throw ComposeError.pollHasEmptyOption + } + }() + + // save language to recent languages + if let settings = context.settingService.currentSetting.value { + settings.managedObjectContext?.performAndWait { + settings.recentLanguages = [language] + settings.recentLanguages.filter { $0 != language } + } + } + + return MastodonEditStatusPublisher(statusID: status.id, + author: author, + isContentWarningComposing: isContentWarningActive, + contentWarning: contentWarning, + content: content, + isMediaSensitive: isContentWarningActive, + attachmentViewModels: attachmentViewModels, + isPollComposing: isPollActive, + pollOptions: pollOptions, + pollExpireConfigurationOption: pollExpireConfigurationOption, + pollMultipleConfigurationOption: pollMultipleConfigurationOption, + visibility: visibility, + language: language) + } + } extension ComposeContentViewModel { diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusEditPublisher.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusEditPublisher.swift new file mode 100644 index 000000000..76ad758bc --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusEditPublisher.swift @@ -0,0 +1,183 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import CoreData +import CoreDataStack +import MastodonCore +import MastodonSDK + +public final class MastodonEditStatusPublisher: NSObject, ProgressReporting { + + // Input + public let statusID: Status.ID + public let author: ManagedObjectRecord + + // content warning + public let isContentWarningComposing: Bool + public let contentWarning: String + // status content + public let content: String + // media + public let isMediaSensitive: Bool + public let attachmentViewModels: [AttachmentViewModel] + // poll + public let isPollComposing: Bool + public let pollOptions: [PollComposeItem.Option] + public let pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option + public let pollMultipleConfigurationOption: PollComposeItem.MultipleConfiguration.Option + // visibility + public let visibility: Mastodon.Entity.Status.Visibility + // language + public let language: String + + // Output + let _progress = Progress() + public var progress: Progress { _progress } + @Published var _state: StatusPublisherState = .pending + public var state: Published.Publisher { $_state } + + public var reactor: StatusPublisherReactor? + + public init( + statusID: Status.ID, + author: ManagedObjectRecord, + isContentWarningComposing: Bool, + contentWarning: String, + content: String, + isMediaSensitive: Bool, + attachmentViewModels: [AttachmentViewModel], + isPollComposing: Bool, + pollOptions: [PollComposeItem.Option], + pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option, + pollMultipleConfigurationOption: PollComposeItem.MultipleConfiguration.Option, + visibility: Mastodon.Entity.Status.Visibility, + language: String + ) { + self.author = author + self.statusID = statusID + self.isContentWarningComposing = isContentWarningComposing + self.contentWarning = contentWarning + self.content = content + self.isMediaSensitive = isMediaSensitive + self.attachmentViewModels = attachmentViewModels + self.isPollComposing = isPollComposing + self.pollOptions = pollOptions + self.pollExpireConfigurationOption = pollExpireConfigurationOption + self.pollMultipleConfigurationOption = pollMultipleConfigurationOption + self.visibility = visibility + self.language = language + } + +} + +// MARK: - StatusPublisher +extension MastodonEditStatusPublisher: StatusPublisher { + + public func publish( + api: APIService, + authContext: AuthContext + ) async throws -> StatusPublishResult { + let idempotencyKey = UUID().uuidString + + let publishStatusTaskStartDelayWeight: Int64 = 20 + let publishStatusTaskStartDelayCount: Int64 = publishStatusTaskStartDelayWeight + + let publishAttachmentTaskWeight: Int64 = 100 + let publishAttachmentTaskCount: Int64 = Int64(attachmentViewModels.count) * publishAttachmentTaskWeight + + let publishStatusTaskWeight: Int64 = 20 + let publishStatusTaskCount: Int64 = publishStatusTaskWeight + + let taskCount = [ + publishStatusTaskStartDelayCount, + publishAttachmentTaskCount, + publishStatusTaskCount + ].reduce(0, +) + progress.totalUnitCount = taskCount + progress.completedUnitCount = 0 + + // start delay + try? await Task.sleep(nanoseconds: 1 * .second) + progress.completedUnitCount += publishStatusTaskStartDelayWeight + + // Task: attachment + + var attachmentIDs: [Mastodon.Entity.Attachment.ID] = [] + for attachmentViewModel in attachmentViewModels { + // set progress + progress.addChild(attachmentViewModel.progress, withPendingUnitCount: publishAttachmentTaskWeight) + // upload media + do { + switch attachmentViewModel.uploadResult { + case .none: + // precondition: all media uploaded + throw AppError.badRequest + case .exists: + guard case let AttachmentViewModel.Input.mastodonAssetUrl(_, attachmentId) = attachmentViewModel.input else { + throw AppError.badRequest + } + attachmentIDs.append(attachmentId) + break + case let .uploadedMastodonAttachment(attachment): + attachmentIDs.append(attachment.id) + + let caption = attachmentViewModel.caption + guard !caption.isEmpty else { continue } + + _ = try await api.updateMedia( + domain: authContext.mastodonAuthenticationBox.domain, + attachmentID: attachment.id, + query: .init( + file: nil, + thumbnail: nil, + description: caption, + focus: nil + ), + mastodonAuthenticationBox: authContext.mastodonAuthenticationBox + ).singleOutput() + + // TODO: allow background upload + // let attachment = try await attachmentViewModel.upload(context: uploadContext) + // let attachmentID = attachment.id + // attachmentIDs.append(attachmentID) + } + } catch { + _state = .failure(error) + throw error + } + } + + let pollOptions: [String]? = { + guard self.isPollComposing else { return nil } + let options = self.pollOptions.compactMap { $0.text.trimmingCharacters(in: .whitespacesAndNewlines) } + return options.isEmpty ? nil : options + }() + let pollExpiresIn: Int? = { + guard self.isPollComposing else { return nil } + guard pollOptions != nil else { return nil } + return self.pollExpireConfigurationOption.seconds + }() + + let query = Mastodon.API.Statuses.EditStatusQuery( + status: content, + mediaIDs: attachmentIDs.isEmpty ? nil : attachmentIDs, + pollOptions: pollOptions, + pollExpiresIn: pollExpiresIn, + pollMultipleAnswers: pollMultipleConfigurationOption, + sensitive: isMediaSensitive, + spoilerText: isContentWarningComposing ? contentWarning : nil, + visibility: visibility, + language: language + ) + + let editStatusResponse = try await api.publishStatusEdit(forStatusID: statusID, + editStatusQuery: query, + authenticationBox: authContext.mastodonAuthenticationBox) + + progress.completedUnitCount += publishStatusTaskCount + _state = .success + + return .edit(editStatusResponse) + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift index 7bd57c9c5..8cb32075b 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift @@ -119,31 +119,35 @@ extension MastodonStatusPublisher: StatusPublisher { progress.addChild(attachmentViewModel.progress, withPendingUnitCount: publishAttachmentTaskWeight) // upload media do { - guard let attachment = attachmentViewModel.uploadResult else { + switch attachmentViewModel.uploadResult { + case .none: // precondition: all media uploaded throw AppError.badRequest + case .exists: + break + case let .uploadedMastodonAttachment(attachment): + attachmentIDs.append(attachment.id) + + let caption = attachmentViewModel.caption + guard !caption.isEmpty else { continue } + + _ = try await api.updateMedia( + domain: authContext.mastodonAuthenticationBox.domain, + attachmentID: attachment.id, + query: .init( + file: nil, + thumbnail: nil, + description: caption, + focus: nil + ), + mastodonAuthenticationBox: authContext.mastodonAuthenticationBox + ).singleOutput() + + // TODO: allow background upload + // let attachment = try await attachmentViewModel.upload(context: uploadContext) + // let attachmentID = attachment.id + // attachmentIDs.append(attachmentID) } - attachmentIDs.append(attachment.id) - - let caption = attachmentViewModel.caption - guard !caption.isEmpty else { continue } - - _ = try await api.updateMedia( - domain: authContext.mastodonAuthenticationBox.domain, - attachmentID: attachment.id, - query: .init( - file: nil, - thumbnail: nil, - description: caption, - focus: nil - ), - mastodonAuthenticationBox: authContext.mastodonAuthenticationBox - ).singleOutput() - - // TODO: allow background upload - // let attachment = try await attachmentViewModel.upload(context: uploadContext) - // let attachmentID = attachment.id - // attachmentIDs.append(attachmentID) } catch { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment fail: \(error.localizedDescription)") _state = .failure(error) @@ -188,7 +192,7 @@ extension MastodonStatusPublisher: StatusPublisher { _state = .success logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): status published: \(publishResponse.value.id)") - return .mastodon(publishResponse) + return .post(publishResponse) } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift index 440af795e..aaf2d2dca 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift @@ -24,7 +24,7 @@ extension ComposeContentToolbarView { var allVisibilities: [Mastodon.Entity.Status.Visibility] { return [.public, .private, .direct] } - + @Published var isVisibilityButtonEnabled = false @Published var isPollActive = false @Published var isEmojiActive = false @Published var isContentWarningActive = false diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift index 68e0112f4..b480834c2 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift @@ -70,7 +70,9 @@ struct ComposeContentToolbarView: View { } label: { label(for: viewModel.visibility.image) .accessibilityLabel(L10n.Scene.Compose.Keyboard.selectVisibilityEntry(viewModel.visibility.title)) + .opacity(viewModel.isVisibilityButtonEnabled ? 1.0 : 0.5) } + .disabled(!viewModel.isVisibilityButtonEnabled) .frame(width: 48, height: 48) case .poll: Button { diff --git a/MastodonSDK/Sources/MastodonUI/SwiftUI/MetaTextViewRepresentable.swift b/MastodonSDK/Sources/MastodonUI/SwiftUI/MetaTextViewRepresentable.swift index 9e4d968de..35922a14c 100644 --- a/MastodonSDK/Sources/MastodonUI/SwiftUI/MetaTextViewRepresentable.swift +++ b/MastodonSDK/Sources/MastodonUI/SwiftUI/MetaTextViewRepresentable.swift @@ -62,6 +62,10 @@ public struct MetaTextViewRepresentable: UIViewRepresentable { public func updateUIView(_ metaTextView: MetaTextView, context: Context) { // update layout context.coordinator.widthLayoutConstraint.constant = width + + // trigger layout engine update to adjust to text height + metaText.textView.setNeedsLayout() + metaText.textView.layoutIfNeeded() } public func makeCoordinator() -> Coordinator { diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift index 05c8eee14..173f043f6 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift @@ -179,7 +179,7 @@ extension MediaView.Configuration { } extension MediaView { - public static func configuration(status: Status) -> [MediaView.Configuration] { + public static func configuration(status: StatusCompatible) -> [MediaView.Configuration] { func videoInfo(from attachment: MastodonAttachment) -> MediaView.Configuration.VideoInfo { MediaView.Configuration.VideoInfo( aspectRadio: attachment.size, @@ -190,7 +190,7 @@ extension MediaView { ) } - let status = status.reblog ?? status + let status: StatusCompatible = status.reblog ?? status let attachments = status.attachments let configurations = attachments.enumerated().map { (idx, attachment) -> MediaView.Configuration in let configuration: MediaView.Configuration = { diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift index 9196c340e..4cefb6714 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift @@ -597,6 +597,10 @@ extension NotificationView: StatusViewDelegate { public func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton) { assertionFailure() } + + public func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, showEditHistory button: UIButton) { + assertionFailure() + } public func statusView(_ statusView: StatusView, accessibilityActivate: Void) { assertionFailure() diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift index ef40ab7fc..7569b640a 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusAuthorView.swift @@ -81,6 +81,7 @@ public class StatusAuthorView: UIStackView { case .notificationQuote: layoutNotificationQuote() case .composeStatusReplica: layoutComposeStatusReplica() case .composeStatusAuthor: layoutComposeStatusAuthor() + case .editHistory: layoutBase() } } @@ -158,6 +159,10 @@ extension StatusAuthorView { public func setupAuthorMenu(menuContext: AuthorMenuContext) -> (UIMenu, [UIAccessibilityCustomAction]) { var actions = [MastodonMenu.Action]() + if menuContext.isMyself { + actions.append(.editStatus) + } + if !menuContext.isMyself { if let statusLanguage = menuContext.statusLanguage, menuContext.isTranslationEnabled, !menuContext.isTranslated { actions.append( diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusMetricRowView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusMetricRowView.swift new file mode 100644 index 000000000..b190a6f8e --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusMetricRowView.swift @@ -0,0 +1,149 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import UIKit +import MastodonAsset + +public final class StatusMetricRowView: UIButton { + let icon: UIImageView + let textLabel: UILabel + let detailLabel: UILabel + let chevron: UIImageView + + private var disposableConstraints: [NSLayoutConstraint] = [] + private var isVerticalAxis: Bool? + + public init(iconImage: UIImage? = nil, text: String? = nil, detailText: String? = nil) { + + icon = UIImageView(image: iconImage?.withRenderingMode(.alwaysTemplate)) + icon.tintColor = Asset.Colors.Label.secondary.color + icon.translatesAutoresizingMaskIntoConstraints = false + + textLabel = UILabel() + textLabel.translatesAutoresizingMaskIntoConstraints = false + textLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + textLabel.textColor = Asset.Colors.Label.primary.color + textLabel.numberOfLines = 0 + textLabel.text = text + + detailLabel = UILabel() + detailLabel.translatesAutoresizingMaskIntoConstraints = false + detailLabel.text = detailText + detailLabel.textColor = Asset.Colors.Label.secondary.color + detailLabel.numberOfLines = 0 + detailLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + + chevron = UIImageView(image: UIImage(systemName: "chevron.right")) + chevron.translatesAutoresizingMaskIntoConstraints = false + chevron.tintColor = Asset.Colors.Label.tertiary.color + + super.init(frame: .zero) + + addSubview(icon) + addSubview(textLabel) + addSubview(detailLabel) + addSubview(chevron) + + accessibilityTraits.insert(.button) + + setupConstraints() + traitCollectionDidChange(nil) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + let isVerticalAxis = traitCollection.preferredContentSizeCategory.isAccessibilityCategory + + if isVerticalAxis { + detailLabel.textAlignment = .natural + } else { + switch traitCollection.layoutDirection { + case .leftToRight, .unspecified: detailLabel.textAlignment = .right + case .rightToLeft: detailLabel.textAlignment = .left + @unknown default: + break + } + } + + guard isVerticalAxis != self.isVerticalAxis else { return } + self.isVerticalAxis = isVerticalAxis + NSLayoutConstraint.deactivate(disposableConstraints) + + if isVerticalAxis { + disposableConstraints = [ + textLabel.topAnchor.constraint(equalTo: topAnchor, constant: 11), + + detailLabel.leadingAnchor.constraint(equalTo: textLabel.leadingAnchor), + detailLabel.topAnchor.constraint(equalTo: textLabel.bottomAnchor, constant: 8), + bottomAnchor.constraint(equalTo: detailLabel.bottomAnchor, constant: 11), + + chevron.leadingAnchor.constraint(greaterThanOrEqualTo: textLabel.trailingAnchor, constant: 12), + chevron.leadingAnchor.constraint(greaterThanOrEqualTo: detailLabel.trailingAnchor, constant: 12), + ] + } else { + disposableConstraints = [ + textLabel.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: 11), + textLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + bottomAnchor.constraint(greaterThanOrEqualTo: textLabel.bottomAnchor, constant: 11), + + detailLabel.leadingAnchor.constraint(greaterThanOrEqualTo: textLabel.trailingAnchor, constant: 8), + + detailLabel.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: 11), + detailLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + bottomAnchor.constraint(greaterThanOrEqualTo: detailLabel.bottomAnchor, constant: 11), + + chevron.leadingAnchor.constraint(equalTo: detailLabel.trailingAnchor, constant: 12), + ] + } + NSLayoutConstraint.activate(disposableConstraints) + } + + var margin: CGFloat = 0 { + didSet { + layoutMargins = UIEdgeInsets(horizontal: margin, vertical: 0) + } + } + + private func setupConstraints() { + icon.setContentHuggingPriority(.defaultHigh, for: .horizontal) + chevron.setContentHuggingPriority(.defaultHigh, for: .horizontal) + chevron.setContentCompressionResistancePriority(.required, for: .horizontal) + let constraints = [ + icon.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: 10), + icon.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + icon.centerYAnchor.constraint(equalTo: centerYAnchor), + textLabel.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 16), + icon.widthAnchor.constraint(greaterThanOrEqualToConstant: 24), + icon.heightAnchor.constraint(greaterThanOrEqualToConstant: 24), + bottomAnchor.constraint(greaterThanOrEqualTo: icon.bottomAnchor, constant: 10), + + chevron.centerYAnchor.constraint(equalTo: centerYAnchor), + layoutMarginsGuide.trailingAnchor.constraint(equalTo: chevron.trailingAnchor), + ] + + NSLayoutConstraint.activate(constraints) + } + + public override var isHighlighted: Bool { + get { super.isHighlighted } + set { + super.isHighlighted = newValue + if newValue { + backgroundColor = Asset.Colors.selectionHighlight.color + } else { + backgroundColor = .clear + } + } + } + + public override var accessibilityLabel: String? { + get { textLabel.text } + set {} + } + + public override var accessibilityValue: String? { + get { detailLabel.text } + set {} + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusMetricView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusMetricView.swift index d5f6a0709..74b3e7125 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusMetricView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusMetricView.swift @@ -5,29 +5,45 @@ // Created by MainasuK on 2022-1-17. // -import os.log import UIKit +import MastodonAsset +import MastodonLocalization protocol StatusMetricViewDelegate: AnyObject { func statusMetricView(_ statusMetricView: StatusMetricView, reblogButtonDidPressed button: UIButton) func statusMetricView(_ statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton) + func statusMetricView(_ statusMetricView: StatusMetricView, didPressEditHistoryButton button: UIButton) } public final class StatusMetricView: UIView { - - let logger = Logger(subsystem: "StatusMetricView", category: "View") - + weak var delegate: StatusMetricViewDelegate? + + var margin: CGFloat = 0 { + didSet { + dateAdaptiveMarginContainerView.margin = margin + reblogButton.margin = margin + favoriteButton.margin = margin + editHistoryButton.margin = margin + } + } // container - public let containerStackView: UIStackView = { + private let containerStackView: UIStackView = { let stackView = UIStackView() - stackView.axis = .horizontal - stackView.spacing = 4 + stackView.axis = .vertical + stackView.alignment = .leading return stackView }() + + private let separator: UIView = { + let view = UIView() + view.backgroundColor = Asset.Theme.Mastodon.separator.color + return view + }() // date + let dateAdaptiveMarginContainerView = AdaptiveMarginContainerView() public let dateLabel: UILabel = { let label = UILabel() label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) @@ -35,33 +51,34 @@ public final class StatusMetricView: UIView { label.adjustsFontSizeToFitWidth = true label.minimumScaleFactor = 0.5 label.numberOfLines = 2 + label.textColor = Asset.Colors.Label.secondary.color return label }() - // meter - public let meterContainer: UIStackView = { + // reblog meter + private let buttonStackView: UIStackView = { let stackView = UIStackView() - stackView.axis = .horizontal - stackView.spacing = 20 + stackView.axis = .vertical + stackView.alignment = .leading return stackView }() - - // reblog meter - public let reblogButton: UIButton = { - let button = UIButton(type: .system) - button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) - button.setTitle("0 reblog", for: .normal) + + public let reblogButton: StatusMetricRowView = { + let button = StatusMetricRowView(iconImage: Asset.Arrow.repeat.image, text: L10n.Common.Controls.Status.Buttons.reblogsTitle, detailText: "") return button }() // favorite meter - public let favoriteButton: UIButton = { - let button = UIButton(type: .system) - button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) - button.setTitle("0 favorite", for: .normal) + public let favoriteButton: StatusMetricRowView = { + let button = StatusMetricRowView(iconImage: UIImage(systemName: "star"), text: L10n.Common.Controls.Status.Buttons.favoritesTitle, detailText: "") return button }() - + + public let editHistoryButton: StatusMetricRowView = { + let button = StatusMetricRowView(iconImage: Asset.Scene.EditHistory.edit.image, text: L10n.Common.Controls.Status.Buttons.editHistoryTitle) + return button + }() + public override init(frame: CGRect) { super.init(frame: frame) _init() @@ -71,7 +88,6 @@ public final class StatusMetricView: UIView { super.init(coder: coder) _init() } - } extension StatusMetricView { @@ -79,40 +95,63 @@ extension StatusMetricView { // container: H - [ dateLabel | meterContainer ] containerStackView.translatesAutoresizingMaskIntoConstraints = false addSubview(containerStackView) + + separator.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(separator) + + reblogButton.translatesAutoresizingMaskIntoConstraints = false + buttonStackView.addArrangedSubview(reblogButton) + + favoriteButton.translatesAutoresizingMaskIntoConstraints = false + buttonStackView.addArrangedSubview(favoriteButton) + + editHistoryButton.translatesAutoresizingMaskIntoConstraints = false + buttonStackView.addArrangedSubview(editHistoryButton) + + buttonStackView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(buttonStackView) + containerStackView.setCustomSpacing(11, after: buttonStackView) + + dateLabel.translatesAutoresizingMaskIntoConstraints = false + dateAdaptiveMarginContainerView.translatesAutoresizingMaskIntoConstraints = false + dateAdaptiveMarginContainerView.contentView = dateLabel + containerStackView.addArrangedSubview(dateAdaptiveMarginContainerView) + NSLayoutConstraint.activate([ containerStackView.topAnchor.constraint(equalTo: topAnchor, constant: 8), containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor, constant: 12), + + separator.heightAnchor.constraint(equalToConstant: 0.5), + + buttonStackView.widthAnchor.constraint(equalTo: containerStackView.widthAnchor), + + reblogButton.widthAnchor.constraint(equalTo: buttonStackView.widthAnchor), + favoriteButton.widthAnchor.constraint(equalTo: reblogButton.widthAnchor), + editHistoryButton.widthAnchor.constraint(equalTo: reblogButton.widthAnchor), + dateLabel.widthAnchor.constraint(equalTo: reblogButton.widthAnchor), ]) - - containerStackView.addArrangedSubview(dateLabel) - dateLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - containerStackView.addArrangedSubview(meterContainer) - - // meterContainer: H - [ reblogButton | favoriteButton ] - meterContainer.addArrangedSubview(reblogButton) - meterContainer.addArrangedSubview(favoriteButton) - reblogButton.setContentHuggingPriority(.required - 2, for: .horizontal) - reblogButton.setContentCompressionResistancePriority(.required - 2, for: .horizontal) - favoriteButton.setContentHuggingPriority(.required - 1, for: .horizontal) - favoriteButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - - reblogButton.addTarget(self, action: #selector(StatusMetricView.reblogButtonDidPressed(_:)), for: .touchUpInside) - favoriteButton.addTarget(self, action: #selector(StatusMetricView.favoriteButtonDidPressed(_:)), for: .touchUpInside) + + reblogButton.addTarget(self, action: #selector(StatusMetricView.didPressReblogButton(_:)), for: .touchUpInside) + favoriteButton.addTarget(self, action: #selector(StatusMetricView.didPressFavoriteButton(_:)), for: .touchUpInside) + editHistoryButton.addTarget(self, action: #selector(StatusMetricView.didPressEditHistoryButton(_:)), for: .touchUpInside) + } } extension StatusMetricView { - @objc private func reblogButtonDidPressed(_ sender: UIButton) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + @objc private func didPressReblogButton(_ sender: UIButton) { delegate?.statusMetricView(self, reblogButtonDidPressed: sender) } - @objc private func favoriteButtonDidPressed(_ sender: UIButton) { - logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + @objc private func didPressFavoriteButton(_ sender: UIButton) { delegate?.statusMetricView(self, favoriteButtonDidPressed: sender) } - + + @objc private func didPressEditHistoryButton(_ sender: UIButton) { + delegate?.statusMetricView(self, didPressEditHistoryButton: sender) + } + } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index 6e950bc95..03eff8c27 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -39,6 +39,33 @@ extension StatusView { } extension StatusView { + + public func configure(status: Status, statusEdit: StatusEdit) { + viewModel.objects.insert(status) + if let reblog = status.reblog { + viewModel.objects.insert(reblog) + } + + configureHeader(status: status) + let author = (status.reblog ?? status).author + configureAuthor(author: author) + let timestamp = (status.reblog ?? status).publisher(for: \.createdAt) + configureTimestamp(timestamp: timestamp.eraseToAnyPublisher()) + configureApplicationName(status.application?.name) + configureMedia(status: status) + configurePollHistory(statusEdit: statusEdit) + configureCard(status: status) + configureToolbar(status: status) + configureFilter(status: status) + configureContent(statusEdit: statusEdit, status: status) + configureMedia(status: statusEdit) + actionToolbarAdaptiveMarginContainerView.isHidden = true + authorView.menuButton.isHidden = true + headerAdaptiveMarginContainerView.isHidden = true + viewModel.isSensitiveToggled = true + viewModel.isContentReveal = true + } + public func configure(status: Status) { viewModel.objects.insert(status) if let reblog = status.reblog { @@ -50,6 +77,7 @@ extension StatusView { configureAuthor(author: author) let timestamp = (status.reblog ?? status).publisher(for: \.createdAt) configureTimestamp(timestamp: timestamp.eraseToAnyPublisher()) + configureApplicationName(status.application?.name) configureContent(status: status) configureMedia(status: status) configurePoll(status: status) @@ -113,7 +141,7 @@ extension StatusView { let header = ViewModel.Header.reply(info: .init(header: metaContent)) return header } - + if let replyTo = status.replyTo { // A. replyTo status exist let header = createHeader(name: replyTo.author.displayNameWithFallback, emojis: replyTo.author.emojis.asDictionary) @@ -234,14 +262,21 @@ extension StatusView { private func configureTimestamp(timestamp: AnyPublisher) { // timestamp - viewModel.timestampFormatter = { (date: Date) in - date.localizedSlowedTimeAgoSinceNow + viewModel.timestampFormatter = { (date: Date, isEdited: Bool) in + if isEdited { + return L10n.Common.Controls.Status.editedAtTimestampPrefix(date.localizedSlowedTimeAgoSinceNow) + } + return date.localizedSlowedTimeAgoSinceNow } timestamp .map { $0 as Date? } .assign(to: \.timestamp, on: viewModel) .store(in: &disposeBag) } + + private func configureApplicationName(_ applicationName: String?) { + viewModel.applicationName = applicationName + } func revertTranslation() { guard let originalStatus = viewModel.originalStatus else { return } @@ -281,7 +316,27 @@ extension StatusView { viewModel.content = PlaintextMetaContent(string: "") } } - + + private func configureContent(statusEdit: StatusEdit, status: Status) { + statusEdit.spoilerText.map { + viewModel.spoilerContent = PlaintextMetaContent(string: $0) + } + + // language + viewModel.language = (status.reblog ?? status).language + // content + do { + let content = MastodonContent(content: statusEdit.content, emojis: statusEdit.emojis.asDictionary) + let metaContent = try MastodonMetaContent.convert(document: content) + viewModel.content = metaContent + viewModel.translatedFromLanguage = nil + viewModel.isCurrentlyTranslating = false + } catch { + assertionFailure(error.localizedDescription) + viewModel.content = PlaintextMetaContent(string: "") + } + } + private func configureContent(status: Status) { guard status.translatedContent == nil else { return configureTranslated(status: status) @@ -327,7 +382,7 @@ extension StatusView { .store(in: &disposeBag) } - private func configureMedia(status: Status) { + private func configureMedia(status: StatusCompatible) { let status = status.reblog ?? status viewModel.isMediaSensitive = status.isMediaSensitive @@ -335,6 +390,19 @@ extension StatusView { let configurations = MediaView.configuration(status: status) viewModel.mediaViewConfigurations = configurations } + + private func configurePollHistory(statusEdit: StatusEdit) { + guard let poll = statusEdit.poll else { return } + + let pollItems = poll.options.map { PollItem.history(option: $0) } + self.viewModel.pollItems = pollItems + pollStatusStackView.isHidden = true + + var _snapshot = NSDiffableDataSourceSnapshot() + _snapshot.appendSections([.main]) + _snapshot.appendItems(pollItems, toSection: .main) + pollTableViewDiffableDataSource?.applySnapshotUsingReloadData(_snapshot) + } private func configurePoll(status: Status) { let status = status.reblog ?? status @@ -433,6 +501,16 @@ extension StatusView { .map(Int.init) .assign(to: \.favoriteCount, on: viewModel) .store(in: &disposeBag) + status.publisher(for: \.editedAt) + .assign(to: \.editedAt, on: viewModel) + .store(in: &disposeBag) + + status.publisher(for: \.editHistory) + .compactMap({ guard let edits = $0 else { return nil } + return Array(edits) + }) + .assign(to: \.statusEdits, on: viewModel) + .store(in: &disposeBag) // relationship status.publisher(for: \.rebloggedBy) diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index 929433b75..f2a9a1810 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -30,7 +30,7 @@ extension StatusView { public var context: AppContext? public var authContext: AuthContext? public var originalStatus: Status? - + // Header @Published public var header: Header = .none @@ -52,8 +52,9 @@ extension StatusView { @Published public var translatedUsingProvider: String? @Published public var timestamp: Date? - public var timestampFormatter: ((_ date: Date) -> String)? + public var timestampFormatter: ((_ date: Date, _ isEdited: Bool) -> String)? @Published public var timestampText = "" + @Published public var applicationName: String? = nil // Spoiler @Published public var spoilerContent: MetaContent? @@ -101,6 +102,9 @@ extension StatusView { @Published public var replyCount: Int = 0 @Published public var reblogCount: Int = 0 @Published public var favoriteCount: Int = 0 + + @Published public var statusEdits: [StatusEdit] = [] + @Published public var editedAt: Date? = nil // Filter @Published public var activeFilters: [Mastodon.Entity.Filter] = [] @@ -264,16 +268,19 @@ extension StatusView.ViewModel { } .store(in: &disposeBag) // timestamp - Publishers.CombineLatest( + Publishers.CombineLatest3( $timestamp, + $editedAt, timestampUpdatePublisher.prepend(Date()).eraseToAnyPublisher() ) - .compactMap { [weak self] timestamp, _ -> String? in + .compactMap { [weak self] timestamp, editedAt, _ -> String? in guard let self = self else { return nil } - guard let timestamp = timestamp, - let text = self.timestampFormatter?(timestamp) - else { return "" } - return text + if let timestamp = editedAt, let text = self.timestampFormatter?(timestamp, true) { + return text + } else if let timestamp = timestamp, let text = self.timestampFormatter?(timestamp, false) { + return text + } + return "" } .removeDuplicates() .assign(to: &$timestampText) @@ -304,6 +311,16 @@ extension StatusView.ViewModel { // statusView.spoilerBannerView.label.reset() } + if statusView.style == .editHistory, let spoilerContent = spoilerContent, !spoilerContent.string.isEmpty { + statusView.historyContentWarningLabel.configure(content: spoilerContent) + statusView.historyContentWarningAdaptiveMarginContainerView.isHidden = statusView.style != .editHistory + statusView.setContentSensitiveeToggleButtonDisplay(isDisplay: false) + } else { + statusView.historyContentWarningLabel.reset() + statusView.historyContentWarningAdaptiveMarginContainerView.isHidden = true + statusView.setContentSensitiveeToggleButtonDisplay(isDisplay: false) + } + let paragraphStyle = statusView.contentMetaText.paragraphStyle if let language = language { if #available(iOS 16, *) { @@ -571,12 +588,13 @@ extension StatusView.ViewModel { favoriteButtonTitle ).map { $0.count + $1.count } - Publishers.CombineLatest( + Publishers.CombineLatest3( $timestamp, + $applicationName, metricButtonTitleLength ) - .sink { timestamp, metricButtonTitleLength in - let text: String = { + .sink { timestamp, applicationName, metricButtonTitleLength in + let dateString: String = { guard let timestamp = timestamp else { return " " } let formatter = DateFormatter() @@ -591,20 +609,42 @@ extension StatusView.ViewModel { } return formatter.string(from: timestamp) }() - + + let text: String + if let applicationName { + text = L10n.Common.Controls.Status.postedViaApplication(dateString, applicationName) + } else { + text = dateString + } + statusView.statusMetricView.dateLabel.text = text } .store(in: &disposeBag) - reblogButtonTitle - .sink { title in - statusView.statusMetricView.reblogButton.setTitle(title, for: .normal) + $reblogCount + .sink { count in + statusView.statusMetricView.reblogButton.isHidden = count == 0 + statusView.statusMetricView.reblogButton.detailLabel.text = count.formatted() } .store(in: &disposeBag) - favoriteButtonTitle - .sink { title in - statusView.statusMetricView.favoriteButton.setTitle(title, for: .normal) + $favoriteCount + .sink { count in + statusView.statusMetricView.favoriteButton.isHidden = count == 0 + statusView.statusMetricView.favoriteButton.detailLabel.text = count.formatted() + } + .store(in: &disposeBag) + + $editedAt + .sink { editedAt in + if let editedAt { + let relativeDateFormatter = RelativeDateTimeFormatter() + let relativeDate = relativeDateFormatter.localizedString(for: editedAt, relativeTo: Date()) + statusView.statusMetricView.editHistoryButton.detailLabel.text = L10n.Common.Controls.Status.Buttons.editHistoryDetail(relativeDate) + statusView.statusMetricView.editHistoryButton.isHidden = false + } else { + statusView.statusMetricView.editHistoryButton.isHidden = true + } } .store(in: &disposeBag) } @@ -705,8 +745,10 @@ extension StatusView.ViewModel { strings.append(info.header.string) strings.append(authorName?.string) } - - strings.append(timestamp) + + if statusView.style != .editHistory { + strings.append(timestamp) + } return strings.compactMap { $0 }.joined(separator: ", ") } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index a2b0e9158..7082fec32 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -33,6 +33,7 @@ public protocol StatusViewDelegate: AnyObject { func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, reblogButtonDidPressed button: UIButton) func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton) + func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, showEditHistory button: UIButton) func statusView(_ statusView: StatusView, cardControl: StatusCardControl, didTapURL url: URL) func statusView(_ statusView: StatusView, cardControlMenu: StatusCardControl) -> UIMenu? @@ -87,6 +88,46 @@ public final class StatusView: UIView { // author let authorAdaptiveMarginContainerView = AdaptiveMarginContainerView() public let authorView = StatusAuthorView() + + // edit history content warning + lazy var historyContentWarningAdaptiveMarginContainerView: AdaptiveMarginContainerView = { + let view = AdaptiveMarginContainerView() + view.contentView = historyContentWarningContainerView + view.margin = StatusView.containerLayoutMargin + return view + }() + + let historyContentWarningLabel: MetaLabel = { + let label = MetaLabel(style: .statusSpoilerBanner) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + lazy var historyContentWarningContainerView: UIView = { + let container = UIView() + container.translatesAutoresizingMaskIntoConstraints = false + + let divider = UIView() + divider.backgroundColor = Asset.Colors.Label.secondary.color + divider.translatesAutoresizingMaskIntoConstraints = false + + container.addSubview(historyContentWarningLabel) + container.addSubview(divider) + + NSLayoutConstraint.activate([ + historyContentWarningLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor), + historyContentWarningLabel.topAnchor.constraint(equalTo: container.topAnchor), + historyContentWarningLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor), + + divider.leadingAnchor.constraint(equalTo: container.leadingAnchor), + divider.topAnchor.constraint(equalTo: historyContentWarningLabel.bottomAnchor, constant: 16), + divider.trailingAnchor.constraint(equalTo: container.trailingAnchor), + divider.heightAnchor.constraint(equalToConstant: 2), + divider.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + + return container + }() // content let contentAdaptiveMarginContainerView = AdaptiveMarginContainerView() @@ -255,7 +296,6 @@ public final class StatusView: UIView { public let actionToolbarContainer = ActionToolbarContainer() // metric - let statusMetricViewAdaptiveMarginContainerView = AdaptiveMarginContainerView() public let statusMetricView = StatusMetricView() // filter hint @@ -397,6 +437,7 @@ extension StatusView { case notificationQuote case composeStatusReplica case composeStatusAuthor + case editHistory } } @@ -411,6 +452,7 @@ extension StatusView.Style { case .notificationQuote: notificationQuote(statusView: statusView) case .composeStatusReplica: composeStatusReplica(statusView: statusView) case .composeStatusAuthor: composeStatusAuthor(statusView: statusView) + case .editHistory: editHistory(statusView: statusView) } statusView.authorView.layout(style: self) @@ -448,6 +490,9 @@ extension StatusView.Style { statusView.authorAdaptiveMarginContainerView.contentView = statusView.authorView statusView.authorAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin statusView.containerStackView.addArrangedSubview(statusView.authorAdaptiveMarginContainerView) + + // history content warning + statusView.containerStackView.addArrangedSubview(statusView.historyContentWarningAdaptiveMarginContainerView) // content container: V - [ contentMetaText statusCardControl ] statusView.contentContainer.axis = .vertical @@ -534,16 +579,8 @@ extension StatusView.Style { base(statusView: statusView) // override the base style // statusMetricView - statusView.statusMetricViewAdaptiveMarginContainerView.contentView = statusView.statusMetricView - statusView.statusMetricViewAdaptiveMarginContainerView.margin = StatusView.containerLayoutMargin - statusView.containerStackView.addArrangedSubview(statusView.statusMetricViewAdaptiveMarginContainerView) - - UIContentSizeCategory.publisher - .sink { category in - statusView.statusMetricView.containerStackView.axis = category > .accessibilityLarge ? .vertical : .horizontal - statusView.statusMetricView.containerStackView.alignment = category > .accessibilityLarge ? .leading : .fill - } - .store(in: &statusView._disposeBag) + statusView.statusMetricView.margin = StatusView.containerLayoutMargin + statusView.containerStackView.addArrangedSubview(statusView.statusMetricView) } func report(statusView: StatusView) { @@ -585,6 +622,9 @@ extension StatusView.Style { statusView.actionToolbarAdaptiveMarginContainerView.removeFromSuperview() } + func editHistory(statusView: StatusView) { + base(statusView: statusView) + } } extension StatusView { @@ -653,7 +693,7 @@ extension StatusView: AdaptiveContainerView { contentAdaptiveMarginContainerView.margin = margin pollAdaptiveMarginContainerView.margin = margin actionToolbarAdaptiveMarginContainerView.margin = margin - statusMetricViewAdaptiveMarginContainerView.margin = margin + statusMetricView.margin = margin } } @@ -744,6 +784,10 @@ extension StatusView: StatusMetricViewDelegate { func statusMetricView(_ statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton) { delegate?.statusView(self, statusMetricView: statusMetricView, favoriteButtonDidPressed: button) } + + func statusMetricView(_ statusMetricView: StatusMetricView, didPressEditHistoryButton button: UIButton) { + delegate?.statusView(self, statusMetricView: statusMetricView, showEditHistory: button) + } } // MARK: - MastodonMenuDelegate diff --git a/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift b/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift index 6fd5df772..a2875ba15 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Menu/MastodonMenu.swift @@ -19,10 +19,18 @@ public enum MastodonMenu { ) -> UIMenu { var children: [UIMenuElement] = [] for action in actions { - let element = action.build(delegate: delegate) - children.append(element.menuElement) + + let element: UIMenuElement + + if case let .deleteStatus = action { + let deleteAction = action.build(delegate: delegate).menuElement + element = UIMenu(options: .displayInline, children: [deleteAction]) + } else { + element = action.build(delegate: delegate).menuElement + } + children.append(element) } - return UIMenu(title: "", options: [], children: children) + return UIMenu(children: children) } public static func setupAccessibilityActions( @@ -49,6 +57,7 @@ extension MastodonMenu { case hideReblogs(HideReblogsActionContext) case shareStatus case deleteStatus + case editStatus func build(delegate: MastodonMenuDelegate) -> BuiltAction { switch self { @@ -136,6 +145,14 @@ extension MastodonMenu { delegate.menuAction(self) } return translateAction + case .editStatus: + let editStatusAction = BuiltAction(title: L10n.Common.Controls.Actions.editPost, image: UIImage(systemName: "pencil")) { + [weak delegate] in + guard let delegate else { return } + delegate.menuAction(self) + } + + return editStatusAction } // end switch } // end func build } // end enum Action diff --git a/ShareActionExtension/Scene/ShareViewController.swift b/ShareActionExtension/Scene/ShareViewController.swift index 5804f431b..18383a053 100644 --- a/ShareActionExtension/Scene/ShareViewController.swift +++ b/ShareActionExtension/Scene/ShareViewController.swift @@ -32,6 +32,7 @@ final class ShareViewController: UIViewController { button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) return button }() + private func configurePublishButtonApperance() { publishButton.adjustsImageWhenHighlighted = false publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color), for: .normal) @@ -99,6 +100,7 @@ extension ShareViewController { let composeContentViewModel = ComposeContentViewModel( context: context, authContext: authContext, + composeContext: .composeStatus, destination: .topLevel, initialContent: "" )