Implement post editing / edit history (#875)

Co-authored-by: Marcus Kida <marcus.kida@bearologics.com>
Co-authored-by: Jed Fox <git@jedfox.com>
This commit is contained in:
Nathan Mattes 2023-03-02 11:06:13 +01:00 committed by GitHub
parent 625ebb00fb
commit 0c224f47df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
92 changed files with 2923 additions and 273 deletions

View File

@ -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",

View File

@ -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 = "<group>"; };
2A33625329759B4200481A90 /* OpenInActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenInActionExtension.entitlements; sourceTree = "<group>"; };
2A33AB652982C4AF008A7FB1 /* FollowersCountWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountWidgetView.swift; sourceTree = "<group>"; };
2A3D9B7D29A8F33A00F30313 /* StatusHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusHistoryView.swift; sourceTree = "<group>"; };
2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewModel.swift; sourceTree = "<group>"; };
2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsTableViewCell.swift; sourceTree = "<group>"; };
2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = "<group>"; };
@ -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 = "<group>"; };
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 = "<group>"; };
D808B94B296ECFDC0031EB1E /* StatusEditHistoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryViewModel.swift; sourceTree = "<group>"; };
D808B94D296EFBBA0031EB1E /* StatusEditHistoryTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryTableViewCell.swift; sourceTree = "<group>"; };
D8099077294BC8A30050219F /* PrivacyTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyTableViewController.swift; sourceTree = "<group>"; };
D8099079294BC9390050219F /* PrivacyTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyTableViewCell.swift; sourceTree = "<group>"; };
D809907B294D25510050219F /* PrivacyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViewModel.swift; sourceTree = "<group>"; };
@ -786,6 +794,8 @@
D8A6FE6429325F5900666A47 /* StringsConvertor */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = StringsConvertor; sourceTree = "<group>"; };
D8A6FE6529325F5900666A47 /* ios-infoPlist.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "ios-infoPlist.json"; sourceTree = "<group>"; };
D8A6FE6629325F5900666A47 /* Localizable.stringsdict */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; path = Localizable.stringsdict; sourceTree = "<group>"; };
D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Status+History.swift"; sourceTree = "<group>"; };
D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryViewController.swift; sourceTree = "<group>"; };
DB0009A826AEE5DC009B9D2D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Base.lproj/Intents.intentdefinition; sourceTree = "<group>"; };
DB0009AD26AEE5E4009B9D2D /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Intents.strings; sourceTree = "<group>"; };
DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = "<group>"; };
@ -1824,6 +1834,17 @@
path = Localization;
sourceTree = "<group>";
};
D8E5C347296DB896007E76A7 /* Edit History */ = {
isa = PBXGroup;
children = (
D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */,
D808B94B296ECFDC0031EB1E /* StatusEditHistoryViewModel.swift */,
D808B94D296EFBBA0031EB1E /* StatusEditHistoryTableViewCell.swift */,
2A3D9B7D29A8F33A00F30313 /* StatusHistoryView.swift */,
);
path = "Edit History";
sourceTree = "<group>";
};
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 */,

View File

@ -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)

View File

