feat: add blurhash image and update content warning

This commit is contained in:
CMK 2021-04-16 20:06:36 +08:00
parent d5366c2d07
commit 680cf9a827
25 changed files with 1014 additions and 325 deletions

View File

@ -168,6 +168,7 @@
<attribute name="language" optional="YES" attributeType="String"/>
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="repliesCount" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
<attribute name="revealedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="spoilerText" optional="YES" attributeType="String"/>
<attribute name="text" optional="YES" attributeType="String"/>
@ -215,7 +216,7 @@
<element name="PollOption" positionX="0" positionY="0" width="128" height="134"/>
<element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/>
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="104"/>
<element name="Status" positionX="0" positionY="0" width="128" height="569"/>
<element name="Status" positionX="0" positionY="0" width="128" height="584"/>
<element name="Tag" positionX="0" positionY="0" width="128" height="134"/>
</elements>
</model>

View File

@ -52,7 +52,7 @@ extension HomeTimelineIndex {
}
}
// internal method for Toot call
// internal method for status call
func softDelete() {
deletedAt = Date()
}

View File

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

View File

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

View File

@ -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 = "<group>"; };
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = "<group>"; };
DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = "<group>"; };
DB51D170262832380062B7A1 /* BlurHashDecode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
DB51D171262832380062B7A1 /* BlurHashEncode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = "<group>"; };
DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TwitterTextEditor+String.swift"; sourceTree = "<group>"; };
DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = "<group>"; };
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = "<group>"; };
@ -977,6 +981,8 @@
DB2B3AE825E38850007045F9 /* UIViewPreview.swift */,
DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */,
DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */,
DB51D170262832380062B7A1 /* BlurHashDecode.swift */,
DB51D171262832380062B7A1 /* BlurHashEncode.swift */,
);
path = Vender;
sourceTree = "<group>";
@ -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 */,

View File

@ -7,7 +7,7 @@
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>20</integer>
<integer>11</integer>
</dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>

View File

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

View File

