From 680cf9a827ed03db1c26c7e87df20fbdfeff24ca Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 16 Apr 2021 20:06:36 +0800 Subject: [PATCH 01/10] feat: add blurhash image and update content warning --- .../CoreData.xcdatamodel/contents | 3 +- CoreDataStack/Entity/HomeTimelineIndex.swift | 2 +- CoreDataStack/Entity/Status.swift | 101 +++++---- Localization/app.json | 3 +- Mastodon.xcodeproj/project.pbxproj | 8 + .../xcschemes/xcschememanagement.plist | 2 +- .../xcshareddata/swiftpm/Package.resolved | 20 +- Mastodon/Diffiable/Item/Item.swift | 54 +---- .../Diffiable/Section/StatusSection.swift | 204 +++++++++++++---- Mastodon/Extension/CoreDataStack/Status.swift | 31 +++ Mastodon/Generated/Strings.swift | 8 +- ...Provider+StatusTableViewCellDelegate.swift | 50 +--- .../StatusProvider/StatusProviderFacade.swift | 48 ++++ .../Resources/en.lproj/Localizable.strings | 3 +- ...iedToStatusContentCollectionViewCell.swift | 4 +- .../Compose/View/ComposeToolbarView.swift | 10 +- .../PublicTimelineViewModel+Diffable.swift | 2 +- .../Container/MosaicImageViewContainer.swift | 94 ++++++-- .../Content/ContentWarningOverlayView.swift | 103 ++++++++- .../Scene/Share/View/Content/StatusView.swift | 213 ++++++++++-------- .../TableviewCell/StatusTableViewCell.swift | 37 ++- .../ViewModel/MosaicImageViewModel.swift | 41 +++- Mastodon/State/DocumentStore.swift | 7 +- Mastodon/Vender/BlurHashDecode.swift | 146 ++++++++++++ Mastodon/Vender/BlurHashEncode.swift | 145 ++++++++++++ 25 files changed, 1014 insertions(+), 325 deletions(-) create mode 100644 Mastodon/Vender/BlurHashDecode.swift create mode 100644 Mastodon/Vender/BlurHashEncode.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index eb095669a..3b558f9ff 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -168,6 +168,7 @@ + @@ -215,7 +216,7 @@ - + \ No newline at end of file diff --git a/CoreDataStack/Entity/HomeTimelineIndex.swift b/CoreDataStack/Entity/HomeTimelineIndex.swift index a902f5ce5..10b00aaa0 100644 --- a/CoreDataStack/Entity/HomeTimelineIndex.swift +++ b/CoreDataStack/Entity/HomeTimelineIndex.swift @@ -52,7 +52,7 @@ extension HomeTimelineIndex { } } - // internal method for Toot call + // internal method for status call func softDelete() { deletedAt = Date() } diff --git a/CoreDataStack/Entity/Status.swift b/CoreDataStack/Entity/Status.swift index f40f78639..1bb71a1db 100644 --- a/CoreDataStack/Entity/Status.swift +++ b/CoreDataStack/Entity/Status.swift @@ -1,5 +1,5 @@ // -// Toot.swift +// Status.swift // CoreDataStack // // Created by MainasuK Cirno on 2021/1/27. @@ -62,11 +62,13 @@ public final class Status: NSManagedObject { @NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var deletedAt: Date? + @NSManaged public private(set) var revealedAt: Date? } -public extension Status { +extension Status { + @discardableResult - static func insert( + public static func insert( into context: NSManagedObjectContext, property: Property, author: MastodonUser, @@ -84,81 +86,81 @@ public extension Status { bookmarkedBy: MastodonUser?, pinnedBy: MastodonUser? ) -> Status { - let toot: Status = context.insertObject() + let status: Status = context.insertObject() - toot.identifier = property.identifier - toot.domain = property.domain + status.identifier = property.identifier + status.domain = property.domain - toot.id = property.id - toot.uri = property.uri - toot.createdAt = property.createdAt - toot.content = property.content + status.id = property.id + status.uri = property.uri + status.createdAt = property.createdAt + status.content = property.content - toot.visibility = property.visibility - toot.sensitive = property.sensitive - toot.spoilerText = property.spoilerText - toot.application = application + status.visibility = property.visibility + status.sensitive = property.sensitive + status.spoilerText = property.spoilerText + status.application = application - toot.reblogsCount = property.reblogsCount - toot.favouritesCount = property.favouritesCount - toot.repliesCount = property.repliesCount + status.reblogsCount = property.reblogsCount + status.favouritesCount = property.favouritesCount + status.repliesCount = property.repliesCount - toot.url = property.url - toot.inReplyToID = property.inReplyToID - toot.inReplyToAccountID = property.inReplyToAccountID + status.url = property.url + status.inReplyToID = property.inReplyToID + status.inReplyToAccountID = property.inReplyToAccountID - toot.language = property.language - toot.text = property.text + status.language = property.language + status.text = property.text - toot.author = author - toot.reblog = reblog + status.author = author + status.reblog = reblog - toot.pinnedBy = pinnedBy - toot.poll = poll + status.pinnedBy = pinnedBy + status.poll = poll if let mentions = mentions { - toot.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions) + status.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions) } if let emojis = emojis { - toot.mutableSetValue(forKey: #keyPath(Status.emojis)).addObjects(from: emojis) + status.mutableSetValue(forKey: #keyPath(Status.emojis)).addObjects(from: emojis) } if let tags = tags { - toot.mutableSetValue(forKey: #keyPath(Status.tags)).addObjects(from: tags) + status.mutableSetValue(forKey: #keyPath(Status.tags)).addObjects(from: tags) } if let mediaAttachments = mediaAttachments { - toot.mutableSetValue(forKey: #keyPath(Status.mediaAttachments)).addObjects(from: mediaAttachments) + status.mutableSetValue(forKey: #keyPath(Status.mediaAttachments)).addObjects(from: mediaAttachments) } if let favouritedBy = favouritedBy { - toot.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(favouritedBy) + status.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(favouritedBy) } if let rebloggedBy = rebloggedBy { - toot.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(rebloggedBy) + status.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(rebloggedBy) } if let mutedBy = mutedBy { - toot.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mutedBy) + status.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mutedBy) } if let bookmarkedBy = bookmarkedBy { - toot.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(bookmarkedBy) + status.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(bookmarkedBy) } - toot.updatedAt = property.networkDate + status.updatedAt = property.networkDate - return toot + return status } - func update(reblogsCount: NSNumber) { + public func update(reblogsCount: NSNumber) { if self.reblogsCount.intValue != reblogsCount.intValue { self.reblogsCount = reblogsCount } } - func update(favouritesCount: NSNumber) { + public func update(favouritesCount: NSNumber) { if self.favouritesCount.intValue != favouritesCount.intValue { self.favouritesCount = favouritesCount } } - func update(repliesCount: NSNumber?) { + public func update(repliesCount: NSNumber?) { guard let count = repliesCount else { return } @@ -167,13 +169,13 @@ public extension Status { } } - func update(replyTo: Status?) { + public func update(replyTo: Status?) { if self.replyTo != replyTo { self.replyTo = replyTo } } - func update(liked: Bool, by mastodonUser: MastodonUser) { + public func update(liked: Bool, by mastodonUser: MastodonUser) { if liked { if !(self.favouritedBy ?? Set()).contains(mastodonUser) { self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(mastodonUser) @@ -185,7 +187,7 @@ public extension Status { } } - func update(reblogged: Bool, by mastodonUser: MastodonUser) { + public func update(reblogged: Bool, by mastodonUser: MastodonUser) { if reblogged { if !(self.rebloggedBy ?? Set()).contains(mastodonUser) { self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(mastodonUser) @@ -197,7 +199,7 @@ public extension Status { } } - func update(muted: Bool, by mastodonUser: MastodonUser) { + public func update(muted: Bool, by mastodonUser: MastodonUser) { if muted { if !(self.mutedBy ?? Set()).contains(mastodonUser) { self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mastodonUser) @@ -209,7 +211,7 @@ public extension Status { } } - func update(bookmarked: Bool, by mastodonUser: MastodonUser) { + public func update(bookmarked: Bool, by mastodonUser: MastodonUser) { if bookmarked { if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) { self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(mastodonUser) @@ -221,14 +223,18 @@ public extension Status { } } - func didUpdate(at networkDate: Date) { + public func update(isReveal: Bool) { + revealedAt = isReveal ? Date() : nil + } + + public func didUpdate(at networkDate: Date) { self.updatedAt = networkDate } } -public extension Status { - struct Property { +extension Status { + public struct Property { public let identifier: ID public let domain: String @@ -337,4 +343,5 @@ extension Status { public static func deleted() -> NSPredicate { return NSPredicate(format: "%K != nil", #keyPath(Status.deletedAt)) } + } diff --git a/Localization/app.json b/Localization/app.json index 5d8ad2645..e3ae30e9e 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -52,7 +52,8 @@ "user_reblogged": "%s reblogged", "user_replied_to": "Replied to %s", "show_post": "Show Post", - "status_content_warning": "content warning", + "content_warning": "content warning", + "content_warning_text": "cw: %s", "media_content_warning": "Tap to reveal that may be sensitive", "poll": { "vote": "Vote", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index f6bf54a2b..ff243e8ba 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -207,6 +207,8 @@ DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; }; + DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB51D170262832380062B7A1 /* BlurHashDecode.swift */; }; + DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB51D171262832380062B7A1 /* BlurHashEncode.swift */; }; DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */; }; DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; }; DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; }; @@ -605,6 +607,8 @@ DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; + DB51D170262832380062B7A1 /* BlurHashDecode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; + DB51D171262832380062B7A1 /* BlurHashEncode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = ""; }; DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TwitterTextEditor+String.swift"; sourceTree = ""; }; DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = ""; }; DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; @@ -977,6 +981,8 @@ DB2B3AE825E38850007045F9 /* UIViewPreview.swift */, DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */, DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */, + DB51D170262832380062B7A1 /* BlurHashDecode.swift */, + DB51D171262832380062B7A1 /* BlurHashEncode.swift */, ); path = Vender; sourceTree = ""; @@ -2461,6 +2467,7 @@ 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */, DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */, DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */, + DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */, DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, @@ -2484,6 +2491,7 @@ 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, + DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */, DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index fd1ce69a1..c25eac1fa 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 20 + 11 Mastodon - RTL.xcscheme_^#shared#^_ diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3bd82fce8..741947371 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/Alamofire/Alamofire.git", "state": { "branch": null, - "revision": "eaf6e622dd41b07b251d8f01752eab31bc811493", - "version": "5.4.1" + "revision": "4d19ad82f80cc71ff829b941ded114c56f4f604c", + "version": "5.4.2" } }, { @@ -24,8 +24,8 @@ "repositoryURL": "https://github.com/Alamofire/AlamofireImage.git", "state": { "branch": null, - "revision": "3e8edbeb75227f8542aa87f90240cf0424d6362f", - "version": "4.1.0" + "revision": "98cbb00ce0ec5fc8e52a5b50a6bfc08d3e5aee10", + "version": "4.2.0" } }, { @@ -51,8 +51,8 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "daebf8ddf974164d1b9a050c8231e263f3106b09", - "version": "6.1.0" + "revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e", + "version": "6.2.1" } }, { @@ -87,8 +87,8 @@ "repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git", "state": { "branch": null, - "revision": "2b6054efa051565954e1d2b9da831680026cd768", - "version": "5.0.0" + "revision": "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07", + "version": "5.0.1" } }, { @@ -96,8 +96,8 @@ "repositoryURL": "https://github.com/uias/Tabman", "state": { "branch": null, - "revision": "bce2c87659c0ed868e6ef0aa1e05a330e202533f", - "version": "2.11.0" + "revision": "f43489cdd743ba7ad86a422ebb5fcbf34e333df4", + "version": "2.11.1" } }, { diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index da3455201..cb01ccdcf 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -5,6 +5,7 @@ // Created by sxiaojian on 2021/1/27. // +import Combine import CoreData import CoreDataStack import Foundation @@ -33,59 +34,18 @@ enum Item { case emptyStateHeader(attribute: EmptyStateHeaderAttribute) } -protocol StatusContentWarningAttribute { - var isStatusTextSensitive: Bool? { get set } - var isStatusSensitive: Bool? { get set } -} - extension Item { - class StatusAttribute: StatusContentWarningAttribute { - var isStatusTextSensitive: Bool? - var isStatusSensitive: Bool? + class StatusAttribute { var isSeparatorLineHidden: Bool + + let isImageLoaded = CurrentValueSubject(false) + let isMediaRevealing = CurrentValueSubject(false) - init( - isStatusTextSensitive: Bool? = nil, - isStatusSensitive: Bool? = nil, - isSeparatorLineHidden: Bool = false - ) { - self.isStatusTextSensitive = isStatusTextSensitive - self.isStatusSensitive = isStatusSensitive + init(isSeparatorLineHidden: Bool = false) { self.isSeparatorLineHidden = isSeparatorLineHidden } - - // delay attribute init - func setupForStatus(status: Status) { - if isStatusTextSensitive == nil { - isStatusTextSensitive = { - guard let spoilerText = status.spoilerText, !spoilerText.isEmpty else { return false } - return true - }() - } - - if isStatusSensitive == nil { - isStatusSensitive = status.sensitive - } - } } - -// class LeafAttribute { -// let identifier = UUID() -// let statusID: Status.ID -// var level: Int = 0 -// var hasReply: Bool = true -// -// init( -// statusID: Status.ID, -// level: Int, -// hasReply: Bool = true -// ) { -// self.statusID = statusID -// self.level = level -// self.hasReply = hasReply -// } -// } - + class EmptyStateHeaderAttribute: Hashable { let id = UUID() let reason: Reason diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 36d4853a8..f2b3059c9 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -134,10 +134,7 @@ extension StatusSection { status: Status, requestUserID: String, statusItemAttribute: Item.StatusAttribute - ) { - // setup attribute - statusItemAttribute.setupForStatus(status: status.reblog ?? status) - + ) { // set header StatusSection.configureHeader(cell: cell, status: status) ManagedObjectObserver.observe(object: status) @@ -172,19 +169,6 @@ extension StatusSection { // set text cell.statusView.activeTextLabel.configure(content: (status.reblog ?? status).content) - // set status text content warning - let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive ?? false - let spoilerText = (status.reblog ?? status).spoilerText ?? "" - cell.statusView.isStatusTextSensitive = isStatusTextSensitive - cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive) - cell.statusView.contentWarningTitle.text = { - if spoilerText.isEmpty { - return L10n.Common.Controls.Status.statusContentWarning - } else { - return L10n.Common.Controls.Status.statusContentWarning + ": \(spoilerText)" - } - }() - // prepare media attachments let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } @@ -208,30 +192,68 @@ extension StatusSection { }() return CGSize(width: maxWidth, height: maxWidth * scale) }() - if mosiacImageViewModel.metas.count == 1 { - let meta = mosiacImageViewModel.metas[0] - let imageView = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize) + let blurhashImageCache = dependency.context.documentStore.blurhashImageCache + let mosaics: [MosaicImageViewContainer.ConfigurableMosaic] = { + if mosiacImageViewModel.metas.count == 1 { + let meta = mosiacImageViewModel.metas[0] + let mosaic = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize) + return [mosaic] + } else { + let mosaics = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height) + return mosaics + } + }() + for (i, mosiac) in mosaics.enumerated() { + let (imageView, blurhashOverlayImageView) = mosiac + let meta = mosiacImageViewModel.metas[i] + let blurhashImageDataKey = meta.url.absoluteString as NSString + if let blurhashImageData = blurhashImageCache.object(forKey: meta.url.absoluteString as NSString), + let image = UIImage(data: blurhashImageData as Data) { + blurhashOverlayImageView.image = image + } else { + meta.blurhashImagePublisher() + .receive(on: DispatchQueue.main) + .sink { image in + blurhashOverlayImageView.image = image + image?.pngData().flatMap { + blurhashImageCache.setObject($0 as NSData, forKey: blurhashImageDataKey) + } + } + .store(in: &cell.disposeBag) + } imageView.af.setImage( withURL: meta.url, placeholderImage: UIImage.placeholder(color: .systemFill), imageTransition: .crossDissolve(0.2) - ) - } else { - let imageViews = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height) - for (i, imageView) in imageViews.enumerated() { - let meta = mosiacImageViewModel.metas[i] - imageView.af.setImage( - withURL: meta.url, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) - ) + ) { response in + switch response.result { + case .success: + statusItemAttribute.isImageLoaded.value = true + case .failure: + break + } } + Publishers.CombineLatest( + statusItemAttribute.isImageLoaded, + statusItemAttribute.isMediaRevealing + ) + .receive(on: DispatchQueue.main) + .sink { isImageLoaded, isMediaRevealing in + guard isImageLoaded else { + blurhashOverlayImageView.alpha = 1 + blurhashOverlayImageView.isHidden = false + return + } + + let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) + animator.addAnimations { + blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1 + } + animator.startAnimation() + } + .store(in: &cell.disposeBag) } cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty - let isStatusSensitive = statusItemAttribute.isStatusSensitive ?? false - cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil - cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 - cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive // set audio if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first { @@ -253,10 +275,6 @@ extension StatusSection { return CGSize(width: maxWidth, height: maxWidth * scale) }() - cell.statusView.playerContainerView.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil - cell.statusView.playerContainerView.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 - cell.statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive - if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first, let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) { @@ -294,6 +312,34 @@ extension StatusSection { cell.statusView.playerContainerView.playerViewController.player?.pause() cell.statusView.playerContainerView.playerViewController.player = nil } + + // set text content warning + StatusSection.configureContentWarningOverlay( + statusView: cell.statusView, + status: status, + attribute: statusItemAttribute, + documentStore: dependency.context.documentStore, + animated: false + ) + // observe model change + ManagedObjectObserver.observe(object: status) + .receive(on: DispatchQueue.main) + .sink { _ in + // do nothing + } receiveValue: { [weak dependency] change in + guard let dependency = dependency else { return } + guard case .update(let object) = change.changeType, + let status = object as? Status else { return } + StatusSection.configureContentWarningOverlay( + statusView: cell.statusView, + status: status, + attribute: statusItemAttribute, + documentStore: dependency.context.documentStore, + animated: true + ) + } + .store(in: &cell.disposeBag) + // set poll let poll = (status.reblog ?? status).poll StatusSection.configurePoll( @@ -352,6 +398,88 @@ extension StatusSection { .store(in: &cell.disposeBag) } + static func configureContentWarningOverlay( + statusView: StatusView, + status: Status, + attribute: Item.StatusAttribute, + documentStore: DocumentStore, + animated: Bool + ) { + statusView.contentWarningOverlayView.blurContentWarningTitleLabel.text = { + let spoilerText = status.spoilerText ?? "" + if spoilerText.isEmpty { + return L10n.Common.Controls.Status.contentWarning + } else { + return L10n.Common.Controls.Status.contentWarningText(spoilerText) + } + }() + let appStartUpTimestamp = documentStore.appStartUpTimestamp + + switch (status.reblog ?? status).sensitiveType { + case .none: + statusView.revealContentWarningButton.isHidden = true + statusView.contentWarningOverlayView.isHidden = true + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true + statusView.updateContentWarningDisplay(isHidden: true, animated: false) + case .all: + statusView.revealContentWarningButton.isHidden = false + statusView.contentWarningOverlayView.isHidden = false + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true + + if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp { + statusView.updateRevealContentWarningButton(isRevealing: true) + statusView.updateContentWarningDisplay(isHidden: true, animated: animated) + attribute.isMediaRevealing.value = true + } else { + statusView.updateRevealContentWarningButton(isRevealing: false) + statusView.updateContentWarningDisplay(isHidden: false, animated: animated) + attribute.isMediaRevealing.value = false + } + case .media(let isSensitive): + if !isSensitive, documentStore.defaultRevealStatusDict[status.id] == nil { + documentStore.defaultRevealStatusDict[status.id] = true + } + statusView.revealContentWarningButton.isHidden = false + statusView.contentWarningOverlayView.isHidden = true + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = false + statusView.updateContentWarningDisplay(isHidden: true, animated: false) + + func updateContentOverlay() { + let needsReveal: Bool = { + if documentStore.defaultRevealStatusDict[status.id] == true { + return true + } + if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp { + return true + } + + return false + }() + attribute.isMediaRevealing.value = needsReveal + if needsReveal { + statusView.updateRevealContentWarningButton(isRevealing: true) + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = nil + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = 0.0 + statusView.statusMosaicImageViewContainer.isUserInteractionEnabled = false + } else { + statusView.updateRevealContentWarningButton(isRevealing: false) + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = ContentWarningOverlayView.blurVisualEffect + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0 + statusView.statusMosaicImageViewContainer.isUserInteractionEnabled = true + } + } + if animated { + UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) { + updateContentOverlay() + } completion: { _ in + // do nothing + } + } else { + updateContentOverlay() + } + } + } + static func configureThreadMeta( cell: StatusTableViewCell, status: Status diff --git a/Mastodon/Extension/CoreDataStack/Status.swift b/Mastodon/Extension/CoreDataStack/Status.swift index cf4f8a1bd..880be6fa3 100644 --- a/Mastodon/Extension/CoreDataStack/Status.swift +++ b/Mastodon/Extension/CoreDataStack/Status.swift @@ -32,3 +32,34 @@ extension Status.Property { ) } } + +extension Status { + + enum SensitiveType { + case none + case all + case media(isSensitive: Bool) + } + + var sensitiveType: SensitiveType { + let spoilerText = self.spoilerText ?? "" + + // cast .all sensitive when has spoiter text + if !spoilerText.isEmpty { + return .all + } + + if let firstAttachment = mediaAttachments?.first { + // cast .media when has non audio media + if firstAttachment.type != .audio { + return .media(isSensitive: sensitive) + } else { + return .none + } + } + + // not sensitive + return .none + } + +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 6eed41a29..5d486b5f6 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -138,12 +138,16 @@ internal enum L10n { } } internal enum Status { + /// content warning + internal static let contentWarning = L10n.tr("Localizable", "Common.Controls.Status.ContentWarning") + /// cw: %@ + internal static func contentWarningText(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.ContentWarningText", String(describing: p1)) + } /// Tap to reveal that may be sensitive internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning") /// Show Post internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost") - /// content warning - internal static let statusContentWarning = L10n.tr("Localizable", "Common.Controls.Status.StatusContentWarning") /// %@ reblogged internal static func userReblogged(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1)) diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 25322e216..8d9687777 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -28,6 +28,14 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { StatusProviderFacade.responseToStatusActiveLabelAction(provider: self, cell: cell, activeLabel: activeLabel, didTapEntity: entity) } + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { + StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) + } + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) + } + } // MARK: - ActionToolbarContainerDelegate @@ -45,25 +53,6 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { StatusProviderFacade.responseToStatusLikeAction(provider: self, cell: cell) } - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { - guard let diffableDataSource = self.tableViewDiffableDataSource else { return } - guard let item = item(for: cell, indexPath: nil) else { return } - - switch item { - case .homeTimelineIndex(_, let attribute), - .status(_, let attribute), - .root(_, let attribute), - .reply(_, let attribute), - .leaf(_, let attribute): - attribute.isStatusTextSensitive = false - default: - return - } - var snapshot = diffableDataSource.snapshot() - snapshot.reloadItems([item]) - diffableDataSource.apply(snapshot) - } - } // MARK: - MosciaImageViewContainerDelegate @@ -83,28 +72,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } func statusTableViewCell(_ cell: StatusTableViewCell, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - guard let diffableDataSource = self.tableViewDiffableDataSource else { return } - guard let item = item(for: cell, indexPath: nil) else { return } - - switch item { - case .homeTimelineIndex(_, let attribute), - .status(_, let attribute), - .root(_, let attribute), - .reply(_, let attribute), - .leaf(_, let attribute): - attribute.isStatusSensitive = false - default: - return - } - contentWarningOverlayView.isUserInteractionEnabled = false - var snapshot = diffableDataSource.snapshot() - snapshot.reloadItems([item]) - UIView.animate(withDuration: 0.33) { - contentWarningOverlayView.blurVisualEffectView.effect = nil - contentWarningOverlayView.vibrancyVisualEffectView.alpha = 0.0 - } completion: { _ in - diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } + StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) } } diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 0e26614c5..17d2fbe4e 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -415,6 +415,54 @@ extension StatusProviderFacade { } +extension StatusProviderFacade { + + static func responseToStatusContentWarningRevealAction(provider: StatusProvider, cell: UITableViewCell) { + _responseToStatusContentWarningRevealAction( + provider: provider, + status: provider.status(for: cell, indexPath: nil) + ) + } + + private static func _responseToStatusContentWarningRevealAction(provider: StatusProvider, status: Future) { + status + .compactMap { [weak provider] status -> AnyPublisher? in + guard let provider = provider else { return nil } + guard let _status = status else { return nil } + return provider.context.managedObjectContext.performChanges { + guard let status = provider.context.managedObjectContext.object(with: _status.objectID) as? Status else { return } + let appStartUpTimestamp = provider.context.documentStore.appStartUpTimestamp + let isRevealing: Bool = { + if provider.context.documentStore.defaultRevealStatusDict[status.id] == true { + return true + } + if status.reblog.flatMap({ provider.context.documentStore.defaultRevealStatusDict[$0.id] }) == true { + return true + } + if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp { + return true + } + + return false + }() + // toggle reveal + provider.context.documentStore.defaultRevealStatusDict[status.id] = false + status.update(isReveal: !isRevealing) + status.reblog?.update(isReveal: !isRevealing) + } + .map { result in + return status + } + .eraseToAnyPublisher() + } + .sink { _ in + // do nothing + } + .store(in: &provider.context.disposeBag) + } + +} + extension StatusProviderFacade { enum Target { case primary // original status diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index c35d6e633..46275884f 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -46,6 +46,8 @@ Please check your internet connection."; "Common.Controls.Firendship.UnblockUser" = "Unblock %@"; "Common.Controls.Firendship.Unmute" = "Unmute"; "Common.Controls.Firendship.UnmuteUser" = "Unmute %@"; +"Common.Controls.Status.ContentWarning" = "content warning"; +"Common.Controls.Status.ContentWarningText" = "cw: %@"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; "Common.Controls.Status.Poll.Closed" = "Closed"; "Common.Controls.Status.Poll.TimeLeft" = "%@ left"; @@ -55,7 +57,6 @@ Please check your internet connection."; "Common.Controls.Status.Poll.VoterCount.Multiple" = "%d voters"; "Common.Controls.Status.Poll.VoterCount.Single" = "%d voter"; "Common.Controls.Status.ShowPost" = "Show Post"; -"Common.Controls.Status.StatusContentWarning" = "content warning"; "Common.Controls.Status.UserReblogged" = "%@ reblogged"; "Common.Controls.Status.UserRepliedTo" = "Replied to %@"; "Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view Artbot’s profile diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift index 95e9b4f1a..506d61391 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift @@ -19,8 +19,7 @@ final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCel override func prepareForReuse() { super.prepareForReuse() - statusView.isStatusTextSensitive = false - statusView.cleanUpContentWarning() + statusView.updateContentWarningDisplay(isHidden: true, animated: false) disposeBag.removeAll() } @@ -45,7 +44,6 @@ extension ComposeRepliedToStatusContentCollectionViewCell { private func _init() { backgroundColor = .clear - statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.systemBackground.color statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 99288a5e0..2940217e5 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -284,13 +284,13 @@ struct ComposeToolbarView_Previews: PreviewProvider { static var previews: some View { UIViewPreview(width: 375) { - let tootbarView = ComposeToolbarView() - tootbarView.translatesAutoresizingMaskIntoConstraints = false + let toolbarView = ComposeToolbarView() + toolbarView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - tootbarView.widthAnchor.constraint(equalToConstant: 375).priority(.defaultHigh), - tootbarView.heightAnchor.constraint(equalToConstant: 64).priority(.defaultHigh), + toolbarView.widthAnchor.constraint(equalToConstant: 375).priority(.defaultHigh), + toolbarView.heightAnchor.constraint(equalToConstant: 64).priority(.defaultHigh), ]) - return tootbarView + return toolbarView } .previewLayout(.fixed(width: 375, height: 100)) } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index 3ca407caa..27336dc58 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -64,7 +64,7 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { guard let spoilerText = targetStatus.spoilerText, !spoilerText.isEmpty else { return false } return true }() - let attribute = oldSnapshotAttributeDict[status.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetStatus.sensitive) + let attribute = oldSnapshotAttributeDict[status.objectID] ?? Item.StatusAttribute() items.append(Item.status(objectID: status.objectID, attribute: attribute)) if statusIDsWhichHasGap.contains(status.id) { items.append(Item.publicMiddleLoader(statusID: status.id)) diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index b3c03a46b..641050bc2 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -34,9 +34,11 @@ final class MosaicImageViewContainer: UIView { } } } + var blurhashOverlayImageViews: [UIImageView] = [] let contentWarningOverlayView: ContentWarningOverlayView = { let contentWarningOverlayView = ContentWarningOverlayView() + contentWarningOverlayView.configure(style: .visualEffectView) return contentWarningOverlayView }() @@ -96,11 +98,14 @@ extension MosaicImageViewContainer { contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0 contentWarningOverlayView.isUserInteractionEnabled = true imageViews = [] + blurhashOverlayImageViews = [] container.spacing = 1 } - func setupImageView(aspectRatio: CGSize, maxSize: CGSize) -> UIImageView { + typealias ConfigurableMosaic = (imageView: UIImageView, blurhashOverlayImageView: UIImageView) + + func setupImageView(aspectRatio: CGSize, maxSize: CGSize) -> ConfigurableMosaic { reset() let contentView = UIView() @@ -130,6 +135,21 @@ extension MosaicImageViewContainer { containerHeightLayoutConstraint.constant = floor(rect.height) containerHeightLayoutConstraint.isActive = true + let blurhashOverlayImageView = UIImageView() + blurhashOverlayImageView.layer.masksToBounds = true + blurhashOverlayImageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius + blurhashOverlayImageView.layer.cornerCurve = .continuous + blurhashOverlayImageView.contentMode = .scaleAspectFill + blurhashOverlayImageViews.append(blurhashOverlayImageView) + blurhashOverlayImageView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(blurhashOverlayImageView) + NSLayoutConstraint.activate([ + blurhashOverlayImageView.topAnchor.constraint(equalTo: imageView.topAnchor), + blurhashOverlayImageView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor), + blurhashOverlayImageView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor), + blurhashOverlayImageView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), + ]) + addSubview(contentWarningOverlayView) NSLayoutConstraint.activate([ contentWarningOverlayView.topAnchor.constraint(equalTo: imageView.topAnchor), @@ -137,11 +157,11 @@ extension MosaicImageViewContainer { contentWarningOverlayView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor), contentWarningOverlayView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), ]) - - return imageView + + return (imageView, blurhashOverlayImageView) } - func setupImageViews(count: Int, maxHeight: CGFloat) -> [UIImageView] { + func setupImageViews(count: Int, maxHeight: CGFloat) -> [ConfigurableMosaic] { reset() guard count > 1 else { return [] @@ -161,16 +181,25 @@ extension MosaicImageViewContainer { container.addArrangedSubview(contentRightStackView) var imageViews: [UIImageView] = [] + var blurhashOverlayImageViews: [UIImageView] = [] for _ in 0..? var pollTableViewHeightLaoutConstraint: NSLayoutConstraint! @@ -115,25 +116,14 @@ final class StatusView: UIView { return label }() - let statusContainerStackView = UIStackView() - let statusTextContainerView = UIView() - let statusContentWarningContainerStackView = UIStackView() - var statusContentWarningContainerStackViewBottomLayoutConstraint: NSLayoutConstraint! - - let contentWarningTitle: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) - label.textColor = Asset.Colors.Label.primary.color - label.text = L10n.Common.Controls.Status.statusContentWarning - return label - }() - let contentWarningActionButton: UIButton = { - let button = UIButton() - button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .medium)) - button.setTitleColor(Asset.Colors.Label.highlight.color, for: .normal) - button.setTitle(L10n.Common.Controls.Status.showPost, for: .normal) + let revealContentWarningButton: UIButton = { + let button = HighlightDimmableButton() + button.setImage(UIImage(systemName: "eye", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .medium)), for: .normal) + button.tintColor = Asset.Colors.Button.normal.color return button }() + + let statusContainerStackView = UIStackView() let statusMosaicImageViewContainer = MosaicImageViewContainer() let pollTableView: PollTableView = { @@ -179,11 +169,11 @@ final class StatusView: UIView { }() // do not use visual effect view due to we blur text only without background - let contentWarningBlurContentImageView: UIImageView = { - let imageView = UIImageView() - imageView.backgroundColor = Asset.Colors.Background.systemBackground.color - imageView.layer.masksToBounds = false - return imageView + let contentWarningOverlayView: ContentWarningOverlayView = { + let contentWarningOverlayView = ContentWarningOverlayView() + contentWarningOverlayView.layer.masksToBounds = false + contentWarningOverlayView.configure(style: .blurContentImageView) + return contentWarningOverlayView }() let playerContainerView = PlayerContainerView() @@ -250,11 +240,12 @@ extension StatusView { headerContainerStackView.addArrangedSubview(headerInfoLabel) headerIconLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) - // author container: [avatar | author meta container] + // author container: [avatar | author meta container | reveal button] let authorContainerStackView = UIStackView() containerStackView.addArrangedSubview(authorContainerStackView) authorContainerStackView.axis = .horizontal authorContainerStackView.spacing = StatusView.avatarToLabelSpacing + authorContainerStackView.distribution = .fill // avatar avatarView.translatesAutoresizingMaskIntoConstraints = false @@ -310,45 +301,44 @@ extension StatusView { authorMetaContainerStackView.addArrangedSubview(subtitleContainerStackView) subtitleContainerStackView.axis = .horizontal subtitleContainerStackView.addArrangedSubview(usernameLabel) + + // reveal button + authorContainerStackView.addArrangedSubview(revealContentWarningButton) + revealContentWarningButton.setContentHuggingPriority(.required - 2, for: .horizontal) - // status container: [status | image / video | audio | poll | poll status] + // status container: [status | image / video | audio | poll | poll status] (overlay with content warning) containerStackView.addArrangedSubview(statusContainerStackView) statusContainerStackView.axis = .vertical statusContainerStackView.spacing = 10 - statusContainerStackView.addArrangedSubview(statusTextContainerView) - statusTextContainerView.setContentCompressionResistancePriority(.required - 2, for: .vertical) - activeTextLabel.translatesAutoresizingMaskIntoConstraints = false - statusTextContainerView.addSubview(activeTextLabel) - NSLayoutConstraint.activate([ - activeTextLabel.topAnchor.constraint(equalTo: statusTextContainerView.topAnchor), - activeTextLabel.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor), - activeTextLabel.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), - statusTextContainerView.bottomAnchor.constraint(greaterThanOrEqualTo: activeTextLabel.bottomAnchor), - ]) - activeTextLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) - contentWarningBlurContentImageView.translatesAutoresizingMaskIntoConstraints = false - statusTextContainerView.addSubview(contentWarningBlurContentImageView) - NSLayoutConstraint.activate([ - activeTextLabel.topAnchor.constraint(equalTo: contentWarningBlurContentImageView.topAnchor, constant: StatusView.contentWarningBlurRadius), - activeTextLabel.leadingAnchor.constraint(equalTo: contentWarningBlurContentImageView.leadingAnchor, constant: StatusView.contentWarningBlurRadius), - - ]) - statusContentWarningContainerStackView.translatesAutoresizingMaskIntoConstraints = false - statusContentWarningContainerStackView.axis = .vertical - statusContentWarningContainerStackView.distribution = .fill - statusContentWarningContainerStackView.alignment = .center - statusTextContainerView.addSubview(statusContentWarningContainerStackView) - statusContentWarningContainerStackViewBottomLayoutConstraint = statusTextContainerView.bottomAnchor.constraint(greaterThanOrEqualTo: statusContentWarningContainerStackView.bottomAnchor) - NSLayoutConstraint.activate([ - statusContentWarningContainerStackView.topAnchor.constraint(equalTo: statusTextContainerView.topAnchor), - statusContentWarningContainerStackView.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor), - statusContentWarningContainerStackView.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), - statusContentWarningContainerStackViewBottomLayoutConstraint, - ]) - statusContentWarningContainerStackView.addArrangedSubview(contentWarningTitle) - statusContentWarningContainerStackView.addArrangedSubview(contentWarningActionButton) + // content warning overlay + contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addSubview(contentWarningOverlayView) + NSLayoutConstraint.activate([ + statusContainerStackView.topAnchor.constraint(equalTo: contentWarningOverlayView.topAnchor, constant: StatusView.contentWarningBlurRadius).priority(.defaultLow), + statusContainerStackView.leftAnchor.constraint(equalTo: contentWarningOverlayView.leftAnchor, constant: StatusView.contentWarningBlurRadius).priority(.defaultLow), + // only layout to top-left corner and draw image to fit size + ]) + // avoid overlay clip author view + containerStackView.bringSubviewToFront(authorContainerStackView) + + // status + statusContainerStackView.addArrangedSubview(activeTextLabel) + activeTextLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) + + // image statusContainerStackView.addArrangedSubview(statusMosaicImageViewContainer) + + // audio + audioView.translatesAutoresizingMaskIntoConstraints = false + statusContainerStackView.addArrangedSubview(audioView) + NSLayoutConstraint.activate([ + audioView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh) + ]) + + // video & gifv + statusContainerStackView.addArrangedSubview(playerContainerView) + pollTableView.translatesAutoresizingMaskIntoConstraints = false statusContainerStackView.addArrangedSubview(pollTableView) pollTableViewHeightLaoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) @@ -376,17 +366,6 @@ extension StatusView { pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) - // audio - audioView.translatesAutoresizingMaskIntoConstraints = false - statusContainerStackView.addArrangedSubview(audioView) - NSLayoutConstraint.activate([ - audioView.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor), - audioView.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), - audioView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh) - ]) - // video gif - statusContainerStackView.addArrangedSubview(playerContainerView) - // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) @@ -399,12 +378,11 @@ extension StatusView { playerContainerView.isHidden = true avatarStackedContainerButton.isHidden = true - contentWarningBlurContentImageView.isHidden = true - statusContentWarningContainerStackView.isHidden = true - statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false + contentWarningOverlayView.isHidden = true activeTextLabel.delegate = self playerContainerView.delegate = self + contentWarningOverlayView.delegate = self headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:))) headerInfoLabel.isUserInteractionEnabled = true @@ -412,7 +390,7 @@ extension StatusView { avatarButton.addTarget(self, action: #selector(StatusView.avatarButtonDidPressed(_:)), for: .touchUpInside) avatarStackedContainerButton.addTarget(self, action: #selector(StatusView.avatarStackedContainerButtonDidPressed(_:)), for: .touchUpInside) - contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside) + revealContentWarningButton.addTarget(self, action: #selector(StatusView.revealContentWarningButtonDidPressed(_:)), for: .touchUpInside) pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside) } @@ -420,30 +398,64 @@ extension StatusView { extension StatusView { - func cleanUpContentWarning() { - contentWarningBlurContentImageView.image = nil + private func cleanUpContentWarning() { + contentWarningOverlayView.blurContentImageView.image = nil } func drawContentWarningImageView() { - guard activeTextLabel.frame != .zero, - isStatusTextSensitive, - let text = activeTextLabel.text, !text.isEmpty else { - cleanUpContentWarning() + guard window != nil else { return } - let image = UIGraphicsImageRenderer(size: activeTextLabel.frame.size).image { context in - activeTextLabel.draw(activeTextLabel.bounds) + guard needsDrawContentOverlay, statusContainerStackView.frame != .zero else { + cleanUpContentWarning() + return + } + + let format = UIGraphicsImageRendererFormat() + format.opaque = false + let image = UIGraphicsImageRenderer(size: statusContainerStackView.frame.size, format: format).image { context in + statusContainerStackView.drawHierarchy(in: statusContainerStackView.bounds, afterScreenUpdates: true) + + // always draw the blurhash image + statusMosaicImageViewContainer.blurhashOverlayImageViews.forEach { imageView in + guard let image = imageView.image else { return } + guard let frame = imageView.superview?.convert(imageView.frame, to: statusContainerStackView) else { return } + image.draw(in: frame) + } } .blur(radius: StatusView.contentWarningBlurRadius) - contentWarningBlurContentImageView.contentScaleFactor = traitCollection.displayScale - contentWarningBlurContentImageView.image = image + contentWarningOverlayView.blurContentImageView.contentScaleFactor = traitCollection.displayScale + contentWarningOverlayView.blurContentImageView.image = image } - func updateContentWarningDisplay(isHidden: Bool) { - contentWarningBlurContentImageView.isHidden = isHidden - statusContentWarningContainerStackView.isHidden = isHidden - statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = !isHidden + func updateContentWarningDisplay(isHidden: Bool, animated: Bool) { + needsDrawContentOverlay = !isHidden + if animated { + UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) { [weak self] in + guard let self = self else { return } + self.contentWarningOverlayView.alpha = isHidden ? 0 : 1 + } completion: { _ in + // do nothing + } + } else { + contentWarningOverlayView.alpha = isHidden ? 0 : 1 + } + + if !isHidden { + drawContentWarningImageView() + } + } + + func updateRevealContentWarningButton(isRevealing: Bool) { + if !isRevealing { + let image = traitCollection.userInterfaceStyle == .light ? UIImage(systemName: "eye")! : UIImage(systemName: "eye.fill") + revealContentWarningButton.setImage(image, for: .normal) + } else { + let image = traitCollection.userInterfaceStyle == .light ? UIImage(systemName: "eye.slash")! : UIImage(systemName: "eye.slash.fill") + revealContentWarningButton.setImage(image, for: .normal) + } + // TODO: a11y } } @@ -465,9 +477,9 @@ extension StatusView { delegate?.statusView(self, avatarButtonDidPressed: sender) } - @objc private func contentWarningActionButtonPressed(_ sender: UIButton) { + @objc private func revealContentWarningButtonDidPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.statusView(self, contentWarningActionButtonPressed: sender) + delegate?.statusView(self, revealContentWarningButtonDidPressed: sender) } @objc private func pollVoteButtonPressed(_ sender: UIButton) { @@ -485,6 +497,15 @@ extension StatusView: ActiveLabelDelegate { } } +// MARK: - ContentWarningOverlayViewDelegate +extension StatusView: ContentWarningOverlayViewDelegate { + func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { + assert(contentWarningOverlayView === self.contentWarningOverlayView) + delegate?.statusView(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + } + +} + // MARK: - PlayerContainerViewDelegate extension StatusView: PlayerContainerViewDelegate { func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { @@ -554,13 +575,13 @@ struct StatusView_Previews: PreviewProvider { ) statusView.headerContainerStackView.isHidden = false let images = MosaicImageView_Previews.images - let imageViews = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) - for (i, imageView) in imageViews.enumerated() { + let mosaics = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) + for (i, mosaic) in mosaics.enumerated() { + let (imageView, _) = mosaic imageView.image = images[i] } statusView.statusMosaicImageViewContainer.isHidden = false statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true - statusView.isStatusTextSensitive = false return statusView } .previewLayout(.fixed(width: 375, height: 380)) @@ -574,14 +595,14 @@ struct StatusView_Previews: PreviewProvider { ) ) statusView.headerContainerStackView.isHidden = false - statusView.isStatusTextSensitive = true statusView.setNeedsLayout() statusView.layoutIfNeeded() + statusView.updateContentWarningDisplay(isHidden: false, animated: false) statusView.drawContentWarningImageView() - statusView.updateContentWarningDisplay(isHidden: false) let images = MosaicImageView_Previews.images - let imageViews = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) - for (i, imageView) in imageViews.enumerated() { + let mosaics = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) + for (i, mosaic) in mosaics.enumerated() { + let (imageView, _) = mosaic imageView.image = images[i] } statusView.statusMosaicImageViewContainer.isHidden = false diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index afa044b67..d219daddd 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -22,7 +22,8 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton) - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) @@ -55,6 +56,7 @@ final class StatusTableViewCell: UITableViewCell { var disposeBag = Set() var pollCountdownSubscription: AnyCancellable? var observations = Set() + private var selectionBackgroundViewObservation: NSKeyValueObservation? let statusView = StatusView() let threadMetaStackView = UIStackView() @@ -70,8 +72,7 @@ final class StatusTableViewCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() selectionStyle = .default - statusView.isStatusTextSensitive = false - statusView.cleanUpContentWarning() + statusView.updateContentWarningDisplay(isHidden: true, animated: false) statusView.pollTableView.dataSource = nil statusView.playerContainerView.reset() statusView.playerContainerView.isHidden = true @@ -92,8 +93,9 @@ final class StatusTableViewCell: UITableViewCell { override func layoutSubviews() { super.layoutSubviews() + DispatchQueue.main.async { - self.statusView.drawContentWarningImageView() + self.statusView.drawContentWarningImageView() } } @@ -103,7 +105,6 @@ extension StatusTableViewCell { private func _init() { backgroundColor = Asset.Colors.Background.systemBackground.color - statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.systemBackground.color statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) @@ -150,9 +151,22 @@ extension StatusTableViewCell { resetSeparatorLineLayout() } + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + resetContentOverlayBlurImageBackgroundColor(selected: highlighted) + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + resetContentOverlayBlurImageBackgroundColor(selected: selected) + } + } extension StatusTableViewCell { + private func resetSeparatorLineLayout() { separatorLineToEdgeLeadingLayoutConstraint.isActive = false separatorLineToEdgeTrailingLayoutConstraint.isActive = false @@ -181,6 +195,11 @@ extension StatusTableViewCell { } } } + + private func resetContentOverlayBlurImageBackgroundColor(selected: Bool) { + let imageViewBackgroundColor: UIColor? = selected ? selectedBackgroundView?.backgroundColor : backgroundColor + statusView.contentWarningOverlayView.blurContentImageView.backgroundColor = imageViewBackgroundColor + } } // MARK: - UITableViewDelegate @@ -270,8 +289,12 @@ extension StatusTableViewCell: StatusViewDelegate { delegate?.statusTableViewCell(self, statusView: statusView, avatarButtonDidPressed: button) } - func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { - delegate?.statusTableViewCell(self, statusView: statusView, contentWarningActionButtonPressed: button) + func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { + delegate?.statusTableViewCell(self, statusView: statusView, revealContentWarningButtonDidPressed: button) + } + + func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + delegate?.statusTableViewCell(self, statusView: statusView, contentWarningOverlayViewDidPressed: contentWarningOverlayView) } func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { diff --git a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift index ce92ccb77..26e426add 100644 --- a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift @@ -6,6 +6,7 @@ // import UIKit +import Combine import CoreDataStack struct MosaicImageViewModel { @@ -24,7 +25,12 @@ struct MosaicImageViewModel { let url = URL(string: urlString) else { continue } - metas.append(MosaicMeta(url: url, size: CGSize(width: width, height: height))) + let mosaicMeta = MosaicMeta( + url: url, + size: CGSize(width: width, height: height), + blurhash: element.blurhash + ) + metas.append(mosaicMeta) } self.metas = metas } @@ -32,6 +38,39 @@ struct MosaicImageViewModel { } struct MosaicMeta { + static let edgeMaxLength: CGFloat = 20 + let url: URL let size: CGSize + let blurhash: String? + + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.MosaicMeta.working-queue", qos: .userInitiated, attributes: .concurrent) + + func blurhashImagePublisher() -> AnyPublisher { + return Future { promise in + guard let blurhash = blurhash else { + promise(.success(nil)) + return + } + + 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) + } + }() + + workingQueue.async { + let image = UIImage(blurHash: blurhash, size: imageSize) + promise(.success(image)) + } + } + .eraseToAnyPublisher() + } } diff --git a/Mastodon/State/DocumentStore.swift b/Mastodon/State/DocumentStore.swift index b39a29245..8b3f88eb7 100644 --- a/Mastodon/State/DocumentStore.swift +++ b/Mastodon/State/DocumentStore.swift @@ -7,5 +7,10 @@ import UIKit import Combine +import MastodonSDK -class DocumentStore: ObservableObject { } +class DocumentStore: ObservableObject { + let blurhashImageCache = NSCache() + let appStartUpTimestamp = Date() + var defaultRevealStatusDict: [Mastodon.Entity.Status.ID: Bool] = [:] +} diff --git a/Mastodon/Vender/BlurHashDecode.swift b/Mastodon/Vender/BlurHashDecode.swift new file mode 100644 index 000000000..7fe3b3985 --- /dev/null +++ b/Mastodon/Vender/BlurHashDecode.swift @@ -0,0 +1,146 @@ +import UIKit + +extension UIImage { + public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) { + guard blurHash.count >= 6 else { return nil } + + let sizeFlag = String(blurHash[0]).decode83() + let numY = (sizeFlag / 9) + 1 + let numX = (sizeFlag % 9) + 1 + + let quantisedMaximumValue = String(blurHash[1]).decode83() + let maximumValue = Float(quantisedMaximumValue + 1) / 166 + + guard blurHash.count == 4 + 2 * numX * numY else { return nil } + + let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in + if i == 0 { + let value = String(blurHash[2 ..< 6]).decode83() + return decodeDC(value) + } else { + let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83() + return decodeAC(value, maximumValue: maximumValue * punch) + } + } + + let width = Int(size.width) + let height = Int(size.height) + let bytesPerRow = width * 3 + guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil } + CFDataSetLength(data, bytesPerRow * height) + guard let pixels = CFDataGetMutableBytePtr(data) else { return nil } + + for y in 0 ..< height { + for x in 0 ..< width { + var r: Float = 0 + var g: Float = 0 + var b: Float = 0 + + for j in 0 ..< numY { + for i in 0 ..< numX { + let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height)) + let colour = colours[i + j * numX] + r += colour.0 * basis + g += colour.1 * basis + b += colour.2 * basis + } + } + + let intR = UInt8(linearTosRGB(r)) + let intG = UInt8(linearTosRGB(g)) + let intB = UInt8(linearTosRGB(b)) + + pixels[3 * x + 0 + y * bytesPerRow] = intR + pixels[3 * x + 1 + y * bytesPerRow] = intG + pixels[3 * x + 2 + y * bytesPerRow] = intB + } + } + + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) + + guard let provider = CGDataProvider(data: data) else { return nil } + guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { return nil } + + self.init(cgImage: cgImage) + } +} + +private func decodeDC(_ value: Int) -> (Float, Float, Float) { + let intR = value >> 16 + let intG = (value >> 8) & 255 + let intB = value & 255 + return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB)) +} + +private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) { + let quantR = value / (19 * 19) + let quantG = (value / 19) % 19 + let quantB = value % 19 + + let rgb = ( + signPow((Float(quantR) - 9) / 9, 2) * maximumValue, + signPow((Float(quantG) - 9) / 9, 2) * maximumValue, + signPow((Float(quantB) - 9) / 9, 2) * maximumValue + ) + + return rgb +} + +private func signPow(_ value: Float, _ exp: Float) -> Float { + return copysign(pow(abs(value), exp), value) +} + +private func linearTosRGB(_ value: Float) -> Int { + let v = max(0, min(1, value)) + if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) } + else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) } +} + +private func sRGBToLinear(_ value: Type) -> Float { + let v = Float(Int64(value)) / 255 + if v <= 0.04045 { return v / 12.92 } + else { return pow((v + 0.055) / 1.055, 2.4) } +} + +private let encodeCharacters: [String] = { + return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) } +}() + +private let decodeCharacters: [String: Int] = { + var dict: [String: Int] = [:] + for (index, character) in encodeCharacters.enumerated() { + dict[character] = index + } + return dict +}() + +extension String { + func decode83() -> Int { + var value: Int = 0 + for character in self { + if let digit = decodeCharacters[String(character)] { + value = value * 83 + digit + } + } + return value + } +} + +private extension String { + subscript (offset: Int) -> Character { + return self[index(startIndex, offsetBy: offset)] + } + + subscript (bounds: CountableClosedRange) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start...end] + } + + subscript (bounds: CountableRange) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start.. String? { + let pixelWidth = Int(round(size.width * scale)) + let pixelHeight = Int(round(size.height * scale)) + + let context = CGContext( + data: nil, + width: pixelWidth, + height: pixelHeight, + bitsPerComponent: 8, + bytesPerRow: pixelWidth * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + context.scaleBy(x: scale, y: -scale) + context.translateBy(x: 0, y: -size.height) + + UIGraphicsPushContext(context) + draw(at: .zero) + UIGraphicsPopContext() + + guard let cgImage = context.makeImage(), + let dataProvider = cgImage.dataProvider, + let data = dataProvider.data, + let pixels = CFDataGetBytePtr(data) else { + assertionFailure("Unexpected error!") + return nil + } + + let width = cgImage.width + let height = cgImage.height + let bytesPerRow = cgImage.bytesPerRow + + var factors: [(Float, Float, Float)] = [] + for y in 0 ..< components.1 { + for x in 0 ..< components.0 { + let normalisation: Float = (x == 0 && y == 0) ? 1 : 2 + let factor = multiplyBasisFunction(pixels: pixels, width: width, height: height, bytesPerRow: bytesPerRow, bytesPerPixel: cgImage.bitsPerPixel / 8, pixelOffset: 0) { + normalisation * cos(Float.pi * Float(x) * $0 / Float(width)) as Float * cos(Float.pi * Float(y) * $1 / Float(height)) as Float + } + factors.append(factor) + } + } + + let dc = factors.first! + let ac = factors.dropFirst() + + var hash = "" + + let sizeFlag = (components.0 - 1) + (components.1 - 1) * 9 + hash += sizeFlag.encode83(length: 1) + + let maximumValue: Float + if ac.count > 0 { + let actualMaximumValue = ac.map({ max(abs($0.0), abs($0.1), abs($0.2)) }).max()! + let quantisedMaximumValue = Int(max(0, min(82, floor(actualMaximumValue * 166 - 0.5)))) + maximumValue = Float(quantisedMaximumValue + 1) / 166 + hash += quantisedMaximumValue.encode83(length: 1) + } else { + maximumValue = 1 + hash += 0.encode83(length: 1) + } + + hash += encodeDC(dc).encode83(length: 4) + + for factor in ac { + hash += encodeAC(factor, maximumValue: maximumValue).encode83(length: 2) + } + + return hash + } + + private func multiplyBasisFunction(pixels: UnsafePointer, width: Int, height: Int, bytesPerRow: Int, bytesPerPixel: Int, pixelOffset: Int, basisFunction: (Float, Float) -> Float) -> (Float, Float, Float) { + var r: Float = 0 + var g: Float = 0 + var b: Float = 0 + + let buffer = UnsafeBufferPointer(start: pixels, count: height * bytesPerRow) + + for x in 0 ..< width { + for y in 0 ..< height { + let basis = basisFunction(Float(x), Float(y)) + r += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 0 + y * bytesPerRow]) + g += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 1 + y * bytesPerRow]) + b += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 2 + y * bytesPerRow]) + } + } + + let scale = 1 / Float(width * height) + + return (r * scale, g * scale, b * scale) + } +} + +private func encodeDC(_ value: (Float, Float, Float)) -> Int { + let roundedR = linearTosRGB(value.0) + let roundedG = linearTosRGB(value.1) + let roundedB = linearTosRGB(value.2) + return (roundedR << 16) + (roundedG << 8) + roundedB +} + +private func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int { + let quantR = Int(max(0, min(18, floor(signPow(value.0 / maximumValue, 0.5) * 9 + 9.5)))) + let quantG = Int(max(0, min(18, floor(signPow(value.1 / maximumValue, 0.5) * 9 + 9.5)))) + let quantB = Int(max(0, min(18, floor(signPow(value.2 / maximumValue, 0.5) * 9 + 9.5)))) + + return quantR * 19 * 19 + quantG * 19 + quantB +} + +private func signPow(_ value: Float, _ exp: Float) -> Float { + return copysign(pow(abs(value), exp), value) +} + +private func linearTosRGB(_ value: Float) -> Int { + let v = max(0, min(1, value)) + if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) } + else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) } +} + +private func sRGBToLinear(_ value: Type) -> Float { + let v = Float(Int64(value)) / 255 + if v <= 0.04045 { return v / 12.92 } + else { return pow((v + 0.055) / 1.055, 2.4) } +} + +private let encodeCharacters: [String] = { + return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) } +}() + +extension BinaryInteger { + func encode83(length: Int) -> String { + var result = "" + for i in 1 ... length { + let digit = (Int(self) / pow(83, length - i)) % 83 + result += encodeCharacters[Int(digit)] + } + return result + } +} + +private func pow(_ base: Int, _ exponent: Int) -> Int { + return (0 ..< exponent).reduce(1) { value, _ in value * base } +} From e3c6aaf64e073ed489b4a50c0633c98407d95326 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 16 Apr 2021 20:29:08 +0800 Subject: [PATCH 02/10] fix: blurhash image render issue --- .../xcschemes/xcschememanagement.plist | 2 +- Mastodon/Diffiable/Section/StatusSection.swift | 15 ++++++++++----- .../Scene/Share/View/Content/StatusView.swift | 17 +++++++---------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index c25eac1fa..6ec23cf5d 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 11 + 10 Mastodon - RTL.xcscheme_^#shared#^_ diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index f2b3059c9..fc7b02c21 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -245,11 +245,16 @@ extension StatusSection { return } - let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) - animator.addAnimations { - blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1 + blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1 + if isMediaRevealing { + let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) + animator.addAnimations { + blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1 + } + animator.startAnimation() + } else { + cell.statusView.drawContentWarningImageView() } - animator.startAnimation() } .store(in: &cell.disposeBag) } @@ -406,7 +411,7 @@ extension StatusSection { animated: Bool ) { statusView.contentWarningOverlayView.blurContentWarningTitleLabel.text = { - let spoilerText = status.spoilerText ?? "" + let spoilerText = (status.reblog ?? status).spoilerText ?? "" if spoilerText.isEmpty { return L10n.Common.Controls.Status.contentWarning } else { diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 2b0294fee..a58ad1247 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -416,13 +416,6 @@ extension StatusView { format.opaque = false let image = UIGraphicsImageRenderer(size: statusContainerStackView.frame.size, format: format).image { context in statusContainerStackView.drawHierarchy(in: statusContainerStackView.bounds, afterScreenUpdates: true) - - // always draw the blurhash image - statusMosaicImageViewContainer.blurhashOverlayImageViews.forEach { imageView in - guard let image = imageView.image else { return } - guard let frame = imageView.superview?.convert(imageView.frame, to: statusContainerStackView) else { return } - image.draw(in: frame) - } } .blur(radius: StatusView.contentWarningBlurRadius) contentWarningOverlayView.blurContentImageView.contentScaleFactor = traitCollection.displayScale @@ -431,6 +424,11 @@ extension StatusView { func updateContentWarningDisplay(isHidden: Bool, animated: Bool) { needsDrawContentOverlay = !isHidden + + if !isHidden { + drawContentWarningImageView() + } + if animated { UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) { [weak self] in guard let self = self else { return } @@ -442,9 +440,8 @@ extension StatusView { contentWarningOverlayView.alpha = isHidden ? 0 : 1 } - if !isHidden { - drawContentWarningImageView() - } + contentWarningOverlayView.blurContentWarningTitleLabel.isHidden = isHidden + contentWarningOverlayView.blurContentWarningLabel.isHidden = isHidden } func updateRevealContentWarningButton(isRevealing: Bool) { From f7aa5c123d98bc6a089ab30939c32631d2a01dc5 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 19 Apr 2021 17:50:58 +0800 Subject: [PATCH 03/10] fix: compose scene layout issue when presenting reply post --- .../Section/ComposeStatusSection.swift | 4 +- .../Diffiable/Section/StatusSection.swift | 6 +- Mastodon/Extension/NSLayoutConstraint.swift | 5 ++ ...iedToStatusContentCollectionViewCell.swift | 8 +-- .../Scene/Compose/ComposeViewController.swift | 6 +- .../View/Container/PlayerContainerView.swift | 2 + .../Scene/Share/View/Content/StatusView.swift | 55 +++++++++++++------ 7 files changed, 59 insertions(+), 27 deletions(-) diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 0e7c574b4..d42caa404 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -81,10 +81,10 @@ extension ComposeStatusSection { managedObjectContext.perform { guard let replyToStatusObjectID = replyToStatusObjectID, let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else { - cell.statusView.headerContainerStackView.isHidden = true + cell.statusView.headerContainerView.isHidden = true return } - cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerContainerView.isHidden = false cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) cell.statusView.headerInfoLabel.text = L10n.Scene.Compose.replyingToUser(replyTo.author.displayNameWithFallback) } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index fc7b02c21..816f852ea 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -525,7 +525,7 @@ extension StatusSection { status: Status ) { if status.reblog != nil { - cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerContainerView.isHidden = false cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage) cell.statusView.headerInfoLabel.text = { let author = status.author @@ -533,7 +533,7 @@ extension StatusSection { return L10n.Common.Controls.Status.userReblogged(name) }() } else if status.inReplyToID != nil { - cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerContainerView.isHidden = false cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) cell.statusView.headerInfoLabel.text = { guard let replyTo = status.replyTo else { @@ -544,7 +544,7 @@ extension StatusSection { return L10n.Common.Controls.Status.userRepliedTo(name) }() } else { - cell.statusView.headerContainerStackView.isHidden = true + cell.statusView.headerContainerView.isHidden = true } } diff --git a/Mastodon/Extension/NSLayoutConstraint.swift b/Mastodon/Extension/NSLayoutConstraint.swift index cae353187..eea697e2b 100644 --- a/Mastodon/Extension/NSLayoutConstraint.swift +++ b/Mastodon/Extension/NSLayoutConstraint.swift @@ -12,4 +12,9 @@ extension NSLayoutConstraint { self.priority = priority return self } + + func identifier(_ identifier: String?) -> Self { + self.identifier = identifier + return self + } } diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift index 506d61391..275f545df 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift @@ -45,16 +45,16 @@ extension ComposeRepliedToStatusContentCollectionViewCell { private func _init() { backgroundColor = .clear + statusView.actionToolbarContainer.isHidden = true + statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) NSLayoutConstraint.activate([ - statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), + statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20).identifier("statusView.top to ComposeRepliedToStatusContentCollectionViewCell.contentView.top"), statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), + contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10).identifier("ComposeRepliedToStatusContentCollectionViewCell.contentView.bottom to statusView.bottom"), ]) - - statusView.actionToolbarContainer.isHidden = true } } diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index b463f13ac..a18cf9216 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -182,7 +182,7 @@ extension ComposeViewController { ) // respond scrollView overlap change - view.layoutIfNeeded() + //view.layoutIfNeeded() // update layout when keyboard show/dismiss Publishers.CombineLatest4( KeyboardResponderService.shared.isShow.eraseToAnyPublisher(), @@ -210,7 +210,9 @@ extension ComposeViewController { self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin UIView.animate(withDuration: 0.3) { self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom - self.view.layoutIfNeeded() + if self.view.window != nil { + self.view.layoutIfNeeded() + } } self.updateKeyboardBackground(isKeyboardDisplay: isShow) return diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index 2c2229466..9c59a7ebd 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -5,6 +5,7 @@ // Created by xiaojian sun on 2021/3/10. // +import os.log import AVKit import UIKit @@ -93,6 +94,7 @@ extension PlayerContainerView { // MARK: - ContentWarningOverlayViewDelegate extension PlayerContainerView: ContentWarningOverlayViewDelegate { func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) delegate?.playerContainerView(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView) } } diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index a58ad1247..40eb05a58 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -29,6 +29,16 @@ final class StatusView: UIView { static let avatarImageCornerRadius: CGFloat = 4 static let avatarToLabelSpacing: CGFloat = 5 static let contentWarningBlurRadius: CGFloat = 12 + static let containerStackViewSpacing: CGFloat = 10 + + weak var delegate: StatusViewDelegate? + private var needsDrawContentOverlay = false + var pollTableViewDataSource: UITableViewDiffableDataSource? + var pollTableViewHeightLaoutConstraint: NSLayoutConstraint! + + let containerStackView = UIStackView() + let headerContainerView = UIView() + let authorContainerView = UIView() static let reblogIconImage: UIImage = { let font = UIFont.systemFont(ofSize: 13, weight: .medium) @@ -53,13 +63,6 @@ final class StatusView: UIView { return attributedString } - weak var delegate: StatusViewDelegate? - private var needsDrawContentOverlay = false - var pollTableViewDataSource: UITableViewDiffableDataSource? - var pollTableViewHeightLaoutConstraint: NSLayoutConstraint! - - let headerContainerStackView = UIStackView() - let headerIconLabel: UILabel = { let label = UILabel() label.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage) @@ -221,9 +224,9 @@ extension StatusView { func _init() { // container: [reblog | author | status | action toolbar] - let containerStackView = UIStackView() + // note: do not set spacing for nested stackView to avoid SDK layout conflict issue containerStackView.axis = .vertical - containerStackView.spacing = 10 + // containerStackView.spacing = 10 containerStackView.translatesAutoresizingMaskIntoConstraints = false addSubview(containerStackView) NSLayoutConstraint.activate([ @@ -232,17 +235,27 @@ extension StatusView { trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), ]) + containerStackView.setContentHuggingPriority(.required - 1, for: .vertical) // header container: [icon | info] - containerStackView.addArrangedSubview(headerContainerStackView) - headerContainerStackView.spacing = 4 + let headerContainerStackView = UIStackView() + headerContainerStackView.axis = .horizontal headerContainerStackView.addArrangedSubview(headerIconLabel) headerContainerStackView.addArrangedSubview(headerInfoLabel) headerIconLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + headerContainerStackView.translatesAutoresizingMaskIntoConstraints = false + headerContainerView.addSubview(headerContainerStackView) + NSLayoutConstraint.activate([ + headerContainerStackView.topAnchor.constraint(equalTo: headerContainerView.topAnchor), + headerContainerStackView.leadingAnchor.constraint(equalTo: headerContainerView.leadingAnchor), + headerContainerStackView.trailingAnchor.constraint(equalTo: headerContainerView.trailingAnchor), + headerContainerView.bottomAnchor.constraint(equalTo: headerContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh), + ]) + containerStackView.addArrangedSubview(headerContainerView) + // author container: [avatar | author meta container | reveal button] let authorContainerStackView = UIStackView() - containerStackView.addArrangedSubview(authorContainerStackView) authorContainerStackView.axis = .horizontal authorContainerStackView.spacing = StatusView.avatarToLabelSpacing authorContainerStackView.distribution = .fill @@ -306,6 +319,16 @@ extension StatusView { authorContainerStackView.addArrangedSubview(revealContentWarningButton) revealContentWarningButton.setContentHuggingPriority(.required - 2, for: .horizontal) + authorContainerStackView.translatesAutoresizingMaskIntoConstraints = false + authorContainerView.addSubview(authorContainerStackView) + NSLayoutConstraint.activate([ + authorContainerStackView.topAnchor.constraint(equalTo: authorContainerView.topAnchor), + authorContainerStackView.leadingAnchor.constraint(equalTo: authorContainerView.leadingAnchor), + authorContainerStackView.trailingAnchor.constraint(equalTo: authorContainerView.trailingAnchor), + authorContainerView.bottomAnchor.constraint(equalTo: authorContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh), + ]) + containerStackView.addArrangedSubview(authorContainerView) + // status container: [status | image / video | audio | poll | poll status] (overlay with content warning) containerStackView.addArrangedSubview(statusContainerStackView) statusContainerStackView.axis = .vertical @@ -370,7 +393,7 @@ extension StatusView { containerStackView.addArrangedSubview(actionToolbarContainer) actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - headerContainerStackView.isHidden = true + headerContainerView.isHidden = true statusMosaicImageViewContainer.isHidden = true pollTableView.isHidden = true pollStatusStackView.isHidden = true @@ -543,7 +566,7 @@ struct StatusView_Previews: PreviewProvider { .previewDisplayName("Normal") UIViewPreview(width: 375) { let statusView = StatusView() - statusView.headerContainerStackView.isHidden = false + statusView.headerContainerView.isHidden = false statusView.avatarButton.isHidden = true statusView.avatarStackedContainerButton.isHidden = false statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure( @@ -570,7 +593,7 @@ struct StatusView_Previews: PreviewProvider { placeholderImage: avatarFlora ) ) - statusView.headerContainerStackView.isHidden = false + statusView.headerContainerView.isHidden = false let images = MosaicImageView_Previews.images let mosaics = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) for (i, mosaic) in mosaics.enumerated() { @@ -591,7 +614,7 @@ struct StatusView_Previews: PreviewProvider { placeholderImage: avatarFlora ) ) - statusView.headerContainerStackView.isHidden = false + statusView.headerContainerView.isHidden = false statusView.setNeedsLayout() statusView.layoutIfNeeded() statusView.updateContentWarningDisplay(isHidden: false, animated: false) From 81a1028f20043ef543d10c5ce5e1a200c649e217 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 19 Apr 2021 18:33:11 +0800 Subject: [PATCH 04/10] feat: pause video playback when set reveal state to false --- Mastodon/Diffiable/Item/Item.swift | 2 +- .../Diffiable/Section/StatusSection.swift | 19 +++++++++---------- ...Provider+StatusTableViewCellDelegate.swift | 5 ++--- .../StatusProvider/StatusProviderFacade.swift | 6 ++++++ .../Container/MosaicImageViewContainer.swift | 2 ++ .../View/Container/PlayerContainerView.swift | 1 + .../Content/ContentWarningOverlayView.swift | 14 +++++++++++++- .../TableviewCell/StatusTableViewCell.swift | 4 ++++ 8 files changed, 38 insertions(+), 15 deletions(-) diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index cb01ccdcf..e169be66f 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -39,7 +39,7 @@ extension Item { var isSeparatorLineHidden: Bool let isImageLoaded = CurrentValueSubject(false) - let isMediaRevealing = CurrentValueSubject(false) + let isRevealing = CurrentValueSubject(false) init(isSeparatorLineHidden: Bool = false) { self.isSeparatorLineHidden = isSeparatorLineHidden diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 816f852ea..a432bf067 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -235,7 +235,7 @@ extension StatusSection { } Publishers.CombineLatest( statusItemAttribute.isImageLoaded, - statusItemAttribute.isMediaRevealing + statusItemAttribute.isRevealing ) .receive(on: DispatchQueue.main) .sink { isImageLoaded, isMediaRevealing in @@ -430,15 +430,16 @@ extension StatusSection { statusView.revealContentWarningButton.isHidden = false statusView.contentWarningOverlayView.isHidden = false statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true + statusView.playerContainerView.contentWarningOverlayView.isHidden = true if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp { statusView.updateRevealContentWarningButton(isRevealing: true) statusView.updateContentWarningDisplay(isHidden: true, animated: animated) - attribute.isMediaRevealing.value = true + attribute.isRevealing.value = true } else { statusView.updateRevealContentWarningButton(isRevealing: false) statusView.updateContentWarningDisplay(isHidden: false, animated: animated) - attribute.isMediaRevealing.value = false + attribute.isRevealing.value = false } case .media(let isSensitive): if !isSensitive, documentStore.defaultRevealStatusDict[status.id] == nil { @@ -460,17 +461,15 @@ extension StatusSection { return false }() - attribute.isMediaRevealing.value = needsReveal + attribute.isRevealing.value = needsReveal if needsReveal { statusView.updateRevealContentWarningButton(isRevealing: true) - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = nil - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = 0.0 - statusView.statusMosaicImageViewContainer.isUserInteractionEnabled = false + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: true, style: .visualEffectView) + statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: true, style: .visualEffectView) } else { statusView.updateRevealContentWarningButton(isRevealing: false) - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = ContentWarningOverlayView.blurVisualEffect - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0 - statusView.statusMosaicImageViewContainer.isUserInteractionEnabled = true + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: false, style: .visualEffectView) + statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: false, style: .visualEffectView) } } if animated { diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 8d9687777..198f0a4a3 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -63,12 +63,11 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - statusTableViewCell(cell, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) } func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - contentWarningOverlayView.isUserInteractionEnabled = false - statusTableViewCell(cell, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) } func statusTableViewCell(_ cell: StatusTableViewCell, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 17d2fbe4e..75efcd36e 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -449,6 +449,12 @@ extension StatusProviderFacade { provider.context.documentStore.defaultRevealStatusDict[status.id] = false status.update(isReveal: !isRevealing) status.reblog?.update(isReveal: !isRevealing) + + // pause video playback if isRevealing before toggle + if isRevealing, let attachment = (status.reblog ?? status).mediaAttachments?.first, + let playerViewModel = provider.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: attachment), playerViewModel.videoKind == .video { + playerViewModel.pause() + } } .map { result in return status diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index 641050bc2..54e25ed87 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -150,6 +150,7 @@ extension MosaicImageViewContainer { blurhashOverlayImageView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), ]) + contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false addSubview(contentWarningOverlayView) NSLayoutConstraint.activate([ contentWarningOverlayView.topAnchor.constraint(equalTo: imageView.topAnchor), @@ -281,6 +282,7 @@ extension MosaicImageViewContainer { ]) } + contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false addSubview(contentWarningOverlayView) NSLayoutConstraint.activate([ contentWarningOverlayView.topAnchor.constraint(equalTo: container.topAnchor), diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index 9c59a7ebd..a6fd4406a 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -71,6 +71,7 @@ extension PlayerContainerView { mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.required - 1), ]) + contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false addSubview(contentWarningOverlayView) NSLayoutConstraint.activate([ contentWarningOverlayView.topAnchor.constraint(equalTo: topAnchor), diff --git a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift index 37c3c7747..a695e1c19 100644 --- a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift +++ b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift @@ -70,7 +70,7 @@ class ContentWarningOverlayView: UIView { extension ContentWarningOverlayView { private func _init() { backgroundColor = .clear - translatesAutoresizingMaskIntoConstraints = false + isUserInteractionEnabled = true // visual effect style // add blur visual effect view in the setup method @@ -169,6 +169,18 @@ extension ContentWarningOverlayView { } } + func update(isRevealing: Bool, style: Style) { + switch style { + case .visualEffectView: + blurVisualEffectView.effect = isRevealing ? nil : ContentWarningOverlayView.blurVisualEffect + vibrancyVisualEffectView.alpha = isRevealing ? 0 : 1 + isUserInteractionEnabled = !isRevealing + case .blurContentImageView: + assertionFailure("not handle here") + break + } + } + } extension ContentWarningOverlayView { diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index d219daddd..7184b7670 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -73,8 +73,10 @@ final class StatusTableViewCell: UITableViewCell { super.prepareForReuse() selectionStyle = .default statusView.updateContentWarningDisplay(isHidden: true, animated: false) + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true statusView.pollTableView.dataSource = nil statusView.playerContainerView.reset() + statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = true statusView.playerContainerView.isHidden = true threadMetaView.isHidden = true disposeBag.removeAll() @@ -94,6 +96,8 @@ final class StatusTableViewCell: UITableViewCell { override func layoutSubviews() { super.layoutSubviews() + // precondition: app is active + guard UIApplication.shared.applicationState == .active else { return } DispatchQueue.main.async { self.statusView.drawContentWarningImageView() } From 4041929b3e9a55c53f1c366f71cea65afe16faec Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 19 Apr 2021 19:13:20 +0800 Subject: [PATCH 05/10] feat: fulfill the content warning when compose reply post --- Mastodon/Diffiable/Section/ComposeStatusSection.swift | 1 + Mastodon/Scene/Compose/ComposeViewModel.swift | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index d42caa404..91363ef09 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -77,6 +77,7 @@ extension ComposeStatusSection { return cell case .input(let replyToStatusObjectID, let attribute): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell + cell.statusContentWarningEditorView.textView.text = attribute.contentWarningContent.value cell.textEditorView.text = attribute.composeContent.value ?? "" managedObjectContext.perform { guard let replyToStatusObjectID = replyToStatusObjectID, diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index ef744d0b3..587b56f23 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -112,6 +112,10 @@ final class ComposeViewModel { for acct in mentionAccts { UITextChecker.learnWord(acct) } + if let spoilerText = status.spoilerText, !spoilerText.isEmpty { + self.isContentWarningComposing.value = true + self.composeStatusAttribute.contentWarningContent.value = spoilerText + } let initialComposeContent = mentionAccts.joined(separator: " ") let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " " From b7258dc7f2b1d6c2f312af0c16a5fa8ed55251f5 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 19 Apr 2021 19:15:49 +0800 Subject: [PATCH 06/10] fix: set reveal button hidden in the compose scene --- .../ComposeRepliedToStatusContentCollectionViewCell.swift | 1 + .../ComposeStatusContentCollectionViewCell.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift index 275f545df..8da4c0729 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift @@ -46,6 +46,7 @@ extension ComposeRepliedToStatusContentCollectionViewCell { backgroundColor = .clear statusView.actionToolbarContainer.isHidden = true + statusView.revealContentWarningButton.isHidden = true statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift index 2b71e55f3..5ec2a9eeb 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift @@ -90,6 +90,7 @@ extension ComposeStatusContentCollectionViewCell { textEditorView.changeObserver = self statusContentWarningEditorView.containerView.isHidden = true + statusView.revealContentWarningButton.isHidden = true } } From 04d427ea93cec4a22843950ceacd9278cb3dea4e Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 20 Apr 2021 13:18:27 +0800 Subject: [PATCH 07/10] feat: make content warning works in the notification scene --- .../xcschemes/xcschememanagement.plist | 2 +- .../Diffiable/Item/NotificationItem.swift | 6 +- .../Section/NotificationSection.swift | 23 ++++--- .../NotificationViewController.swift | 23 ++++++- .../NotificationViewModel+diffable.swift | 31 +++++++-- .../NotificationStatusTableViewCell.swift | 63 +++++++++++++++++-- .../NotificationTableViewCell.swift | 5 ++ 7 files changed, 125 insertions(+), 28 deletions(-) diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 6ec23cf5d..18c8840d8 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 10 + 13 Mastodon - RTL.xcscheme_^#shared#^_ diff --git a/Mastodon/Diffiable/Item/NotificationItem.swift b/Mastodon/Diffiable/Item/NotificationItem.swift index ba0d0c140..f26d2e43d 100644 --- a/Mastodon/Diffiable/Item/NotificationItem.swift +++ b/Mastodon/Diffiable/Item/NotificationItem.swift @@ -9,7 +9,7 @@ import CoreData import Foundation enum NotificationItem { - case notification(objectID: NSManagedObjectID) + case notification(objectID: NSManagedObjectID, attribute: Item.StatusAttribute) case bottomLoader } @@ -17,7 +17,7 @@ enum NotificationItem { extension NotificationItem: Equatable { static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool { switch (lhs, rhs) { - case (.notification(let idLeft), .notification(let idRight)): + case (.notification(let idLeft, _), .notification(let idRight, _)): return idLeft == idRight case (.bottomLoader, .bottomLoader): return true @@ -30,7 +30,7 @@ extension NotificationItem: Equatable { extension NotificationItem: Hashable { func hash(into hasher: inout Hasher) { switch self { - case .notification(let id): + case .notification(let id, _): hasher.combine(id) case .bottomLoader: hasher.combine(String(describing: NotificationItem.bottomLoader.self)) diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 5ccab431c..9c59350b4 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -22,15 +22,14 @@ extension NotificationSection { timestampUpdatePublisher: AnyPublisher, managedObjectContext: NSManagedObjectContext, delegate: NotificationTableViewCellDelegate, - dependency: NeedsDependency, - requestUserID: String + dependency: NeedsDependency ) -> UITableViewDiffableDataSource { UITableViewDiffableDataSource(tableView: tableView) { [weak delegate, weak dependency] (tableView, indexPath, notificationItem) -> UITableViewCell? in guard let dependency = dependency else { return nil } switch notificationItem { - case .notification(let objectID): + case .notification(let objectID, let attribute): let notification = managedObjectContext.object(with: objectID) as! MastodonNotification guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.typeRaw) else { @@ -46,14 +45,18 @@ extension NotificationSection { if let status = notification.status { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell cell.delegate = delegate + let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value + let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" let frame = CGRect(x: 0, y: 0, width: tableView.readableContentGuide.layoutFrame.width - NotificationStatusTableViewCell.statusPadding.left - NotificationStatusTableViewCell.statusPadding.right, height: tableView.readableContentGuide.layoutFrame.height) - StatusSection.configure(cell: cell, - dependency: dependency, - readableLayoutFrame: frame, - timestampUpdatePublisher: timestampUpdatePublisher, - status: status, - requestUserID: requestUserID, - statusItemAttribute: Item.StatusAttribute(isStatusTextSensitive: false, isStatusSensitive: false)) + StatusSection.configure( + cell: cell, + dependency: dependency, + readableLayoutFrame: frame, + timestampUpdatePublisher: timestampUpdatePublisher, + status: status, + requestUserID: requestUserID, + statusItemAttribute: attribute + ) timestampUpdatePublisher .sink { _ in let timeText = notification.createAt.shortTimeAgoSinceNow diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index ad9a7472e..57b5dc639 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -36,6 +36,7 @@ final class NotificationViewController: UIViewController, NeedsDependency { tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) tableView.tableFooterView = UIView() tableView.estimatedRowHeight = UITableView.automaticDimension + tableView.backgroundColor = .clear return tableView }() @@ -45,13 +46,14 @@ final class NotificationViewController: UIViewController, NeedsDependency { extension NotificationViewController { override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color navigationItem.titleView = segmentControl segmentControl.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .valueChanged) tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.topAnchor.constraint(equalTo: view.topAnchor), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), @@ -65,6 +67,7 @@ extension NotificationViewController { viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self viewModel.setupDiffableDataSource(for: tableView, delegate: self, dependency: self) viewModel.viewDidLoad.send() + // bind refresh control viewModel.isFetchingLatestNotification .receive(on: DispatchQueue.main) @@ -83,6 +86,8 @@ extension NotificationViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + tableView.deselectRow(with: transitionCoordinator, animated: animated) + // needs trigger manually after onboarding dismiss setNeedsStatusBarAppearanceUpdate() } @@ -159,11 +164,10 @@ extension NotificationViewController { extension NotificationViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } switch item { - case .notification(let objectID): + case .notification(let objectID, _): let notification = context.managedObjectContext.object(with: objectID) as! MastodonNotification if let status = notification.status { let viewModel = ThreadViewModel(context: context, optionalStatus: status) @@ -199,6 +203,7 @@ extension NotificationViewController: ContentOffsetAdjustableTimelineViewControl } } +// MARK: - NotificationTableViewCellDelegate extension NotificationViewController: NotificationTableViewCellDelegate { func userAvatarDidPressed(notification: MastodonNotification) { let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account) @@ -210,6 +215,18 @@ extension NotificationViewController: NotificationTableViewCellDelegate { func parent() -> UIViewController { self } + + func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: self, cell: cell) + } + + func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: self, cell: cell) + } + + func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: self, cell: cell) + } } // MARK: - UIScrollViewDelegate diff --git a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift index 5bd2d92dd..cd28c5f5a 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift @@ -20,16 +20,13 @@ extension NotificationViewModel { .autoconnect() .share() .eraseToAnyPublisher() - guard let userid = activeMastodonAuthenticationBox.value?.userID else { - return - } + diffableDataSource = NotificationSection.tableViewDiffableDataSource( for: tableView, timestampUpdatePublisher: timestampUpdatePublisher, managedObjectContext: context.managedObjectContext, delegate: delegate, - dependency: dependency, - requestUserID: userid + dependency: dependency ) } } @@ -67,9 +64,31 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate { 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]) - newSnapshot.appendItems(notifications.map { NotificationItem.notification(objectID: $0.objectID) }, toSection: .main) + let items: [NotificationItem] = notifications.map { notification in + let attribute: Item.StatusAttribute = oldSnapshotAttributeDict[notification.objectID] ?? Item.StatusAttribute() + +// let attribute: Item.StatusAttribute = { +// if let attribute = oldSnapshotAttributeDict[notification.objectID] { +// return attribute +// } else if let status = notification.status { +// let attribute = Item.StatusAttribute() +// let isSensitive = status.sensitive || !(status.spoilerText ?? "").isEmpty +// attribute.isRevealing.value = !isSensitive +// return attribute +// } else { +// return Item.StatusAttribute() +// } +// }() + return NotificationItem.notification(objectID: notification.objectID, attribute: attribute) + } + newSnapshot.appendItems(items, toSection: .main) if !notifications.isEmpty, self.noMoreNotification.value == false { newSnapshot.appendItems([.bottomLoader], toSection: .main) } diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index 871adcaeb..7b76dd2f0 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -8,6 +8,7 @@ import Combine import Foundation import UIKit +import ActiveLabel final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { static let actionImageBorderWidth: CGFloat = 2 @@ -78,8 +79,7 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { override func prepareForReuse() { super.prepareForReuse() avatatImageView.af.cancelImageRequest() - statusView.isStatusTextSensitive = false - statusView.cleanUpContentWarning() + statusView.updateContentWarningDisplay(isHidden: true, animated: false) statusView.pollTableView.dataSource = nil statusView.playerContainerView.reset() statusView.playerContainerView.isHidden = true @@ -99,6 +99,9 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { override func layoutSubviews() { super.layoutSubviews() + + // precondition: app is active + guard UIApplication.shared.applicationState == .active else { return } DispatchQueue.main.async { self.statusView.drawContentWarningImageView() } @@ -107,6 +110,8 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { extension NotificationStatusTableViewCell { func configure() { + backgroundColor = Asset.Colors.Background.systemBackground.color + let containerStackView = UIStackView() containerStackView.axis = .horizontal containerStackView.alignment = .top @@ -154,7 +159,6 @@ extension NotificationStatusTableViewCell { actionImageView.centerYAnchor.constraint(equalTo: actionImageBackground.centerYAnchor) ]) - let actionStackView = UIStackView() actionStackView.axis = .horizontal actionStackView.distribution = .fill @@ -187,13 +191,12 @@ extension NotificationStatusTableViewCell { statusBorder.trailingAnchor.constraint(equalTo: statusView.trailingAnchor, constant: 12), ]) + statusView.delegate = self statusStackView.addArrangedSubview(statusBorder) containerStackView.addArrangedSubview(statusStackView) - statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color - statusView.isUserInteractionEnabled = false // remove item don't display statusView.actionToolbarContainer.removeFromStackView() // it affect stackView's height,need remove @@ -206,4 +209,54 @@ extension NotificationStatusTableViewCell { statusBorder.layer.borderColor = Asset.Colors.Border.notification.color.cgColor actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + resetContentOverlayBlurImageBackgroundColor(selected: highlighted) + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + resetContentOverlayBlurImageBackgroundColor(selected: selected) + } + + private func resetContentOverlayBlurImageBackgroundColor(selected: Bool) { + let imageViewBackgroundColor: UIColor? = selected ? selectedBackgroundView?.backgroundColor : backgroundColor + statusView.contentWarningOverlayView.blurContentImageView.backgroundColor = imageViewBackgroundColor + } +} + +// MARK: - StatusViewDelegate +extension NotificationStatusTableViewCell: StatusViewDelegate { + func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) { + // do nothing + } + + func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) { + // do nothing + } + + func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { + delegate?.notificationStatusTableViewCell(self, statusView: statusView, revealContentWarningButtonDidPressed: button) + } + + func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + delegate?.notificationStatusTableViewCell(self, statusView: statusView, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + } + + func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + delegate?.notificationStatusTableViewCell(self, statusView: statusView, playerContainerView: playerContainerView, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + } + + func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) { + // do nothing + } + + func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + // do nothing + } + + } diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index 60b43ac35..619bffa17 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -16,6 +16,11 @@ protocol NotificationTableViewCellDelegate: AnyObject { func parent() -> UIViewController func userAvatarDidPressed(notification: MastodonNotification) + + func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) + func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) + func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) + } final class NotificationTableViewCell: UITableViewCell { From f6e785a8943c4a93a76ac6c718420d303f2d3223 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 20 Apr 2021 13:40:14 +0800 Subject: [PATCH 08/10] feat: set GIF pause and auto resume when toggle content warning overlay --- .../Diffiable/Section/StatusSection.swift | 1 + .../StatusProvider/StatusProviderFacade.swift | 55 +++++++++++++++---- .../View/Container/PlayerContainerView.swift | 1 + .../Content/ContentWarningOverlayView.swift | 4 ++ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 1dd155d5b..4f09142a7 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -469,6 +469,7 @@ extension StatusSection { statusView.revealContentWarningButton.isHidden = false statusView.contentWarningOverlayView.isHidden = true statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = false + statusView.playerContainerView.contentWarningOverlayView.isHidden = false statusView.updateContentWarningDisplay(isHidden: true, animated: false) func updateContentOverlay() { diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 75efcd36e..2e6102227 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -417,26 +417,52 @@ extension StatusProviderFacade { extension StatusProviderFacade { + static func responseToStatusContentWarningRevealAction(dependency: NotificationViewController, cell: UITableViewCell) { + let status = Future { promise in + guard let diffableDataSource = dependency.viewModel.diffableDataSource, + let indexPath = dependency.tableView.indexPath(for: cell), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + + switch item { + case .notification(let objectID, _): + dependency.viewModel.fetchedResultsController.managedObjectContext.perform { + let notification = dependency.viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! MastodonNotification + promise(.success(notification.status)) + } + default: + promise(.success(nil)) + } + } + + _responseToStatusContentWarningRevealAction( + dependency: dependency, + status: status + ) + } + static func responseToStatusContentWarningRevealAction(provider: StatusProvider, cell: UITableViewCell) { _responseToStatusContentWarningRevealAction( - provider: provider, + dependency: provider, status: provider.status(for: cell, indexPath: nil) ) } - private static func _responseToStatusContentWarningRevealAction(provider: StatusProvider, status: Future) { + private static func _responseToStatusContentWarningRevealAction(dependency: NeedsDependency, status: Future) { status - .compactMap { [weak provider] status -> AnyPublisher? in - guard let provider = provider else { return nil } + .compactMap { [weak dependency] status -> AnyPublisher? in + guard let dependency = dependency else { return nil } guard let _status = status else { return nil } - return provider.context.managedObjectContext.performChanges { - guard let status = provider.context.managedObjectContext.object(with: _status.objectID) as? Status else { return } - let appStartUpTimestamp = provider.context.documentStore.appStartUpTimestamp + return dependency.context.managedObjectContext.performChanges { + guard let status = dependency.context.managedObjectContext.object(with: _status.objectID) as? Status else { return } + let appStartUpTimestamp = dependency.context.documentStore.appStartUpTimestamp let isRevealing: Bool = { - if provider.context.documentStore.defaultRevealStatusDict[status.id] == true { + if dependency.context.documentStore.defaultRevealStatusDict[status.id] == true { return true } - if status.reblog.flatMap({ provider.context.documentStore.defaultRevealStatusDict[$0.id] }) == true { + if status.reblog.flatMap({ dependency.context.documentStore.defaultRevealStatusDict[$0.id] }) == true { return true } if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp { @@ -446,15 +472,20 @@ extension StatusProviderFacade { return false }() // toggle reveal - provider.context.documentStore.defaultRevealStatusDict[status.id] = false + dependency.context.documentStore.defaultRevealStatusDict[status.id] = false status.update(isReveal: !isRevealing) status.reblog?.update(isReveal: !isRevealing) // pause video playback if isRevealing before toggle if isRevealing, let attachment = (status.reblog ?? status).mediaAttachments?.first, - let playerViewModel = provider.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: attachment), playerViewModel.videoKind == .video { + let playerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: attachment) { playerViewModel.pause() } + // resume GIF playback if NOT isRevealing before toggle + if !isRevealing, let attachment = (status.reblog ?? status).mediaAttachments?.first, + let playerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: attachment), playerViewModel.videoKind == .gif { + playerViewModel.play() + } } .map { result in return status @@ -464,7 +495,7 @@ extension StatusProviderFacade { .sink { _ in // do nothing } - .store(in: &provider.context.disposeBag) + .store(in: &dependency.context.disposeBag) } } diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index a6fd4406a..f7a8a1546 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -22,6 +22,7 @@ final class PlayerContainerView: UIView { let contentWarningOverlayView: ContentWarningOverlayView = { let contentWarningOverlayView = ContentWarningOverlayView() + contentWarningOverlayView.update(cornerRadius: PlayerContainerView.cornerRadius) return contentWarningOverlayView }() diff --git a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift index a695e1c19..f04a56e9e 100644 --- a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift +++ b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift @@ -181,6 +181,10 @@ extension ContentWarningOverlayView { } } + func update(cornerRadius: CGFloat) { + blurVisualEffectView.layer.cornerRadius = cornerRadius + } + } extension ContentWarningOverlayView { From a1b19e44f7d5ca373b06e2c2f86f8df49898bf2f Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 21 Apr 2021 23:58:36 +0800 Subject: [PATCH 09/10] chore: add GItHub CI Action for project --- .github/scripts/build.sh | 13 +++++++++++++ .github/scripts/setup.sh | 4 ++++ .github/workflows/main.yml | 27 +++++++++++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100755 .github/scripts/build.sh create mode 100755 .github/scripts/setup.sh create mode 100644 .github/workflows/main.yml diff --git a/.github/scripts/build.sh b/.github/scripts/build.sh new file mode 100755 index 000000000..76e65f49f --- /dev/null +++ b/.github/scripts/build.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -eo pipefail + +# build with SwiftPM: +# https://developer.apple.com/documentation/swift_packages/building_swift_packages_or_apps_that_use_them_in_continuous_integration_workflows + +xcodebuild -workspace Mastodon.xcworkspace \ + -scheme Mastodon \ + -disableAutomaticPackageResolution \ + -destination "platform=iOS Simulator,name=iPhone SE (2nd generation)" \ + clean \ + build | xcpretty \ No newline at end of file diff --git a/.github/scripts/setup.sh b/.github/scripts/setup.sh new file mode 100755 index 000000000..e1411fb50 --- /dev/null +++ b/.github/scripts/setup.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +sudo gem install cocoapods-keys +pod install \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..67670b46c --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: + - master + - develop + - feature/* + pull_request: + branches: + - develop + +# macOS environments: https://github.com/actions/virtual-environments/tree/main/images/macos + +jobs: + build: + name: CI build + runs-on: macos-11.0 + steps: + - name: checkout + uses: actions/checkout@v2 + - name: force Xcode 12.2 + run: sudo xcode-select -switch /Applications/Xcode_12.2.app + - name: setup + run: exec ./.github/scripts/setup.sh + - name: build + run: exec ./.github/scripts/build.sh From b9537e20c46f3c35eb964a2a3a0d5a30071af79c Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 22 Apr 2021 00:03:01 +0800 Subject: [PATCH 10/10] chore: rollback macOS version to 10.15 due to 11.0 marked private preview ref: https://github.com/actions/virtual-environments/issues/2486 --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 67670b46c..e1bc703a7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,7 +15,7 @@ on: jobs: build: name: CI build - runs-on: macos-11.0 + runs-on: macos-10.15 steps: - name: checkout uses: actions/checkout@v2