From d332c98a0f10c030466ef1d703d6f1abf6544542 Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 29 Jan 2022 19:51:40 +0800 Subject: [PATCH] feat: add content warning for post media --- Mastodon.xcodeproj/project.pbxproj | 16 +-- .../xcshareddata/xcschemes/Mastodon.xcscheme | 7 + Mastodon/Extension/Date.swift | 44 ------ .../Provider/DataSourceFacade+Model.swift | 2 +- .../Protocol/Provider/DataSourceFacade.swift | 4 +- ...Provider+StatusTableViewCellDelegate.swift | 20 ++- .../HomeTimelineViewController.swift | 68 +++------ .../NotificationTableViewCell+ViewModel.swift | 2 +- .../NotificationViewModel+Diffable.swift | 94 ------------- .../Content/MediaView+Configuration.swift | 62 ++++++--- .../NotificationView+Configuration.swift | 12 +- .../Content/StatusView+Configuration.swift | 42 ++---- .../Service/BlurhashImageCacheService.swift | 53 +++---- .../CoreData 3.xcdatamodel/contents | 5 - .../Sources/MastodonUI/DateTimeProvider.swift | 12 ++ .../Sources/MastodonUI/Extension/Date.swift | 34 +++++ .../Content/MediaView+Configuration.swift | 131 +++++++++++------- .../MastodonUI/View/Content/MediaView.swift | 60 +++++++- .../Content/NotificationView+ViewModel.swift | 6 +- .../View/Content/StatusView+ViewModel.swift | 42 ++++-- .../MastodonUI/View/Content/StatusView.swift | 1 + 21 files changed, 355 insertions(+), 362 deletions(-) delete mode 100644 Mastodon/Extension/Date.swift delete mode 100644 Mastodon/Scene/Notification/NotificationViewModel+Diffable.swift create mode 100644 MastodonSDK/Sources/MastodonUI/DateTimeProvider.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9bc2abe2e..04b0fccab 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -25,7 +25,6 @@ 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; }; 164F0EBC267D4FE400249499 /* BoopSound.caf in Resources */ = {isa = PBXBuildFile; fileRef = 164F0EBB267D4FE400249499 /* BoopSound.caf */; }; 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; }; - 2D084B8D26258EA3003AA3AF /* NotificationViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D084B8C26258EA3003AA3AF /* NotificationViewModel+Diffable.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; }; 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; }; @@ -413,6 +412,7 @@ DB852D1F26FB037800FC9D81 /* SidebarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB852D1E26FB037800FC9D81 /* SidebarViewModel.swift */; }; DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; }; DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */; }; + DB894CC427A5490600684B74 /* BlurhashImageCacheService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB894CC327A5490600684B74 /* BlurhashImageCacheService.swift */; }; DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF52B25C13561002E6C99 /* ViewStateStore.swift */; }; DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF52C25C13561002E6C99 /* DocumentStore.swift */; }; DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF52D25C13561002E6C99 /* AppContext.swift */; }; @@ -477,7 +477,6 @@ DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F932616E28B004B8251 /* APIService+Follow.swift */; }; DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */; }; DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */; }; - DBAEDE5C267A058D00D25FF5 /* BlurhashImageCacheService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */; }; DBB3BA2A26A81C020004F2D4 /* FLAnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */; }; DBB3BA2B26A81D060004F2D4 /* FLAnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */; }; DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; @@ -516,7 +515,6 @@ DBBC24D126A5484F00398BB9 /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DBBC24D026A5484F00398BB9 /* UITextView+Placeholder */; }; DBBC24DC26A54BCB00398BB9 /* MastodonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */; }; DBBC24DE26A54BCB00398BB9 /* MastodonMetricFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC24D826A54BCB00398BB9 /* MastodonMetricFormatter.swift */; }; - DBBC50BF278ED0E700AF0CC6 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC50BE278ED0E700AF0CC6 /* Date.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBBF1DBF2652401B00E5B703 /* AutoCompleteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DBE2652401B00E5B703 /* AutoCompleteViewModel.swift */; }; DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF1DC126524D2900E5B703 /* AutoCompleteTableViewCell.swift */; }; @@ -750,7 +748,6 @@ 159AC43EFE0A1F95FCB358A4 /* Pods-MastodonIntent.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonIntent.release.xcconfig"; path = "Target Support Files/Pods-MastodonIntent/Pods-MastodonIntent.release.xcconfig"; sourceTree = ""; }; 164F0EBB267D4FE400249499 /* BoopSound.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = BoopSound.caf; sourceTree = ""; }; 1D6D967E77A5357E2C6110D9 /* Pods-Mastodon.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk - debug.xcconfig"; sourceTree = ""; }; - 2D084B8C26258EA3003AA3AF /* NotificationViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+Diffable.swift"; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; 2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; }; 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = ""; }; @@ -1180,6 +1177,7 @@ DB852D1E26FB037800FC9D81 /* SidebarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarViewModel.swift; sourceTree = ""; }; DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = ""; }; DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionAppendEntryCollectionViewCell.swift; sourceTree = ""; }; + DB894CC327A5490600684B74 /* BlurhashImageCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurhashImageCacheService.swift; sourceTree = ""; }; DB89BA1025C10FF5008580ED /* Mastodon.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Mastodon.entitlements; sourceTree = ""; }; DB8AF52B25C13561002E6C99 /* ViewStateStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewStateStore.swift; sourceTree = ""; }; DB8AF52C25C13561002E6C99 /* DocumentStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DocumentStore.swift; sourceTree = ""; }; @@ -1257,7 +1255,6 @@ DBAE3F932616E28B004B8251 /* APIService+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Follow.swift"; sourceTree = ""; }; DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Mute.swift"; sourceTree = ""; }; DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = ""; }; - DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurhashImageCacheService.swift; sourceTree = ""; }; DBB3BA2926A81C020004F2D4 /* FLAnimatedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FLAnimatedImageView.swift; sourceTree = ""; }; DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = ""; }; DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; @@ -1282,7 +1279,6 @@ DBBC24CE26A547AE00398BB9 /* ThemeService+Appearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThemeService+Appearance.swift"; sourceTree = ""; }; DBBC24D626A54BCB00398BB9 /* MastodonRegex.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonRegex.swift; sourceTree = ""; }; DBBC24D826A54BCB00398BB9 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = ""; }; - DBBC50BE278ED0E700AF0CC6 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; DBBC50C0278ED49200AF0CC6 /* MastodonAuthenticationBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationBox.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBBF1DBE2652401B00E5B703 /* AutoCompleteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewModel.swift; sourceTree = ""; }; @@ -1690,9 +1686,9 @@ DB6D9F6226357848008423CD /* SettingService.swift */, DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */, DB297B1A2679FAE200704C90 /* PlaceholderImageCacheService.swift */, - DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */, DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */, DB73BF42271192BB00781945 /* InstanceService.swift */, + DB894CC327A5490600684B74 /* BlurhashImageCacheService.swift */, ); path = Service; sourceTree = ""; @@ -2724,7 +2720,6 @@ DBCC3B35261440BA0045B23D /* UINavigationController.swift */, DB73BF4827140BA300781945 /* UICollectionViewDiffableDataSource.swift */, DB73BF4A27140C0800781945 /* UITableViewDiffableDataSource.swift */, - DBBC50BE278ED0E700AF0CC6 /* Date.swift */, ); path = Extension; sourceTree = ""; @@ -2794,7 +2789,6 @@ 2D35237F26256F470031AF25 /* Cell */, DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */, 2D607AD726242FC500B70763 /* NotificationViewModel.swift */, - 2D084B8C26258EA3003AA3AF /* NotificationViewModel+Diffable.swift */, ); path = Notification; sourceTree = ""; @@ -3782,7 +3776,6 @@ 5DF1054125F886D400D6C0D4 /* VideoPlaybackService.swift in Sources */, DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */, 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */, - DBBC50BF278ED0E700AF0CC6 /* Date.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, 2D9DB967263A76FB007C1D71 /* BlockDomainService.swift in Sources */, DB336F43278EB1690031E64B /* MediaView+Configuration.swift in Sources */, @@ -3976,7 +3969,6 @@ DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* OnboardingHeadlineTableViewCell.swift in Sources */, 5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */, - DBAEDE5C267A058D00D25FF5 /* BlurhashImageCacheService.swift in Sources */, DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */, DB852D1926FAEB6B00FC9D81 /* SidebarViewController.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, @@ -4024,7 +4016,6 @@ DB0617EF277F12720030EE79 /* NavigationActionView.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, - 2D084B8D26258EA3003AA3AF /* NotificationViewModel+Diffable.swift in Sources */, DB3667A1268ABB2E0027D07F /* ComposeStatusAttachmentItem.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, DBA94434265CBB5300C537E1 /* ProfileFieldSection.swift in Sources */, @@ -4136,6 +4127,7 @@ 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, DB36679D268AB91B0027D07F /* ComposeStatusAttachmentTableViewCell.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, + DB894CC427A5490600684B74 /* BlurhashImageCacheService.swift in Sources */, DBFEF07B26A6BCE8006D7ED1 /* APIService+Status+Publish.swift in Sources */, DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon.xcscheme b/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon.xcscheme index de059787b..b99adf881 100644 --- a/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon.xcscheme +++ b/Mastodon.xcodeproj/xcshareddata/xcschemes/Mastodon.xcscheme @@ -86,6 +86,13 @@ ReferencedContainer = "container:Mastodon.xcodeproj"> + + + + String { - let earlierDate = date < self ? date : self - let latestDate = earlierDate == date ? self : date - - if isSlowed, earlierDate.timeIntervalSince(latestDate) >= -60 { - return L10n.Common.Controls.Timeline.Timestamp.now - } else { - if isAbbreviated { - return latestDate.localizedShortTimeAgo(since: earlierDate) - } else { - return Date.relativeTimestampFormatter.localizedString(for: earlierDate, relativeTo: latestDate) - } - } - } - -} diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift index 1e16e0407..efdf41dbd 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Model.swift @@ -47,7 +47,7 @@ extension DataSourceFacade { switch target { case .status: return status.reblog ?? status - case .repost: + case .reblog: return status } } diff --git a/Mastodon/Protocol/Provider/DataSourceFacade.swift b/Mastodon/Protocol/Provider/DataSourceFacade.swift index 809aab424..4d3536517 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade.swift @@ -10,7 +10,7 @@ import Foundation enum DataSourceFacade { enum StatusTarget { - case status // remove repost wrapper - case repost // keep repost wrapper + case status // remove reblog wrapper + case reblog // keep reblog wrapper } } diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift index cd167cb9d..ffd3de8b1 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift @@ -30,7 +30,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider { } await DataSourceFacade.coordinateToProfileScene( provider: self, - target: .status, // without reblog header + target: .reblog, // keep the wrapper for header author status: status ) } @@ -117,6 +117,24 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & MediaPrev assertionFailure("only works for status data provider") return } + + let managedObjectContext = self.context.managedObjectContext + let needsToggleMediaSensitive: Bool = try await managedObjectContext.perform { + guard let _status = status.object(in: managedObjectContext) else { return false } + let status = _status.reblog ?? _status + guard status.sensitive else { return false } + guard status.isMediaSensitiveToggled else { return true } + return false + } + + guard !needsToggleMediaSensitive else { + try await DataSourceFacade.responseToToggleMediaSensitiveAction( + dependency: self, + status: status + ) + return + } + try await DataSourceFacade.coordinateToMediaPreviewScene( dependency: self, status: status, diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index e408ab8de..fcef0bffd 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -374,7 +374,7 @@ extension HomeTimelineViewController { @objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) { // TODO: - let viewModel = SuggestionAccountViewModel(context: context) +// let viewModel = SuggestionAccountViewModel(context: context) // viewModel.delegate = self.viewModel // coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil)) } @@ -553,40 +553,9 @@ extension HomeTimelineViewController: UITableViewDelegate, AutoGenerateTableView // func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { // aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) // } -// -// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { -// aspectTableView(tableView, didSelectRowAt: indexPath) -// } -// -// func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { -// return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) -// } -// -// func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { -// return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) -// } -// -// func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { -// return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) -// } -// -// func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { -// aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) -// } } -// MARK: - UITableViewDataSourcePrefetching -//extension HomeTimelineViewController: UITableViewDataSourcePrefetching { -// func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { -// aspectTableView(tableView, prefetchRowsAt: indexPaths) -// } -// -// func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { -// aspectTableView(tableView, cancelPrefetchingForRowsAt: indexPaths) -// } -//} - // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { func navigationBar() -> UINavigationBar? { @@ -613,24 +582,23 @@ extension HomeTimelineViewController: ScrollViewContainer { var scrollView: UIScrollView { return tableView } func scrollToTop(animated: Bool) { - // TODO: -// if scrollView.contentOffset.y < scrollView.frame.height, -// viewModel.loadLatestStateMachine.canEnterState(HomeTimelineViewModel.LoadLatestState.Loading.self), -// (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) == 0.0, -// !refreshControl.isRefreshing { -// scrollView.scrollRectToVisible(CGRect(origin: CGPoint(x: 0, y: -refreshControl.frame.height), size: CGSize(width: 1, height: 1)), animated: animated) -// DispatchQueue.main.async { [weak self] in -// guard let self = self else { return } -// self.refreshControl.beginRefreshing() -// self.refreshControl.sendActions(for: .valueChanged) -// } -// } else { -// let indexPath = IndexPath(row: 0, section: 0) -// guard viewModel.diffableDataSource?.itemIdentifier(for: indexPath) != nil else { return } -// // save position -// savePositionBeforeScrollToTop() -// tableView.scrollToRow(at: indexPath, at: .top, animated: true) -// } + if scrollView.contentOffset.y < scrollView.frame.height, + viewModel.loadLatestStateMachine.canEnterState(HomeTimelineViewModel.LoadLatestState.Loading.self), + (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) == 0.0, + !refreshControl.isRefreshing { + scrollView.scrollRectToVisible(CGRect(origin: CGPoint(x: 0, y: -refreshControl.frame.height), size: CGSize(width: 1, height: 1)), animated: animated) + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.refreshControl.beginRefreshing() + self.refreshControl.sendActions(for: .valueChanged) + } + } else { + let indexPath = IndexPath(row: 0, section: 0) + guard viewModel.diffableDataSource?.itemIdentifier(for: indexPath) != nil else { return } + // save position + savePositionBeforeScrollToTop() + tableView.scrollToRow(at: indexPath, at: .top, animated: true) + } } } diff --git a/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift index 99c040424..83bd7b829 100644 --- a/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift +++ b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift @@ -42,7 +42,7 @@ extension NotificationTableViewCell { case .feed(let feed): notificationView.configure(feed: feed) } - +// self.delegate = delegate } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+Diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+Diffable.swift deleted file mode 100644 index 943db00b0..000000000 --- a/Mastodon/Scene/Notification/NotificationViewModel+Diffable.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// NotificationViewModel+Diffable.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/13. -// - -import CoreData -import CoreDataStack -import os.log -import UIKit -import MastodonSDK - - - -//extension NotificationViewModel: NSFetchedResultsControllerDelegate { -// func controllerWillChangeContent(_ controller: NSFetchedResultsController) { -// os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) -// } -// -// func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { -// os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) -// -// guard let tableView = self.tableView else { return } -// guard let navigationBar = contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return } -// -// guard let diffableDataSource = self.diffableDataSource else { return } -// -// let predicate: NSPredicate = { -// let notificationTypePredicate = MastodonNotification.predicate( -// validTypesRaws: Mastodon.Entity.Notification.NotificationType.knownCases.map { $0.rawValue } -// ) -// return fetchedResultsController.fetchRequest.predicate.flatMap { -// NSCompoundPredicate(andPredicateWithSubpredicates: [$0, notificationTypePredicate]) -// } ?? notificationTypePredicate -// }() -// let parentManagedObjectContext = fetchedResultsController.managedObjectContext -// let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) -// managedObjectContext.parent = parentManagedObjectContext -// -// managedObjectContext.perform { -// let notifications: [MastodonNotification] = { -// let request = MastodonNotification.sortedFetchRequest -// request.returnsObjectsAsFaults = false -// request.predicate = predicate -// do { -// return try managedObjectContext.fetch(request) -// } catch { -// assertionFailure(error.localizedDescription) -// return [] -// } -// }() -// -// DispatchQueue.main.async { -// let oldSnapshot = diffableDataSource.snapshot() -// var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] -// for item in oldSnapshot.itemIdentifiers { -// guard case let .notification(objectID, attribute) = item else { continue } -// oldSnapshotAttributeDict[objectID] = attribute -// } -// var newSnapshot = NSDiffableDataSourceSnapshot() -// newSnapshot.appendSections([.main]) -// -// let segment = self.selectedIndex.value -// switch segment { -// case .everyThing: -// let items: [NotificationItem] = notifications.map { notification in -// let attribute: Item.StatusAttribute = oldSnapshotAttributeDict[notification.objectID] ?? Item.StatusAttribute() -// return NotificationItem.notification(objectID: notification.objectID, attribute: attribute) -// } -// newSnapshot.appendItems(items, toSection: .main) -// case .mentions: -// let items: [NotificationItem] = notifications.map { notification in -// let attribute: Item.StatusAttribute = oldSnapshotAttributeDict[notification.objectID] ?? Item.StatusAttribute() -// return NotificationItem.notificationStatus(objectID: notification.objectID, attribute: attribute) -// } -// newSnapshot.appendItems(items, toSection: .main) -// } -// -// if !notifications.isEmpty, self.noMoreNotification.value == false { -// newSnapshot.appendItems([.bottomLoader], toSection: .main) -// } -// -// self.isFetchingLatestNotification.value = false -// -// diffableDataSource.apply(newSnapshot, animatingDifferences: false) { [weak self] in -// guard let self = self else { return } -// self.dataSourceDidUpdated.send() -// } -// } -// } -// } -// -//} diff --git a/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift b/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift index 5401ec9ba..52651d81b 100644 --- a/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/MediaView+Configuration.swift @@ -25,24 +25,54 @@ extension MediaView { return status.publisher(for: \.attachments) .map { attachments -> [MediaView.Configuration] in return attachments.map { attachment -> MediaView.Configuration in - switch attachment.kind { - case .image: - let info = MediaView.Configuration.ImageInfo( - aspectRadio: attachment.size, - assetURL: attachment.assetURL + let configuration: MediaView.Configuration = { + switch attachment.kind { + case .image: + let info = MediaView.Configuration.ImageInfo( + aspectRadio: attachment.size, + assetURL: attachment.assetURL + ) + return .init( + info: .image(info: info), + blurhash: attachment.blurhash + ) + case .video: + let info = videoInfo(from: attachment) + return .init( + info: .video(info: info), + blurhash: attachment.blurhash + ) + case .gifv: + let info = videoInfo(from: attachment) + return .init( + info: .gif(info: info), + blurhash: attachment.blurhash + ) + case .audio: + // TODO: + let info = videoInfo(from: attachment) + return .init( + info: .video(info: info), + blurhash: attachment.blurhash + ) + } // end switch + }() + + if let assetURL = configuration.assetURL, + let blurhash = configuration.blurhash + { + AppContext.shared.blurhashImageCacheService.image( + blurhash: blurhash, + size: configuration.aspectRadio, + url: assetURL ) - return .image(info: info) - case .video: - let info = videoInfo(from: attachment) - return .video(info: info) - case .gifv: - let info = videoInfo(from: attachment) - return .gif(info: info) - case .audio: - // TODO: - let info = videoInfo(from: attachment) - return .video(info: info) + .assign(to: \.blurhashImage, on: configuration) + .store(in: &configuration.blurhashImageDisposeBag) } + + configuration.isReveal = status.sensitive ? status.isMediaSensitiveToggled : true + + return configuration } } .eraseToAnyPublisher() diff --git a/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift b/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift index 41550a2a8..705e90a76 100644 --- a/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/NotificationView+Configuration.swift @@ -92,13 +92,7 @@ extension NotificationView { .assign(to: \.authorUsername, on: viewModel) .store(in: &disposeBag) // timestamp - viewModel.timestampFormatter = { (date: Date) in - date.localizedSlowedTimeAgoSinceNow - } - notification.publisher(for: \.createAt) - .map { $0 as Date? } - .assign(to: \.timestamp, on: viewModel) - .store(in: &disposeBag) + viewModel.timestamp = notification.createAt // notification type indicator Publishers.CombineLatest3( notification.publisher(for: \.typeRaw), @@ -111,7 +105,7 @@ extension NotificationView { self.viewModel.notificationIndicatorText = nil return } - + func createMetaContent(text: String, emojis: MastodonContent.Emojis) -> MetaContent { let content = MastodonContent(content: text, emojis: emojis) guard let metaContent = try? MastodonMetaContent.convert(document: content) else { @@ -119,7 +113,7 @@ extension NotificationView { } return metaContent } - + // TODO: fix the i18n. The subject should assert place at the string beginning switch type { case .follow: diff --git a/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift b/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift index 8530fe244..249c2cfcb 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift @@ -173,14 +173,10 @@ extension StatusView { .map { $0 as String? } .assign(to: \.authorUsername, on: viewModel) .store(in: &disposeBag) - - // // protected - // author.publisher(for: \.locked) - // .assign(to: \.protected, on: viewModel) - // .store(in: &disposeBag) - // // visibility - // viewModel.visibility = status.visibility.asStatusVisibility - + // locked + author.publisher(for: \.locked) + .assign(to: \.locked, on: viewModel) + .store(in: &disposeBag) // isMuting Publishers.CombineLatest( viewModel.$userIdentifier, @@ -267,42 +263,22 @@ extension StatusView { status.publisher(for: \.isContentSensitiveToggled) .assign(to: \.isContentSensitiveToggled, on: viewModel) .store(in: &disposeBag) - status.publisher(for: \.isMediaSensitiveToggled) - .assign(to: \.isMediaSensitiveToggled, on: viewModel) - .store(in: &disposeBag) - + // viewModel.source = status.source } private func configureMedia(status: Status) { let status = status.reblog ?? status -// mediaGridContainerView.viewModel.resetContentWarningOverlay() -// viewModel.isMediaSensitiveSwitchable = true - - viewModel.isMediaSensitive = status.sensitive + viewModel.isMediaSensitive = status.sensitive && !status.attachments.isEmpty // some servers set media sensitive even empty attachments MediaView.configuration(status: status) .assign(to: \.mediaViewConfigurations, on: viewModel) .store(in: &disposeBag) -// // set directly without delay -// viewModel.isMediaSensitiveToggled = status.isMediaSensitiveToggled -// viewModel.isMediaSensitive = status.isMediaSensitive -// mediaGridContainerView.configureOverlayDisplay( -// isDisplay: status.isMediaSensitiveToggled ? !status.isMediaSensitive : !status.isMediaSensitive, -// animated: false -// ) -// -// status.publisher(for: \.isMediaSensitive) -// .receive(on: DispatchQueue.main) -// .assign(to: \.isMediaSensitive, on: viewModel) -// .store(in: &disposeBag) -// -// status.publisher(for: \.isMediaSensitiveToggled) -// .receive(on: DispatchQueue.main) -// .assign(to: \.isMediaSensitiveToggled, on: viewModel) -// .store(in: &disposeBag) + status.publisher(for: \.isMediaSensitiveToggled) + .assign(to: \.isMediaSensitiveToggled, on: viewModel) + .store(in: &disposeBag) } private func configurePoll(status: Status) { diff --git a/Mastodon/Service/BlurhashImageCacheService.swift b/Mastodon/Service/BlurhashImageCacheService.swift index 580cb5429..b15a9750b 100644 --- a/Mastodon/Service/BlurhashImageCacheService.swift +++ b/Mastodon/Service/BlurhashImageCacheService.swift @@ -8,13 +8,19 @@ import UIKit import Combine -final class BlurhashImageCacheService { +public final class BlurhashImageCacheService { + + static let edgeMaxLength: CGFloat = 20 let cache = NSCache() let workingQueue = DispatchQueue(label: "org.joinmastodon.app.BlurhashImageCacheService.working-queue", qos: .userInitiated, attributes: .concurrent) - func image(blurhash: String, size: CGSize, url: URL) -> AnyPublisher { + public func image( + blurhash: String, + size: CGSize, + url: String + ) -> AnyPublisher { let key = Key(blurhash: blurhash, size: size, url: url) if let image = self.cache.object(forKey: key) { @@ -23,7 +29,7 @@ final class BlurhashImageCacheService { return Future { promise in self.workingQueue.async { - guard let image = BlurhashImageCacheService.blurhashImage(blurhash: blurhash, size: size, url: url) else { + guard let image = BlurhashImageCacheService.blurhashImage(blurhash: blurhash, size: size) else { promise(.success(nil)) return } @@ -33,27 +39,25 @@ final class BlurhashImageCacheService { } .receive(on: RunLoop.main) .eraseToAnyPublisher() - } - static func blurhashImage(blurhash: String, size: CGSize, url: URL) -> UIImage? { - fatalError() -// let imageSize: CGSize = { -// let aspectRadio = size.width / size.height -// if size.width > size.height { -// let width: CGFloat = MosaicMeta.edgeMaxLength -// let height = width / aspectRadio -// return CGSize(width: width, height: height) -// } else { -// let height: CGFloat = MosaicMeta.edgeMaxLength -// let width = height * aspectRadio -// return CGSize(width: width, height: height) -// } -// }() -// -// let image = UIImage(blurHash: blurhash, size: imageSize) -// -// return image + static func blurhashImage(blurhash: String, size: CGSize) -> UIImage? { + let imageSize: CGSize = { + let aspectRadio = size.width / size.height + if size.width > size.height { + let width: CGFloat = BlurhashImageCacheService.edgeMaxLength + let height = width / aspectRadio + return CGSize(width: width, height: height) + } else { + let height: CGFloat = BlurhashImageCacheService.edgeMaxLength + let width = height * aspectRadio + return CGSize(width: width, height: height) + } + }() + + let image = UIImage(blurHash: blurhash, size: imageSize) + + return image } } @@ -62,9 +66,9 @@ extension BlurhashImageCacheService { class Key: NSObject { let blurhash: String let size: CGSize - let url: URL + let url: String - init(blurhash: String, size: CGSize, url: URL) { + init(blurhash: String, size: CGSize, url: String) { self.blurhash = blurhash self.size = size self.url = url @@ -83,6 +87,5 @@ extension BlurhashImageCacheService { size.height.hashValue ^ url.hashValue } - } } diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 3.xcdatamodel/contents b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 3.xcdatamodel/contents index 9f6f3ce17..fbdf742ef 100644 --- a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 3.xcdatamodel/contents +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 3.xcdatamodel/contents @@ -129,11 +129,6 @@ - - - - - diff --git a/MastodonSDK/Sources/MastodonUI/DateTimeProvider.swift b/MastodonSDK/Sources/MastodonUI/DateTimeProvider.swift new file mode 100644 index 000000000..0bd5b695f --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/DateTimeProvider.swift @@ -0,0 +1,12 @@ +// +// DateTimeProvider.swift +// +// +// Created by MainasuK on 2022-1-29. +// + +import Foundation + +public protocol DateTimeProvider { + func shortTimeAgoSinceNow(to date: Date?) -> String? +} diff --git a/MastodonSDK/Sources/MastodonUI/Extension/Date.swift b/MastodonSDK/Sources/MastodonUI/Extension/Date.swift index de377ee24..89d31dc91 100644 --- a/MastodonSDK/Sources/MastodonUI/Extension/Date.swift +++ b/MastodonSDK/Sources/MastodonUI/Extension/Date.swift @@ -9,6 +9,40 @@ import Foundation import MastodonAsset import MastodonLocalization +extension Date { + + public static let relativeTimestampFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.dateTimeStyle = .numeric + formatter.unitsStyle = .full + return formatter + }() + + public var localizedSlowedTimeAgoSinceNow: String { + return self.localizedTimeAgo(since: Date(), isSlowed: true, isAbbreviated: true) + } + + public var localizedTimeAgoSinceNow: String { + return self.localizedTimeAgo(since: Date(), isSlowed: false, isAbbreviated: false) + } + + public func localizedTimeAgo(since date: Date, isSlowed: Bool, isAbbreviated: Bool) -> String { + let earlierDate = date < self ? date : self + let latestDate = earlierDate == date ? self : date + + if isSlowed, earlierDate.timeIntervalSince(latestDate) >= -60 { + return L10n.Common.Controls.Timeline.Timestamp.now + } else { + if isAbbreviated { + return latestDate.localizedShortTimeAgo(since: earlierDate) + } else { + return Date.relativeTimestampFormatter.localizedString(for: earlierDate, relativeTo: latestDate) + } + } + } + +} + extension Date { public func localizedShortTimeAgo(since date: Date) -> String { diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift index b5468726d..cb4d742bb 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView+Configuration.swift @@ -12,13 +12,25 @@ import CoreData import Photos extension MediaView { - public enum Configuration: Hashable { - case image(info: ImageInfo) - case gif(info: VideoInfo) - case video(info: VideoInfo) + public class Configuration: Hashable { + + public let info: Info + public let blurhash: String? + + @Published public var isReveal = true + @Published public var blurhashImage: UIImage? + public var blurhashImageDisposeBag = Set() + + public init( + info: MediaView.Configuration.Info, + blurhash: String? + ) { + self.info = info + self.blurhash = blurhash + } public var aspectRadio: CGSize { - switch self { + switch info { case .image(let info): return info.aspectRadio case .gif(let info): return info.aspectRadio case .video(let info): return info.aspectRadio @@ -26,7 +38,7 @@ extension MediaView { } public var assetURL: String? { - switch self { + switch info { case .image(let info): return info.assetURL case .gif(let info): @@ -37,7 +49,7 @@ extension MediaView { } public var resourceType: PHAssetResourceType { - switch self { + switch info { case .image: return .photo case .gif: @@ -47,51 +59,72 @@ extension MediaView { } } - public struct ImageInfo: Hashable { - public let aspectRadio: CGSize - public let assetURL: String? - - public init( - aspectRadio: CGSize, - assetURL: String? - ) { - self.aspectRadio = aspectRadio - self.assetURL = assetURL - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(aspectRadio.width) - hasher.combine(aspectRadio.height) - assetURL.flatMap { hasher.combine($0) } - } + public static func == (lhs: MediaView.Configuration, rhs: MediaView.Configuration) -> Bool { + return lhs.info == rhs.info + && lhs.blurhash == rhs.blurhash + && lhs.isReveal == rhs.isReveal } - public struct VideoInfo: Hashable { - public let aspectRadio: CGSize - public let assetURL: String? - public let previewURL: String? - public let durationMS: Int? - - public init( - aspectRadio: CGSize, - assetURL: String?, - previewURL: String?, - durationMS: Int? - ) { - self.aspectRadio = aspectRadio - self.assetURL = assetURL - self.previewURL = previewURL - self.durationMS = durationMS - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(aspectRadio.width) - hasher.combine(aspectRadio.height) - assetURL.flatMap { hasher.combine($0) } - previewURL.flatMap { hasher.combine($0) } - durationMS.flatMap { hasher.combine($0) } - } + public func hash(into hasher: inout Hasher) { + hasher.combine(info) + hasher.combine(blurhash) } + } } +extension MediaView.Configuration { + + public enum Info: Hashable { + case image(info: ImageInfo) + case gif(info: VideoInfo) + case video(info: VideoInfo) + } + + public struct ImageInfo: Hashable { + public let aspectRadio: CGSize + public let assetURL: String? + + public init( + aspectRadio: CGSize, + assetURL: String? + ) { + self.aspectRadio = aspectRadio + self.assetURL = assetURL + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(aspectRadio.width) + hasher.combine(aspectRadio.height) + assetURL.flatMap { hasher.combine($0) } + } + } + + public struct VideoInfo: Hashable { + public let aspectRadio: CGSize + public let assetURL: String? + public let previewURL: String? + public let durationMS: Int? + + public init( + aspectRadio: CGSize, + assetURL: String?, + previewURL: String?, + durationMS: Int? + ) { + self.aspectRadio = aspectRadio + self.assetURL = assetURL + self.previewURL = previewURL + self.durationMS = durationMS + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(aspectRadio.width) + hasher.combine(aspectRadio.height) + assetURL.flatMap { hasher.combine($0) } + previewURL.flatMap { hasher.combine($0) } + durationMS.flatMap { hasher.combine($0) } + } + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift index 7cc040076..e51330d7d 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift @@ -8,9 +8,12 @@ import AVKit import UIKit +import Combine public final class MediaView: UIView { + var _disposeBag = Set() + public static let cornerRadius: CGFloat = 0 public static let durationFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -23,6 +26,14 @@ public final class MediaView: UIView { public private(set) var configuration: Configuration? + private(set) lazy var blurhashImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.isUserInteractionEnabled = false + imageView.layer.masksToBounds = true // clip overflow + return imageView + }() + private(set) lazy var imageView: UIImageView = { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill @@ -91,7 +102,7 @@ extension MediaView { setupContainerViewHierarchy() - switch configuration { + switch configuration.info { case .image(let info): configure(image: info) case .gif(let info): @@ -99,6 +110,31 @@ extension MediaView { case .video(let info): configure(video: info) } + + if let blurhash = configuration.blurhash { + configure(blurhash: blurhash) + + configuration.$blurhashImage + .receive(on: DispatchQueue.main) + .assign(to: \.image, on: blurhashImageView) + .store(in: &_disposeBag) + + blurhashImageView.alpha = configuration.isReveal ? 0 : 1 + } + + configuration.$isReveal + .dropFirst() + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] isReveal in + guard let self = self else { return } + let animator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) + animator.addAnimations { + self.blurhashImageView.alpha = isReveal ? 0 : 1 + } + animator.startAnimation() + } + .store(in: &_disposeBag) } private func configure(image info: Configuration.ImageInfo) { @@ -122,7 +158,7 @@ extension MediaView { placeholderImage: placeholder ) } - + private func configure(gif info: Configuration.VideoInfo) { // use view controller as View here playerViewController.view.translatesAutoresizingMaskIntoConstraints = false @@ -188,7 +224,22 @@ extension MediaView { } + private func configure(blurhash: String) { + blurhashImageView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(blurhashImageView) + NSLayoutConstraint.activate([ + blurhashImageView.topAnchor.constraint(equalTo: container.topAnchor), + blurhashImageView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + blurhashImageView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + blurhashImageView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + blurhashImageView.backgroundColor = .systemGray + } + public func prepareForReuse() { + _disposeBag.removeAll() + // reset appearance alpha = 1 @@ -207,6 +258,11 @@ extension MediaView { playerViewController.player = nil playerLooper = nil + // blurhash + blurhashImageView.removeFromSuperview() + blurhashImageView.removeConstraints(blurhashImageView.constraints) + blurhashImageView.image = nil + // reset indicator indicatorBlurEffectView.removeFromSuperview() diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift index e73dd3efd..2151b55b0 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView+ViewModel.swift @@ -34,7 +34,6 @@ extension NotificationView { @Published public var isBlocking = false @Published public var timestamp: Date? - public var timestampFormatter: ((_ date: Date) -> String)? let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) .autoconnect() @@ -100,13 +99,12 @@ extension NotificationView.ViewModel { ) .sink { [weak self] timestamp, _ in guard let self = self else { return } - guard let timestamp = timestamp, - let text = self.timestampFormatter?(timestamp) - else { + guard let timestamp = timestamp else { notificationView.dateLabel.configure(content: PlaintextMetaContent(string: "")) return } + let text = timestamp.localizedTimeAgoSinceNow notificationView.dateLabel.configure(content: PlaintextMetaContent(string: text)) } .store(in: &disposeBag) diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index 2b1098d11..ea1d6deba 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -35,6 +35,8 @@ extension StatusView { @Published public var authorName: MetaContent? @Published public var authorUsername: String? + @Published public var locked = false + @Published public var isMyself = false @Published public var isMuting = false @Published public var isBlocking = false @@ -125,6 +127,10 @@ extension StatusView { } init() { + // isReblogEnabled + $locked + .map { !$0 } + .assign(to: &$isReblogEnabled) // isContentSensitive $spoilerContent .map { $0 != nil } @@ -141,14 +147,14 @@ extension StatusView { $isContentSensitive, $isContentSensitiveToggled ) - .map { $1 ? $0 : !$0 } + .map { $0 ? $1 : true } .assign(to: &$isContentReveal) // $isMediaReveal Publishers.CombineLatest( $isMediaSensitive, $isMediaSensitiveToggled ) - .map { $1 ? !$0 : $0} + .map { $0 ? $1 : true } .assign(to: &$isMediaReveal) } } @@ -300,19 +306,16 @@ extension StatusView.ViewModel { } } .store(in: &disposeBag) - Publishers.CombineLatest( - $isContentSensitive, - $isMediaSensitive - ) - .sink { isContentSensitive, isMediaSensitive in - if isContentSensitive || isMediaSensitive { - let image = Asset.Human.eyeCircleFill.image - statusView.contentWarningToggleButton.setImage(image, for: .normal) - statusView.contentWarningToggleButton.tintColor = .systemGray - statusView.setContentWarningToggleButtonDisplay() + $isSensitive + .sink { isSensitive in + if isSensitive { + let image = Asset.Human.eyeCircleFill.image + statusView.contentWarningToggleButton.setImage(image, for: .normal) + statusView.contentWarningToggleButton.tintColor = .systemGray + statusView.setContentWarningToggleButtonDisplay() + } } - } - .store(in: &disposeBag) + .store(in: &disposeBag) // $spoilerContent // .sink { metaContent in // guard let metaContent = metaContent else { @@ -411,6 +414,17 @@ extension StatusView.ViewModel { } .store(in: &disposeBag) + Publishers.CombineLatest( + $mediaViewConfigurations, + $isMediaReveal + ) + .sink { configurations, isMediaReveal in + for configuration in configurations { + configuration.isReveal = isMediaReveal + } + } + .store(in: &disposeBag) + // FIXME: statusView.mediaGridContainerView.viewModel.isContentWarningOverlayDisplay = false // $isMediaReveal diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index 0b8cae96a..26ca62490 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -559,6 +559,7 @@ extension StatusView.Style { statusView.usernameTrialingDotLabel.removeFromSuperview() statusView.dateLabel.removeFromSuperview() statusView.contentContainer.removeFromSuperview() + statusView.spoilerOverlayView.removeFromSuperview() statusView.mediaContainerView.removeFromSuperview() statusView.pollContainerView.removeFromSuperview() statusView.statusVisibilityView.removeFromSuperview()