@ -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<Bool, Never>(false)
let isMediaRevealing = CurrentValueSubject<Bool, Never>(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

View File

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

View File

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

View File

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

View File

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

View File

@ -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?, Never>) {
status
.compactMap { [weak provider] status -> AnyPublisher<Status?, Never>? 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

View File

@ -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 cant view Artbots profile

View File

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

View File

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

View File

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

View File

@ -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..<count {
imageViews.append(UIImageView())
blurhashOverlayImageViews.append(UIImageView())
}
self.imageViews.append(contentsOf: imageViews)
self.blurhashOverlayImageViews.append(contentsOf: blurhashOverlayImageViews)
imageViews.forEach { imageView in
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
imageView.layer.cornerCurve = .continuous
imageView.contentMode = .scaleAspectFill
}
blurhashOverlayImageViews.forEach { imageView in
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
imageView.layer.cornerCurve = .continuous
imageView.contentMode = .scaleAspectFill
}
if count == 2 {
contentLeftStackView.addArrangedSubview(imageViews[0])
contentRightStackView.addArrangedSubview(imageViews[1])
@ -178,9 +207,16 @@ extension MosaicImageViewContainer {
case .rightToLeft:
imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
imageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
default:
imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
}
} else if count == 3 {
@ -192,10 +228,18 @@ extension MosaicImageViewContainer {
imageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
imageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
default:
imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
imageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
}
} else if count == 4 {
contentLeftStackView.addArrangedSubview(imageViews[0])
@ -208,14 +252,35 @@ extension MosaicImageViewContainer {
imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
imageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
imageViews[3].layer.maskedCorners = [.layerMinXMaxYCorner]
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner]
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
blurhashOverlayImageViews[3].layer.maskedCorners = [.layerMinXMaxYCorner]
default:
imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner]
imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
imageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
imageViews[3].layer.maskedCorners = [.layerMaxXMaxYCorner]
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMinXMinYCorner]
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
blurhashOverlayImageViews[3].layer.maskedCorners = [.layerMaxXMaxYCorner]
}
}
for (imageView, blurhashOverlayImageView) in zip(imageViews, blurhashOverlayImageViews) {
blurhashOverlayImageView.translatesAutoresizingMaskIntoConstraints = false
container.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: container.topAnchor),
@ -224,7 +289,7 @@ extension MosaicImageViewContainer {
contentWarningOverlayView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
return imageViews
return zip(imageViews, blurhashOverlayImageViews).map { ($0, $1) }
}
}
@ -260,7 +325,7 @@ struct MosaicImageView_Previews: PreviewProvider {
UIViewPreview(width: 375) {
let view = MosaicImageViewContainer()
let image = images[3]
let imageView = view.setupImageView(
let (imageView, _) = view.setupImageView(
aspectRatio: image.size,
maxSize: CGSize(width: 375, height: 400)
)
@ -272,7 +337,7 @@ struct MosaicImageView_Previews: PreviewProvider {
UIViewPreview(width: 375) {
let view = MosaicImageViewContainer()
let image = images[1]
let imageView = view.setupImageView(
let (imageView, _) = view.setupImageView(
aspectRatio: image.size,
maxSize: CGSize(width: 375, height: 400)
)
@ -287,8 +352,9 @@ struct MosaicImageView_Previews: PreviewProvider {
UIViewPreview(width: 375) {
let view = MosaicImageViewContainer()
let images = self.images.prefix(2)
let imageViews = view.setupImageViews(count: images.count, maxHeight: 162)
for (i, imageView) in imageViews.enumerated() {
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
for (i, mosiac) in mosaics.enumerated() {
let (imageView, blurhashOverlayImageView) = mosiac
imageView.image = images[i]
}
return view
@ -298,8 +364,9 @@ struct MosaicImageView_Previews: PreviewProvider {
UIViewPreview(width: 375) {
let view = MosaicImageViewContainer()
let images = self.images.prefix(3)
let imageViews = view.setupImageViews(count: images.count, maxHeight: 162)
for (i, imageView) in imageViews.enumerated() {
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
for (i, mosiac) in mosaics.enumerated() {
let (imageView, blurhashOverlayImageView) = mosiac
imageView.image = images[i]
}
return view
@ -309,8 +376,9 @@ struct MosaicImageView_Previews: PreviewProvider {
UIViewPreview(width: 375) {
let view = MosaicImageViewContainer()
let images = self.images.prefix(4)
let imageViews = view.setupImageViews(count: images.count, maxHeight: 162)
for (i, imageView) in imageViews.enumerated() {
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
for (i, mosiac) in mosaics.enumerated() {
let (imageView, blurhashOverlayImageView) = mosiac
imageView.image = images[i]
}
return view

View File

@ -17,14 +17,38 @@ class ContentWarningOverlayView: UIView {
static let cornerRadius: CGFloat = 4
static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial)
let blurVisualEffectView = UIVisualEffectView(effect: ContentWarningOverlayView.blurVisualEffect)
let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: ContentWarningOverlayView.blurVisualEffect))
let contentWarningLabel: UILabel = {
let vibrancyContentWarningLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15))
label.text = L10n.Common.Controls.Status.mediaContentWarning
label.textAlignment = .center
label.numberOfLines = 0
return label
}()
let blurContentImageView: UIImageView = {
let imageView = UIImageView()
imageView.layer.masksToBounds = false
return imageView
}()
let blurContentWarningTitleLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17))
label.text = L10n.Common.Controls.Status.mediaContentWarning
label.textColor = Asset.Colors.Label.primary.color
label.textAlignment = .center
return label
}()
let blurContentWarningLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15))
label.text = L10n.Common.Controls.Status.mediaContentWarning
label.textColor = Asset.Colors.Label.secondary.color
label.textAlignment = .center
label.layer.setupShadow()
return label
}()
@ -47,7 +71,8 @@ extension ContentWarningOverlayView {
private func _init() {
backgroundColor = .clear
translatesAutoresizingMaskIntoConstraints = false
// visual effect style
// add blur visual effect view in the setup method
blurVisualEffectView.layer.masksToBounds = true
blurVisualEffectView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
@ -62,12 +87,12 @@ extension ContentWarningOverlayView {
vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.bottomAnchor),
])
contentWarningLabel.translatesAutoresizingMaskIntoConstraints = false
vibrancyVisualEffectView.contentView.addSubview(contentWarningLabel)
vibrancyContentWarningLabel.translatesAutoresizingMaskIntoConstraints = false
vibrancyVisualEffectView.contentView.addSubview(vibrancyContentWarningLabel)
NSLayoutConstraint.activate([
contentWarningLabel.leadingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.leadingAnchor),
contentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor),
contentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor),
vibrancyContentWarningLabel.leadingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.leadingAnchor),
vibrancyContentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor),
vibrancyContentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor),
])
blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
@ -78,12 +103,74 @@ extension ContentWarningOverlayView {
blurVisualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor),
blurVisualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
// blur image style
blurContentImageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(blurContentImageView)
NSLayoutConstraint.activate([
blurContentImageView.topAnchor.constraint(equalTo: topAnchor),
blurContentImageView.leadingAnchor.constraint(equalTo: leadingAnchor),
blurContentImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
blurContentImageView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
let blurContentWarningLabelContainer = UIStackView()
blurContentWarningLabelContainer.axis = .vertical
blurContentWarningLabelContainer.spacing = 4
blurContentWarningLabelContainer.alignment = .center
blurContentWarningLabelContainer.translatesAutoresizingMaskIntoConstraints = false
blurContentImageView.addSubview(blurContentWarningLabelContainer)
NSLayoutConstraint.activate([
blurContentWarningLabelContainer.topAnchor.constraint(equalTo: topAnchor),
blurContentWarningLabelContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
blurContentWarningLabelContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
blurContentWarningLabelContainer.bottomAnchor.constraint(equalTo: bottomAnchor),
])
let topPaddingView = UIView()
let bottomPaddingView = UIView()
topPaddingView.translatesAutoresizingMaskIntoConstraints = false
blurContentWarningLabelContainer.addArrangedSubview(topPaddingView)
blurContentWarningLabelContainer.addArrangedSubview(blurContentWarningTitleLabel)
blurContentWarningLabelContainer.addArrangedSubview(blurContentWarningLabel)
bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
blurContentWarningLabelContainer.addArrangedSubview(bottomPaddingView)
NSLayoutConstraint.activate([
topPaddingView.heightAnchor.constraint(equalTo: bottomPaddingView.heightAnchor, multiplier: 1.0).priority(.defaultHigh),
])
blurContentWarningTitleLabel.setContentHuggingPriority(.defaultHigh + 2, for: .vertical)
blurContentWarningLabel.setContentHuggingPriority(.defaultHigh + 1, for: .vertical)
tapGestureRecognizer.addTarget(self, action: #selector(ContentWarningOverlayView.tapGestureRecognizerHandler(_:)))
addGestureRecognizer(tapGestureRecognizer)
configure(style: .visualEffectView)
}
}
extension ContentWarningOverlayView {
enum Style {
case visualEffectView
case blurContentImageView
}
func configure(style: Style) {
switch style {
case .visualEffectView:
blurVisualEffectView.isHidden = false
vibrancyVisualEffectView.isHidden = false
blurContentImageView.isHidden = true
case .blurContentImageView:
blurVisualEffectView.isHidden = true
vibrancyVisualEffectView.isHidden = true
blurContentImageView.isHidden = false
}
}
}
extension ContentWarningOverlayView {
@objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)

View File

@ -14,7 +14,8 @@ import AlamofireImage
protocol StatusViewDelegate: class {
func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel)
func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton)
func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton)
func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
@ -53,7 +54,7 @@ final class StatusView: UIView {
}
weak var delegate: StatusViewDelegate?
var isStatusTextSensitive = false
private var needsDrawContentOverlay = false
var pollTableViewDataSource: UITableViewDiffableDataSource<PollSection, PollItem>?
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

View File

@ -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<AnyCancellable>()
var pollCountdownSubscription: AnyCancellable?
var observations = Set<NSKeyValueObservation>()
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) {

View File

@ -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<UIImage?, Never> {
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()
}
}

View File

@ -7,5 +7,10 @@
import UIKit
import Combine
import MastodonSDK
class DocumentStore: ObservableObject { }
class DocumentStore: ObservableObject {
let blurhashImageCache = NSCache<NSString, NSData>()
let appStartUpTimestamp = Date()
var defaultRevealStatusDict: [Mastodon.Entity.Status.ID: Bool] = [:]
}

View File

@ -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<Type: BinaryInteger>(_ 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<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start...end]
}
subscript (bounds: CountableRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start..<end]
}
}

View File

@ -0,0 +1,145 @@
import UIKit
extension UIImage {
public func blurHash(numberOfComponents components: (Int, Int)) -> 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<UInt8>, 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<Type: BinaryInteger>(_ 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 }
}