@ -167,6 +167,8 @@ extension StatusSection {
let managedObjectContext = context.managedObjectContext
statusView.pollTableViewDiffableDataSource = UITableViewDiffableDataSource<PollSection, PollItem>(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<Poll> = .init(objectID: option.poll.objectID)
guard let poll = option.poll else { return }
let pollRecord: ManagedObjectRecord<Poll> = .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<PollSection, PollItem>(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(

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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(

View File

@ -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 {

View File

@ -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<AnyCancellable>()
@ -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)
}
}

View File

@ -211,6 +211,7 @@ extension HashtagTimelineViewController {
let composeViewModel = ComposeViewModel(
context: context,
authContext: viewModel.authContext,
composeContext: .composeStatus,
destination: .topLevel,
initialContent: hashtag
)

View File

@ -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

View File

@ -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)

View File

@ -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?

View File

@ -558,6 +558,7 @@ extension ProfileViewController {
let composeViewModel = ComposeViewModel(
context: context,
authContext: viewModel.authContext,
composeContext: .composeStatus,
destination: .topLevel,
initialContent: mention
)

View File

@ -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))

View File

@ -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))

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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<Int, StatusEdit>?
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<Int, StatusEdit>(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<Int, StatusEdit>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.edits)
tableViewDataSource?.apply(snapshot)
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -117,6 +117,7 @@ extension ThreadViewController {
let composeViewModel = ComposeViewModel(
context: context,
authContext: viewModel.authContext,
composeContext: .composeStatus,
destination: .reply(parent: threadContext.status)
)
_ = coordinator.present(

View File

@ -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)

View File

@ -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,

View File

@ -33,6 +33,7 @@ class ThreadViewModel {
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>?
@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 {

View File

@ -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))

View File

@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>CoreData 7.xcdatamodel</string>
<string>CoreData 8.xcdatamodel</string>
</dict>
</plist>

View File

@ -0,0 +1,292 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22D68" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Application" representedClassName="CoreDataStack.Application" syncable="YES">
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
<attribute name="vapidKey" optional="YES" attributeType="String"/>
<attribute name="website" optional="YES" attributeType="String"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="application" inverseEntity="Status"/>
</entity>
<entity name="Card" representedClassName="CoreDataStack.Card" syncable="YES">
<attribute name="authorName" optional="YES" attributeType="String"/>
<attribute name="authorURLRaw" optional="YES" attributeType="String"/>
<attribute name="blurhash" optional="YES" attributeType="String"/>
<attribute name="desc" attributeType="String"/>
<attribute name="embedURLRaw" optional="YES" attributeType="String"/>
<attribute name="height" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="html" optional="YES" attributeType="String"/>
<attribute name="image" optional="YES" attributeType="String"/>
<attribute name="providerName" optional="YES" attributeType="String"/>
<attribute name="providerURLRaw" optional="YES" attributeType="String"/>
<attribute name="title" attributeType="String"/>
<attribute name="typeRaw" attributeType="String"/>
<attribute name="urlRaw" attributeType="String"/>
<attribute name="width" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="card" inverseEntity="Status"/>
</entity>
<entity name="DomainBlock" representedClassName="CoreDataStack.DomainBlock" syncable="YES">
<attribute name="blockedDomain" attributeType="String"/>
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="userID" attributeType="String"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="userID"/>
<constraint value="domain"/>
<constraint value="blockedDomain"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Emoji" representedClassName="CoreDataStack.Emoji" syncable="YES">
<attribute name="category" optional="YES" attributeType="String"/>
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="shortcode" attributeType="String"/>
<attribute name="staticURL" attributeType="String"/>
<attribute name="url" attributeType="String"/>
<attribute name="visibleInPicker" attributeType="Boolean" usesScalarValueType="YES"/>
</entity>
<entity name="Feed" representedClassName="CoreDataStack.Feed" syncable="YES">
<attribute name="acctRaw" optional="YES" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="hasMore" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="isLoadingMore" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="kindRaw" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="notification" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Notification" inverseName="feeds" inverseEntity="Notification"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="feeds" inverseEntity="Status"/>
</entity>
<entity name="Instance" representedClassName="CoreDataStack.Instance" syncable="YES">
<attribute name="configurationRaw" optional="YES" attributeType="Binary"/>
<attribute name="configurationV2Raw" optional="YES" attributeType="Binary"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="version" optional="YES" attributeType="String"/>
<relationship name="authentications" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonAuthentication" inverseName="instance" inverseEntity="MastodonAuthentication"/>
</entity>
<entity name="MastodonAuthentication" representedClassName="CoreDataStack.MastodonAuthentication" syncable="YES">
<attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="appAccessToken" attributeType="String"/>
<attribute name="clientID" attributeType="String"/>
<attribute name="clientSecret" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userAccessToken" attributeType="String"/>
<attribute name="userID" attributeType="String"/>
<attribute name="username" attributeType="String"/>
<relationship name="instance" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Instance" inverseName="authentications" inverseEntity="Instance"/>
<relationship name="user" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mastodonAuthentication" inverseEntity="MastodonUser"/>
</entity>
<entity name="MastodonUser" representedClassName="CoreDataStack.MastodonUser" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="String"/>
<attribute name="avatarStatic" optional="YES" attributeType="String"/>
<attribute name="bot" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="displayName" attributeType="String"/>
<attribute name="domain" attributeType="String"/>
<attribute name="emojis" optional="YES" attributeType="Binary"/>
<attribute name="fields" optional="YES" attributeType="Binary"/>
<attribute name="followersCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="header" attributeType="String"/>
<attribute name="headerStatic" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="locked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="suspended" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" optional="YES" attributeType="String"/>
<attribute name="username" attributeType="String"/>
<relationship name="blocking" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="blockingBy" inverseEntity="MastodonUser"/>
<relationship name="blockingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="blocking" inverseEntity="MastodonUser"/>
<relationship name="bookmarked" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="bookmarkedBy" inverseEntity="Status"/>
<relationship name="domainBlocking" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="domainBlockingBy" inverseEntity="MastodonUser"/>
<relationship name="domainBlockingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="domainBlocking" inverseEntity="MastodonUser"/>
<relationship name="endorsed" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="endorsedBy" inverseEntity="MastodonUser"/>
<relationship name="endorsedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="endorsed" inverseEntity="MastodonUser"/>
<relationship name="favourite" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="favouritedBy" inverseEntity="Status"/>
<relationship name="followedTags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="followedBy" inverseEntity="Tag"/>
<relationship name="following" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followingBy" inverseEntity="MastodonUser"/>
<relationship name="followingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="following" inverseEntity="MastodonUser"/>
<relationship name="followRequested" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followRequestedBy" inverseEntity="MastodonUser"/>
<relationship name="followRequestedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followRequested" inverseEntity="MastodonUser"/>
<relationship name="mastodonAuthentication" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonAuthentication" inverseName="user" inverseEntity="MastodonAuthentication"/>
<relationship name="muted" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="mutedBy" inverseEntity="Status"/>
<relationship name="muting" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mutingBy" inverseEntity="MastodonUser"/>
<relationship name="mutingBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="muting" inverseEntity="MastodonUser"/>
<relationship name="notifications" toMany="YES" deletionRule="Nullify" destinationEntity="Notification" inverseName="account" inverseEntity="Notification"/>
<relationship name="pinnedStatus" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="pinnedBy" inverseEntity="Status"/>
<relationship name="privateNotes" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="to" inverseEntity="PrivateNote"/>
<relationship name="privateNotesTo" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="from" inverseEntity="PrivateNote"/>
<relationship name="reblogged" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="rebloggedBy" inverseEntity="Status"/>
<relationship name="searchHistories" toMany="YES" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="account" inverseEntity="SearchHistory"/>
<relationship name="showingReblogs" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="showingReblogsBy" inverseEntity="MastodonUser"/>
<relationship name="showingReblogsBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="showingReblogs" inverseEntity="MastodonUser"/>
<relationship name="statuses" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="author" inverseEntity="Status"/>
<relationship name="votePollOptions" toMany="YES" deletionRule="Nullify" destinationEntity="PollOption" inverseName="votedBy" inverseEntity="PollOption"/>
<relationship name="votePolls" toMany="YES" deletionRule="Nullify" destinationEntity="Poll" inverseName="votedBy" inverseEntity="Poll"/>
</entity>
<entity name="Notification" representedClassName="CoreDataStack.Notification" syncable="YES">
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="followRequestState" optional="YES" attributeType="Binary"/>
<attribute name="id" attributeType="String"/>
<attribute name="transientFollowRequestState" optional="YES" transient="YES" attributeType="Binary"/>
<attribute name="typeRaw" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String"/>
<relationship name="account" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="notifications" inverseEntity="MastodonUser"/>
<relationship name="feeds" toMany="YES" deletionRule="Cascade" destinationEntity="Feed" inverseName="notification" inverseEntity="Feed"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="notifications" inverseEntity="Status"/>
</entity>
<entity name="Poll" representedClassName="CoreDataStack.Poll" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String" defaultValueString=""/>
<attribute name="expired" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="expiresAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" attributeType="String"/>
<attribute name="isVoting" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="multiple" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="votersCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="votesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="options" toMany="YES" deletionRule="Cascade" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="poll" inverseEntity="Status"/>
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePolls" inverseEntity="MastodonUser"/>
</entity>
<entity name="PollOption" representedClassName="CoreDataStack.PollOption" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="index" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isSelected" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="title" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="votesCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePollOptions" inverseEntity="MastodonUser"/>
</entity>
<entity name="PrivateNote" representedClassName="CoreDataStack.PrivateNote" syncable="YES">
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="from" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotesTo" inverseEntity="MastodonUser"/>
<relationship name="to" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotes" inverseEntity="MastodonUser"/>
</entity>
<entity name="SearchHistory" representedClassName="CoreDataStack.SearchHistory" syncable="YES">
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String" defaultValueString=""/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String" defaultValueString=""/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="searchHistories" inverseEntity="MastodonUser"/>
<relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag" inverseName="searchHistories" inverseEntity="Tag"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="searchHistories" inverseEntity="Status"/>
</entity>
<entity name="Setting" representedClassName="CoreDataStack.Setting" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="preferredStaticAvatar" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="preferredStaticEmoji" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="preferredTrueBlackDarkMode" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="preferredUsingDefaultBrowser" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="rawRecentLanguages" optional="YES" attributeType="Binary"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String"/>
<relationship name="subscriptions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Subscription" inverseName="setting" inverseEntity="Subscription"/>
</entity>
<entity name="Status" representedClassName="CoreDataStack.Status" syncable="YES">
<attribute name="attachments" optional="YES" attributeType="Binary"/>
<attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="editedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="emojis" optional="YES" attributeType="Binary"/>
<attribute name="favouritesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="String"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="isSensitiveToggled" transient="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="language" optional="YES" attributeType="String"/>
<attribute name="mentions" optional="YES" attributeType="Binary"/>
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="repliesCount" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
<attribute name="revealedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="spoilerText" optional="YES" attributeType="String"/>
<attribute name="text" optional="YES" attributeType="String"/>
<attribute name="translatedContent" optional="YES" transient="YES" attributeType="Transformable"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="uri" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="String"/>
<attribute name="visibilityRaw" optional="YES" attributeType="String" elementID="visibility"/>
<relationship name="application" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Application" inverseName="status" inverseEntity="Application"/>
<relationship name="author" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="statuses" inverseEntity="MastodonUser"/>
<relationship name="bookmarkedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="bookmarked" inverseEntity="MastodonUser"/>
<relationship name="card" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Card" inverseName="status" inverseEntity="Card"/>
<relationship name="editHistory" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="StatusEdit" inverseName="status" inverseEntity="StatusEdit"/>
<relationship name="favouritedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="favourite" inverseEntity="MastodonUser"/>
<relationship name="feeds" toMany="YES" deletionRule="Cascade" destinationEntity="Feed" inverseName="status" inverseEntity="Feed"/>
<relationship name="mutedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="muted" inverseEntity="MastodonUser"/>
<relationship name="notifications" toMany="YES" deletionRule="Cascade" destinationEntity="Notification" inverseName="status" inverseEntity="Notification"/>
<relationship name="pinnedBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="pinnedStatus" inverseEntity="MastodonUser"/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Poll" inverseName="status" inverseEntity="Poll"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogFrom" inverseEntity="Status"/>
<relationship name="reblogFrom" toMany="YES" deletionRule="Cascade" destinationEntity="Status" inverseName="reblog" inverseEntity="Status"/>
<relationship name="rebloggedBy" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="reblogged" inverseEntity="MastodonUser"/>
<relationship name="replyFrom" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="replyTo" inverseEntity="Status"/>
<relationship name="replyTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="replyFrom" inverseEntity="Status"/>
<relationship name="searchHistories" toMany="YES" deletionRule="Cascade" destinationEntity="SearchHistory" inverseName="status" inverseEntity="SearchHistory"/>
</entity>
<entity name="StatusEdit" representedClassName="CoreDataStack.StatusEdit" syncable="YES">
<attribute name="attachments" optional="YES" attributeType="Binary"/>
<attribute name="content" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="emojis" optional="YES" attributeType="Binary"/>
<attribute name="poll" optional="YES" attributeType="Binary"/>
<attribute name="sensitive" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="spoilerText" optional="YES" attributeType="String"/>
<relationship name="author" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="editHistory" inverseEntity="Status"/>
</entity>
<entity name="Subscription" representedClassName="CoreDataStack.Subscription" syncable="YES">
<attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="endpoint" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="policyRaw" attributeType="String"/>
<attribute name="serverKey" optional="YES" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userToken" optional="YES" attributeType="String"/>
<relationship name="alert" maxCount="1" deletionRule="Cascade" destinationEntity="SubscriptionAlerts" inverseName="subscription" inverseEntity="SubscriptionAlerts"/>
<relationship name="setting" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Setting" inverseName="subscriptions" inverseEntity="Setting"/>
</entity>
<entity name="SubscriptionAlerts" representedClassName="CoreDataStack.SubscriptionAlerts" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="favouriteRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="followRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="followRequestRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mentionRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="pollRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="subscription" maxCount="1" deletionRule="Nullify" destinationEntity="Subscription" inverseName="alert" inverseEntity="Subscription"/>
</entity>
<entity name="Tag" representedClassName="CoreDataStack.Tag" syncable="YES">
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String" defaultValueString=""/>
<attribute name="following" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="histories" optional="YES" attributeType="Binary"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="url" attributeType="String"/>
<relationship name="followedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followedTags" inverseEntity="MastodonUser"/>
<relationship name="searchHistories" toMany="YES" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="hashtag" inverseEntity="SearchHistory"/>
</entity>
</model>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="18154" systemVersion="20G71" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21512" systemVersion="21G115" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Application" representedClassName=".Application" syncable="YES">
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
@ -276,25 +276,4 @@
<relationship name="searchHistory" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SearchHistory" inverseName="hashtag" inverseEntity="SearchHistory"/>
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="tags" inverseEntity="Status"/>
</entity>
<elements>
<element name="Application" positionX="0" positionY="0" width="128" height="104"/>
<element name="Attachment" positionX="0" positionY="0" width="128" height="254"/>
<element name="DomainBlock" positionX="45" positionY="162" width="128" height="89"/>
<element name="Emoji" positionX="0" positionY="0" width="128" height="134"/>
<element name="History" positionX="0" positionY="0" width="128" height="119"/>
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
<element name="MastodonAuthentication" positionX="0" positionY="0" width="128" height="209"/>
<element name="MastodonNotification" positionX="9" positionY="162" width="128" height="164"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="734"/>
<element name="Mention" positionX="0" positionY="0" width="128" height="149"/>
<element name="Poll" positionX="0" positionY="0" width="128" height="194"/>
<element name="PollOption" positionX="0" positionY="0" width="128" height="134"/>
<element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/>
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="149"/>
<element name="Setting" positionX="72" positionY="162" width="128" height="179"/>
<element name="Status" positionX="0" positionY="0" width="128" height="614"/>
<element name="Subscription" positionX="81" positionY="171" width="128" height="179"/>
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="14"/>
<element name="Tag" positionX="0" positionY="0" width="128" height="149"/>
</elements>
</model>

View File

@ -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<PollOption>
@ -324,3 +324,9 @@ extension Poll: AutoUpdatableObject {
}
}
}
public extension Set<PollOption> {
func sortedByIndex() -> [PollOption] {
sorted(by: { lhs, rhs in lhs.index < rhs.index })
}
}

