diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index eb095669..3b558f9f 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 a902f5ce..10b00aaa 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 f40f7863..1bb71a1d 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 5d8ad264..e3ae30e9 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 f6bf54a2..ff243e8b 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 fd1ce69a..c25eac1f 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 3bd82fce..74194737 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 da345520..cb01ccdc 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 36d4853a..f2b3059c 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 cf4f8a1b..880be6fa 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 6eed41a2..5d486b5f 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 25322e21..8d968777 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 0e26614c..17d2fbe4 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 c35d6e63..46275884 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 95e9b4f1..506d6139 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 99288a5e..2940217e 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 3ca407ca..27336dc5 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 b3c03a46..641050bc 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 afa044b6..d219dadd 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 ce92ccb7..26e426ad 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 b39a2924..8b3f88eb 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 00000000..7fe3b398 --- /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 } +}