View File

@ -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<MastodonUser>?
@ -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) {

View File

@ -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<Status>
@NSManaged public private(set) var notifications: Set<Notification>
@NSManaged public private(set) var searchHistories: Set<SearchHistory>
@NSManaged public private(set) var editHistory: Set<StatusEdit>?
// 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<StatusEdit>) {
self.editHistory = editHistory
}
}
extension Status {

View File

@ -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
}

View File

@ -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" : {

View File

@ -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
}
}

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "edit.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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")

View File

@ -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
}
}

View File

@ -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
)
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}
}
}

View File

@ -11,4 +11,5 @@ import CoreDataStack
public enum PollItem: Hashable {
case option(record: ManagedObjectRecord<PollOption>)
case history(option: StatusEdit.Poll.Option)
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -20,6 +20,7 @@ extension Persistence {
public enum Tag { }
public enum SearchHistory { }
public enum Notification { }
public enum StatusEdit {}
}
extension Persistence {

View File

@ -24,3 +24,4 @@ extension MastodonEmojiContainer {
extension Mastodon.Entity.Account: MastodonEmojiContainer { }
extension Mastodon.Entity.Status: MastodonEmojiContainer { }
extension Mastodon.Entity.StatusEdit: MastodonEmojiContainer { }

View File

@ -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<Mastodon.Entity.StatusSource> {
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<Mastodon.Entity.Status> {
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
}
}

View File

@ -9,5 +9,6 @@ import Foundation
import MastodonSDK
public enum StatusPublishResult {
case mastodon(Mastodon.Response.Content<Mastodon.Entity.Status>)
case post(Mastodon.Response.Content<Mastodon.Entity.Status>)
case edit(Mastodon.Response.Content<Mastodon.Entity.Status>)
}

View File

@ -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

View File

@ -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";

View File

@ -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?

View File

@ -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<Mastodon.Response.Content<[Mastodon.Entity.Account]>, Error> {

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.StatusSource>, 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<Mastodon.Response.Content<[Mastodon.Entity.StatusEdit]>, 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 polls 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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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
}
}
}

View File

@ -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)

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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"
}
}
}

View File

@ -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 }
}

View File

@ -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
}
}

View File

@ -71,6 +71,7 @@ public struct AttachmentView: View {
.lineLimit(1)
}
.padding(EdgeInsets(top: 6, leading: 0, bottom: 10, trailing: 0))
.disabled(!viewModel.isCaptionEditable)
}
}
)

View File

@ -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) {

View File

@ -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

View File

@ -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 ?? "<nil>")")
return attachmentUploadResponse.value
return .uploadedMastodonAttachment(attachmentUploadResponse.value)
}
}
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

@ -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<MastodonUser>?
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 {

View File

@ -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<MastodonUser>
// 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<StatusPublisherState>.Publisher { $_state }
public var reactor: StatusPublisherReactor?
public init(
statusID: Status.ID,
author: ManagedObjectRecord<MastodonUser>,
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)
}
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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 {

View File

@ -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 {

View File

@ -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 = {

View File

@ -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()

View File

@ -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(

View File

@ -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 {}
}
}

View File

@ -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)
}
}

View File

@ -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<Date, Never>) {
// 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<PollSection, PollItem>()
_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)

View File

@ -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: ", ")
}

View File

@ -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

View File

@ -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

View File

@ -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: ""
)