diff --git a/.github/scripts/build.sh b/.github/scripts/build.sh
new file mode 100755
index 000000000..76e65f49f
--- /dev/null
+++ b/.github/scripts/build.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+set -eo pipefail
+
+# build with SwiftPM:
+# https://developer.apple.com/documentation/swift_packages/building_swift_packages_or_apps_that_use_them_in_continuous_integration_workflows
+
+xcodebuild -workspace Mastodon.xcworkspace \
+ -scheme Mastodon \
+ -disableAutomaticPackageResolution \
+ -destination "platform=iOS Simulator,name=iPhone SE (2nd generation)" \
+ clean \
+ build | xcpretty
\ No newline at end of file
diff --git a/.github/scripts/setup.sh b/.github/scripts/setup.sh
new file mode 100755
index 000000000..e1411fb50
--- /dev/null
+++ b/.github/scripts/setup.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+
+sudo gem install cocoapods-keys
+pod install
\ No newline at end of file
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 000000000..e1bc703a7
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,27 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - master
+ - develop
+ - feature/*
+ pull_request:
+ branches:
+ - develop
+
+# macOS environments: https://github.com/actions/virtual-environments/tree/main/images/macos
+
+jobs:
+ build:
+ name: CI build
+ runs-on: macos-10.15
+ steps:
+ - name: checkout
+ uses: actions/checkout@v2
+ - name: force Xcode 12.2
+ run: sudo xcode-select -switch /Applications/Xcode_12.2.app
+ - name: setup
+ run: exec ./.github/scripts/setup.sh
+ - name: build
+ run: exec ./.github/scripts/build.sh
diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
index ff26e9bab..0d0170282 100644
--- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
+++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
@@ -194,6 +194,7 @@
+
@@ -263,9 +264,9 @@
-
+
-
\ No newline at end of file
+
diff --git a/CoreDataStack/Entity/HomeTimelineIndex.swift b/CoreDataStack/Entity/HomeTimelineIndex.swift
index a902f5ce5..10b00aaa0 100644
--- a/CoreDataStack/Entity/HomeTimelineIndex.swift
+++ b/CoreDataStack/Entity/HomeTimelineIndex.swift
@@ -52,7 +52,7 @@ extension HomeTimelineIndex {
}
}
- // internal method for Toot call
+ // internal method for status call
func softDelete() {
deletedAt = Date()
}
diff --git a/CoreDataStack/Entity/Status.swift b/CoreDataStack/Entity/Status.swift
index 81ace7a5d..1bb71a1db 100644
--- a/CoreDataStack/Entity/Status.swift
+++ b/CoreDataStack/Entity/Status.swift
@@ -1,5 +1,5 @@
//
-// Toot.swift
+// Status.swift
// CoreDataStack
//
// Created by MainasuK Cirno on 2021/1/27.
@@ -62,11 +62,13 @@ public final class Status: NSManagedObject {
@NSManaged public private(set) var updatedAt: Date
@NSManaged public private(set) var deletedAt: Date?
+ @NSManaged public private(set) var revealedAt: Date?
}
-public extension Status {
+extension Status {
+
@discardableResult
- static func insert(
+ public static func insert(
into context: NSManagedObjectContext,
property: Property,
author: MastodonUser,
@@ -84,81 +86,81 @@ public extension Status {
bookmarkedBy: MastodonUser?,
pinnedBy: MastodonUser?
) -> Status {
- let toot: Status = context.insertObject()
+ let status: Status = context.insertObject()
- toot.identifier = property.identifier
- toot.domain = property.domain
+ status.identifier = property.identifier
+ status.domain = property.domain
- toot.id = property.id
- toot.uri = property.uri
- toot.createdAt = property.createdAt
- toot.content = property.content
+ status.id = property.id
+ status.uri = property.uri
+ status.createdAt = property.createdAt
+ status.content = property.content
- toot.visibility = property.visibility
- toot.sensitive = property.sensitive
- toot.spoilerText = property.spoilerText
- toot.application = application
+ status.visibility = property.visibility
+ status.sensitive = property.sensitive
+ status.spoilerText = property.spoilerText
+ status.application = application
- toot.reblogsCount = property.reblogsCount
- toot.favouritesCount = property.favouritesCount
- toot.repliesCount = property.repliesCount
+ status.reblogsCount = property.reblogsCount
+ status.favouritesCount = property.favouritesCount
+ status.repliesCount = property.repliesCount
- toot.url = property.url
- toot.inReplyToID = property.inReplyToID
- toot.inReplyToAccountID = property.inReplyToAccountID
+ status.url = property.url
+ status.inReplyToID = property.inReplyToID
+ status.inReplyToAccountID = property.inReplyToAccountID
- toot.language = property.language
- toot.text = property.text
+ status.language = property.language
+ status.text = property.text
- toot.author = author
- toot.reblog = reblog
+ status.author = author
+ status.reblog = reblog
- toot.pinnedBy = pinnedBy
- toot.poll = poll
+ status.pinnedBy = pinnedBy
+ status.poll = poll
if let mentions = mentions {
- toot.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions)
+ status.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions)
}
if let emojis = emojis {
- toot.mutableSetValue(forKey: #keyPath(Status.emojis)).addObjects(from: emojis)
+ status.mutableSetValue(forKey: #keyPath(Status.emojis)).addObjects(from: emojis)
}
if let tags = tags {
- toot.mutableSetValue(forKey: #keyPath(Status.tags)).addObjects(from: tags)
+ status.mutableSetValue(forKey: #keyPath(Status.tags)).addObjects(from: tags)
}
if let mediaAttachments = mediaAttachments {
- toot.mutableSetValue(forKey: #keyPath(Status.mediaAttachments)).addObjects(from: mediaAttachments)
+ status.mutableSetValue(forKey: #keyPath(Status.mediaAttachments)).addObjects(from: mediaAttachments)
}
if let favouritedBy = favouritedBy {
- toot.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(favouritedBy)
+ status.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(favouritedBy)
}
if let rebloggedBy = rebloggedBy {
- toot.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(rebloggedBy)
+ status.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(rebloggedBy)
}
if let mutedBy = mutedBy {
- toot.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mutedBy)
+ status.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mutedBy)
}
if let bookmarkedBy = bookmarkedBy {
- toot.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(bookmarkedBy)
+ status.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(bookmarkedBy)
}
- toot.updatedAt = property.networkDate
+ status.updatedAt = property.networkDate
- return toot
+ return status
}
- func update(reblogsCount: NSNumber) {
+ public func update(reblogsCount: NSNumber) {
if self.reblogsCount.intValue != reblogsCount.intValue {
self.reblogsCount = reblogsCount
}
}
- func update(favouritesCount: NSNumber) {
+ public func update(favouritesCount: NSNumber) {
if self.favouritesCount.intValue != favouritesCount.intValue {
self.favouritesCount = favouritesCount
}
}
- func update(repliesCount: NSNumber?) {
+ public func update(repliesCount: NSNumber?) {
guard let count = repliesCount else {
return
}
@@ -167,13 +169,13 @@ public extension Status {
}
}
- func update(replyTo: Status?) {
+ public func update(replyTo: Status?) {
if self.replyTo != replyTo {
self.replyTo = replyTo
}
}
- func update(liked: Bool, by mastodonUser: MastodonUser) {
+ public func update(liked: Bool, by mastodonUser: MastodonUser) {
if liked {
if !(self.favouritedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(mastodonUser)
@@ -185,7 +187,7 @@ public extension Status {
}
}
- func update(reblogged: Bool, by mastodonUser: MastodonUser) {
+ public func update(reblogged: Bool, by mastodonUser: MastodonUser) {
if reblogged {
if !(self.rebloggedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(mastodonUser)
@@ -197,7 +199,7 @@ public extension Status {
}
}
- func update(muted: Bool, by mastodonUser: MastodonUser) {
+ public func update(muted: Bool, by mastodonUser: MastodonUser) {
if muted {
if !(self.mutedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mastodonUser)
@@ -209,7 +211,7 @@ public extension Status {
}
}
- func update(bookmarked: Bool, by mastodonUser: MastodonUser) {
+ public func update(bookmarked: Bool, by mastodonUser: MastodonUser) {
if bookmarked {
if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) {
self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(mastodonUser)
@@ -221,14 +223,18 @@ public extension Status {
}
}
- func didUpdate(at networkDate: Date) {
+ public func update(isReveal: Bool) {
+ revealedAt = isReveal ? Date() : nil
+ }
+
+ public func didUpdate(at networkDate: Date) {
self.updatedAt = networkDate
}
}
-public extension Status {
- struct Property {
+extension Status {
+ public struct Property {
public let identifier: ID
public let domain: String
@@ -338,7 +344,4 @@ extension Status {
return NSPredicate(format: "%K != nil", #keyPath(Status.deletedAt))
}
- public static func author(author: MastodonUser) -> NSPredicate {
- return NSPredicate(format: "%K == %@", #keyPath(Status.author), author)
- }
}
diff --git a/Localization/app.json b/Localization/app.json
index 18d7a193e..ec908384b 100644
--- a/Localization/app.json
+++ b/Localization/app.json
@@ -58,7 +58,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 67bd4878a..e3f553ba7 100644
--- a/Mastodon.xcodeproj/project.pbxproj
+++ b/Mastodon.xcodeproj/project.pbxproj
@@ -238,6 +238,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 */; };
@@ -667,6 +669,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 = ""; };
@@ -1048,6 +1052,8 @@
DB2B3AE825E38850007045F9 /* UIViewPreview.swift */,
DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */,
DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */,
+ DB51D170262832380062B7A1 /* BlurHashDecode.swift */,
+ DB51D171262832380062B7A1 /* BlurHashEncode.swift */,
);
path = Vender;
sourceTree = "";
@@ -2618,6 +2624,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 */,
@@ -2643,6 +2650,7 @@
5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */,
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */,
+ DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */,
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */,
diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
index fd1ce69a1..18c8840d8 100644
--- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -7,7 +7,7 @@
CoreDataStack.xcscheme_^#shared#^_
orderHint
- 20
+ 13
Mastodon - RTL.xcscheme_^#shared#^_
diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 000000000..741947371
--- /dev/null
+++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,142 @@
+{
+ "object": {
+ "pins": [
+ {
+ "package": "ActiveLabel",
+ "repositoryURL": "https://github.com/TwidereProject/ActiveLabel.swift",
+ "state": {
+ "branch": null,
+ "revision": "d6cf96e0ca4f2269021bcf8f11381ab57897f84a",
+ "version": "4.0.0"
+ }
+ },
+ {
+ "package": "Alamofire",
+ "repositoryURL": "https://github.com/Alamofire/Alamofire.git",
+ "state": {
+ "branch": null,
+ "revision": "4d19ad82f80cc71ff829b941ded114c56f4f604c",
+ "version": "5.4.2"
+ }
+ },
+ {
+ "package": "AlamofireImage",
+ "repositoryURL": "https://github.com/Alamofire/AlamofireImage.git",
+ "state": {
+ "branch": null,
+ "revision": "98cbb00ce0ec5fc8e52a5b50a6bfc08d3e5aee10",
+ "version": "4.2.0"
+ }
+ },
+ {
+ "package": "AlamofireNetworkActivityIndicator",
+ "repositoryURL": "https://github.com/Alamofire/AlamofireNetworkActivityIndicator",
+ "state": {
+ "branch": null,
+ "revision": "392bed083e8d193aca16bfa684ee24e4bcff0510",
+ "version": "3.1.0"
+ }
+ },
+ {
+ "package": "CommonOSLog",
+ "repositoryURL": "https://github.com/MainasuK/CommonOSLog",
+ "state": {
+ "branch": null,
+ "revision": "c121624a30698e9886efe38aebb36ff51c01b6c2",
+ "version": "0.1.1"
+ }
+ },
+ {
+ "package": "Kingfisher",
+ "repositoryURL": "https://github.com/onevcat/Kingfisher.git",
+ "state": {
+ "branch": null,
+ "revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e",
+ "version": "6.2.1"
+ }
+ },
+ {
+ "package": "Pageboy",
+ "repositoryURL": "https://github.com/uias/Pageboy",
+ "state": {
+ "branch": null,
+ "revision": "34ecb6e7c4e0e07494960ab2f7cc9a02293915a6",
+ "version": "3.6.2"
+ }
+ },
+ {
+ "package": "swift-nio",
+ "repositoryURL": "https://github.com/apple/swift-nio.git",
+ "state": {
+ "branch": null,
+ "revision": "8da5c5a4e6c5084c296b9f39dc54f00be146e0fa",
+ "version": "1.14.2"
+ }
+ },
+ {
+ "package": "swift-nio-zlib-support",
+ "repositoryURL": "https://github.com/apple/swift-nio-zlib-support.git",
+ "state": {
+ "branch": null,
+ "revision": "37760e9a52030bb9011972c5213c3350fa9d41fd",
+ "version": "1.0.0"
+ }
+ },
+ {
+ "package": "SwiftyJSON",
+ "repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git",
+ "state": {
+ "branch": null,
+ "revision": "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07",
+ "version": "5.0.1"
+ }
+ },
+ {
+ "package": "Tabman",
+ "repositoryURL": "https://github.com/uias/Tabman",
+ "state": {
+ "branch": null,
+ "revision": "f43489cdd743ba7ad86a422ebb5fcbf34e333df4",
+ "version": "2.11.1"
+ }
+ },
+ {
+ "package": "ThirdPartyMailer",
+ "repositoryURL": "https://github.com/vtourraine/ThirdPartyMailer.git",
+ "state": {
+ "branch": null,
+ "revision": "923c60ee7588da47db8cfc4e0f5b96e5e605ef84",
+ "version": "1.7.1"
+ }
+ },
+ {
+ "package": "TOCropViewController",
+ "repositoryURL": "https://github.com/TimOliver/TOCropViewController.git",
+ "state": {
+ "branch": null,
+ "revision": "dad97167bf1be16aeecd109130900995dd01c515",
+ "version": "2.6.0"
+ }
+ },
+ {
+ "package": "TwitterTextEditor",
+ "repositoryURL": "https://github.com/MainasuK/TwitterTextEditor",
+ "state": {
+ "branch": "feature/input-view",
+ "revision": "1e565d13e3c26fc2bedeb418890df42f80d6e3d5",
+ "version": null
+ }
+ },
+ {
+ "package": "UITextView+Placeholder",
+ "repositoryURL": "https://github.com/MainasuK/UITextView-Placeholder",
+ "state": {
+ "branch": null,
+ "revision": "20f513ded04a040cdf5467f0891849b1763ede3b",
+ "version": "1.4.1"
+ }
+ }
+ ]
+ },
+ "version": 1
+}
diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift
index da3455201..e169be66f 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 isRevealing = 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/Item/NotificationItem.swift b/Mastodon/Diffiable/Item/NotificationItem.swift
index ba0d0c140..f26d2e43d 100644
--- a/Mastodon/Diffiable/Item/NotificationItem.swift
+++ b/Mastodon/Diffiable/Item/NotificationItem.swift
@@ -9,7 +9,7 @@ import CoreData
import Foundation
enum NotificationItem {
- case notification(objectID: NSManagedObjectID)
+ case notification(objectID: NSManagedObjectID, attribute: Item.StatusAttribute)
case bottomLoader
}
@@ -17,7 +17,7 @@ enum NotificationItem {
extension NotificationItem: Equatable {
static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool {
switch (lhs, rhs) {
- case (.notification(let idLeft), .notification(let idRight)):
+ case (.notification(let idLeft, _), .notification(let idRight, _)):
return idLeft == idRight
case (.bottomLoader, .bottomLoader):
return true
@@ -30,7 +30,7 @@ extension NotificationItem: Equatable {
extension NotificationItem: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
- case .notification(let id):
+ case .notification(let id, _):
hasher.combine(id)
case .bottomLoader:
hasher.combine(String(describing: NotificationItem.bottomLoader.self))
diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift
index 0e7c574b4..91363ef09 100644
--- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift
+++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift
@@ -77,14 +77,15 @@ extension ComposeStatusSection {
return cell
case .input(let replyToStatusObjectID, let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell
+ cell.statusContentWarningEditorView.textView.text = attribute.contentWarningContent.value
cell.textEditorView.text = attribute.composeContent.value ?? ""
managedObjectContext.perform {
guard let replyToStatusObjectID = replyToStatusObjectID,
let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
- cell.statusView.headerContainerStackView.isHidden = true
+ cell.statusView.headerContainerView.isHidden = true
return
}
- cell.statusView.headerContainerStackView.isHidden = false
+ cell.statusView.headerContainerView.isHidden = false
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
cell.statusView.headerInfoLabel.text = L10n.Scene.Compose.replyingToUser(replyTo.author.displayNameWithFallback)
}
diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift
index 5ccab431c..9c59350b4 100644
--- a/Mastodon/Diffiable/Section/NotificationSection.swift
+++ b/Mastodon/Diffiable/Section/NotificationSection.swift
@@ -22,15 +22,14 @@ extension NotificationSection {
timestampUpdatePublisher: AnyPublisher,
managedObjectContext: NSManagedObjectContext,
delegate: NotificationTableViewCellDelegate,
- dependency: NeedsDependency,
- requestUserID: String
+ dependency: NeedsDependency
) -> UITableViewDiffableDataSource {
UITableViewDiffableDataSource(tableView: tableView) {
[weak delegate, weak dependency]
(tableView, indexPath, notificationItem) -> UITableViewCell? in
guard let dependency = dependency else { return nil }
switch notificationItem {
- case .notification(let objectID):
+ case .notification(let objectID, let attribute):
let notification = managedObjectContext.object(with: objectID) as! MastodonNotification
guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.typeRaw) else {
@@ -46,14 +45,18 @@ extension NotificationSection {
if let status = notification.status {
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell
cell.delegate = delegate
+ let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value
+ let requestUserID = activeMastodonAuthenticationBox?.userID ?? ""
let frame = CGRect(x: 0, y: 0, width: tableView.readableContentGuide.layoutFrame.width - NotificationStatusTableViewCell.statusPadding.left - NotificationStatusTableViewCell.statusPadding.right, height: tableView.readableContentGuide.layoutFrame.height)
- StatusSection.configure(cell: cell,
- dependency: dependency,
- readableLayoutFrame: frame,
- timestampUpdatePublisher: timestampUpdatePublisher,
- status: status,
- requestUserID: requestUserID,
- statusItemAttribute: Item.StatusAttribute(isStatusTextSensitive: false, isStatusSensitive: false))
+ StatusSection.configure(
+ cell: cell,
+ dependency: dependency,
+ readableLayoutFrame: frame,
+ timestampUpdatePublisher: timestampUpdatePublisher,
+ status: status,
+ requestUserID: requestUserID,
+ statusItemAttribute: attribute
+ )
timestampUpdatePublisher
.sink { _ in
let timeText = notification.createAt.shortTimeAgoSinceNow
diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift
index cd4230dab..4f09142a7 100644
--- a/Mastodon/Diffiable/Section/StatusSection.swift
+++ b/Mastodon/Diffiable/Section/StatusSection.swift
@@ -140,10 +140,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)
@@ -178,19 +175,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 }
@@ -214,30 +198,73 @@ 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.isRevealing
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { isImageLoaded, isMediaRevealing in
+ guard isImageLoaded else {
+ blurhashOverlayImageView.alpha = 1
+ blurhashOverlayImageView.isHidden = false
+ return
+ }
+
+ blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1
+ if isMediaRevealing {
+ let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
+ animator.addAnimations {
+ blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1
+ }
+ animator.startAnimation()
+ } else {
+ cell.statusView.drawContentWarningImageView()
+ }
+ }
+ .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 {
@@ -259,10 +286,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)
{
@@ -313,6 +336,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(
@@ -373,6 +424,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.reblog ?? 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
+ statusView.playerContainerView.contentWarningOverlayView.isHidden = true
+
+ if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp {
+ statusView.updateRevealContentWarningButton(isRevealing: true)
+ statusView.updateContentWarningDisplay(isHidden: true, animated: animated)
+ attribute.isRevealing.value = true
+ } else {
+ statusView.updateRevealContentWarningButton(isRevealing: false)
+ statusView.updateContentWarningDisplay(isHidden: false, animated: animated)
+ attribute.isRevealing.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.playerContainerView.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.isRevealing.value = needsReveal
+ if needsReveal {
+ statusView.updateRevealContentWarningButton(isRevealing: true)
+ statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: true, style: .visualEffectView)
+ statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: true, style: .visualEffectView)
+ } else {
+ statusView.updateRevealContentWarningButton(isRevealing: false)
+ statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: false, style: .visualEffectView)
+ statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: false, style: .visualEffectView)
+ }
+ }
+ 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
@@ -413,7 +546,7 @@ extension StatusSection {
status: Status
) {
if status.reblog != nil {
- cell.statusView.headerContainerStackView.isHidden = false
+ cell.statusView.headerContainerView.isHidden = false
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage)
cell.statusView.headerInfoLabel.text = {
let author = status.author
@@ -421,7 +554,7 @@ extension StatusSection {
return L10n.Common.Controls.Status.userReblogged(name)
}()
} else if status.inReplyToID != nil {
- cell.statusView.headerContainerStackView.isHidden = false
+ cell.statusView.headerContainerView.isHidden = false
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
cell.statusView.headerInfoLabel.text = {
guard let replyTo = status.replyTo else {
@@ -432,7 +565,7 @@ extension StatusSection {
return L10n.Common.Controls.Status.userRepliedTo(name)
}()
} else {
- cell.statusView.headerContainerStackView.isHidden = true
+ cell.statusView.headerContainerView.isHidden = true
}
}
diff --git a/Mastodon/Extension/CoreDataStack/Status.swift b/Mastodon/Extension/CoreDataStack/Status.swift
index cf4f8a1bd..880be6fa3 100644
--- a/Mastodon/Extension/CoreDataStack/Status.swift
+++ b/Mastodon/Extension/CoreDataStack/Status.swift
@@ -32,3 +32,34 @@ extension Status.Property {
)
}
}
+
+extension Status {
+
+ enum SensitiveType {
+ case none
+ case all
+ case media(isSensitive: Bool)
+ }
+
+ var sensitiveType: SensitiveType {
+ let spoilerText = self.spoilerText ?? ""
+
+ // cast .all sensitive when has spoiter text
+ if !spoilerText.isEmpty {
+ return .all
+ }
+
+ if let firstAttachment = mediaAttachments?.first {
+ // cast .media when has non audio media
+ if firstAttachment.type != .audio {
+ return .media(isSensitive: sensitive)
+ } else {
+ return .none
+ }
+ }
+
+ // not sensitive
+ return .none
+ }
+
+}
diff --git a/Mastodon/Extension/NSLayoutConstraint.swift b/Mastodon/Extension/NSLayoutConstraint.swift
index cae353187..eea697e2b 100644
--- a/Mastodon/Extension/NSLayoutConstraint.swift
+++ b/Mastodon/Extension/NSLayoutConstraint.swift
@@ -12,4 +12,9 @@ extension NSLayoutConstraint {
self.priority = priority
return self
}
+
+ func identifier(_ identifier: String?) -> Self {
+ self.identifier = identifier
+ return self
+ }
}
diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift
index 1fb5c5f59..0972b5a20 100644
--- a/Mastodon/Generated/Strings.swift
+++ b/Mastodon/Generated/Strings.swift
@@ -148,12 +148,16 @@ internal enum L10n {
}
}
internal enum Status {
+ /// content warning
+ internal static let contentWarning = L10n.tr("Localizable", "Common.Controls.Status.ContentWarning")
+ /// cw: %@
+ internal static func contentWarningText(_ p1: Any) -> String {
+ return L10n.tr("Localizable", "Common.Controls.Status.ContentWarningText", String(describing: p1))
+ }
/// Tap to reveal that may be sensitive
internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning")
/// Show Post
internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost")
- /// content warning
- internal static let statusContentWarning = L10n.tr("Localizable", "Common.Controls.Status.StatusContentWarning")
/// %@ reblogged
internal static func userReblogged(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1))
diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift
index 25322e216..198f0a4a3 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
@@ -74,37 +63,15 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
}
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
- statusTableViewCell(cell, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
+ StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell)
}
func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
- contentWarningOverlayView.isUserInteractionEnabled = false
- statusTableViewCell(cell, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
+ StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell)
}
func statusTableViewCell(_ cell: StatusTableViewCell, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
- guard let diffableDataSource = self.tableViewDiffableDataSource else { return }
- guard let item = item(for: cell, indexPath: nil) else { return }
-
- switch item {
- case .homeTimelineIndex(_, let attribute),
- .status(_, let attribute),
- .root(_, let attribute),
- .reply(_, let attribute),
- .leaf(_, let attribute):
- attribute.isStatusSensitive = false
- default:
- return
- }
- contentWarningOverlayView.isUserInteractionEnabled = false
- var snapshot = diffableDataSource.snapshot()
- snapshot.reloadItems([item])
- UIView.animate(withDuration: 0.33) {
- contentWarningOverlayView.blurVisualEffectView.effect = nil
- contentWarningOverlayView.vibrancyVisualEffectView.alpha = 0.0
- } completion: { _ in
- diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil)
- }
+ StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell)
}
}
diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
index 0e26614c5..2e6102227 100644
--- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
+++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
@@ -415,6 +415,91 @@ extension StatusProviderFacade {
}
+extension StatusProviderFacade {
+
+ static func responseToStatusContentWarningRevealAction(dependency: NotificationViewController, cell: UITableViewCell) {
+ let status = Future { promise in
+ guard let diffableDataSource = dependency.viewModel.diffableDataSource,
+ let indexPath = dependency.tableView.indexPath(for: cell),
+ let item = diffableDataSource.itemIdentifier(for: indexPath) else {
+ promise(.success(nil))
+ return
+ }
+
+ switch item {
+ case .notification(let objectID, _):
+ dependency.viewModel.fetchedResultsController.managedObjectContext.perform {
+ let notification = dependency.viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! MastodonNotification
+ promise(.success(notification.status))
+ }
+ default:
+ promise(.success(nil))
+ }
+ }
+
+ _responseToStatusContentWarningRevealAction(
+ dependency: dependency,
+ status: status
+ )
+ }
+
+ static func responseToStatusContentWarningRevealAction(provider: StatusProvider, cell: UITableViewCell) {
+ _responseToStatusContentWarningRevealAction(
+ dependency: provider,
+ status: provider.status(for: cell, indexPath: nil)
+ )
+ }
+
+ private static func _responseToStatusContentWarningRevealAction(dependency: NeedsDependency, status: Future) {
+ status
+ .compactMap { [weak dependency] status -> AnyPublisher? in
+ guard let dependency = dependency else { return nil }
+ guard let _status = status else { return nil }
+ return dependency.context.managedObjectContext.performChanges {
+ guard let status = dependency.context.managedObjectContext.object(with: _status.objectID) as? Status else { return }
+ let appStartUpTimestamp = dependency.context.documentStore.appStartUpTimestamp
+ let isRevealing: Bool = {
+ if dependency.context.documentStore.defaultRevealStatusDict[status.id] == true {
+ return true
+ }
+ if status.reblog.flatMap({ dependency.context.documentStore.defaultRevealStatusDict[$0.id] }) == true {
+ return true
+ }
+ if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp {
+ return true
+ }
+
+ return false
+ }()
+ // toggle reveal
+ dependency.context.documentStore.defaultRevealStatusDict[status.id] = false
+ status.update(isReveal: !isRevealing)
+ status.reblog?.update(isReveal: !isRevealing)
+
+ // pause video playback if isRevealing before toggle
+ if isRevealing, let attachment = (status.reblog ?? status).mediaAttachments?.first,
+ let playerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: attachment) {
+ playerViewModel.pause()
+ }
+ // resume GIF playback if NOT isRevealing before toggle
+ if !isRevealing, let attachment = (status.reblog ?? status).mediaAttachments?.first,
+ let playerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: attachment), playerViewModel.videoKind == .gif {
+ playerViewModel.play()
+ }
+ }
+ .map { result in
+ return status
+ }
+ .eraseToAnyPublisher()
+ }
+ .sink { _ in
+ // do nothing
+ }
+ .store(in: &dependency.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 b413d887c..346734666 100644
--- a/Mastodon/Resources/en.lproj/Localizable.strings
+++ b/Mastodon/Resources/en.lproj/Localizable.strings
@@ -50,6 +50,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";
@@ -59,7 +61,6 @@ Please check your internet connection.";
"Common.Controls.Status.Poll.VoterCount.Multiple" = "%d voters";
"Common.Controls.Status.Poll.VoterCount.Single" = "%d voter";
"Common.Controls.Status.ShowPost" = "Show Post";
-"Common.Controls.Status.StatusContentWarning" = "content warning";
"Common.Controls.Status.UserReblogged" = "%@ reblogged";
"Common.Controls.Status.UserRepliedTo" = "Replied to %@";
"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view Artbot’s profile
diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift
index 95e9b4f1a..8da4c0729 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,18 +44,18 @@ extension ComposeRepliedToStatusContentCollectionViewCell {
private func _init() {
backgroundColor = .clear
- statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.systemBackground.color
+ statusView.actionToolbarContainer.isHidden = true
+ statusView.revealContentWarningButton.isHidden = true
+
statusView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(statusView)
NSLayoutConstraint.activate([
- statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20),
+ statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20).identifier("statusView.top to ComposeRepliedToStatusContentCollectionViewCell.contentView.top"),
statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor),
- contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10),
+ contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10).identifier("ComposeRepliedToStatusContentCollectionViewCell.contentView.bottom to statusView.bottom"),
])
-
- statusView.actionToolbarContainer.isHidden = true
}
}
diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift
index 2b71e55f3..5ec2a9eeb 100644
--- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift
+++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift
@@ -90,6 +90,7 @@ extension ComposeStatusContentCollectionViewCell {
textEditorView.changeObserver = self
statusContentWarningEditorView.containerView.isHidden = true
+ statusView.revealContentWarningButton.isHidden = true
}
}
diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift
index b463f13ac..a18cf9216 100644
--- a/Mastodon/Scene/Compose/ComposeViewController.swift
+++ b/Mastodon/Scene/Compose/ComposeViewController.swift
@@ -182,7 +182,7 @@ extension ComposeViewController {
)
// respond scrollView overlap change
- view.layoutIfNeeded()
+ //view.layoutIfNeeded()
// update layout when keyboard show/dismiss
Publishers.CombineLatest4(
KeyboardResponderService.shared.isShow.eraseToAnyPublisher(),
@@ -210,7 +210,9 @@ extension ComposeViewController {
self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
UIView.animate(withDuration: 0.3) {
self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom
- self.view.layoutIfNeeded()
+ if self.view.window != nil {
+ self.view.layoutIfNeeded()
+ }
}
self.updateKeyboardBackground(isKeyboardDisplay: isShow)
return
diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift
index ef744d0b3..587b56f23 100644
--- a/Mastodon/Scene/Compose/ComposeViewModel.swift
+++ b/Mastodon/Scene/Compose/ComposeViewModel.swift
@@ -112,6 +112,10 @@ final class ComposeViewModel {
for acct in mentionAccts {
UITextChecker.learnWord(acct)
}
+ if let spoilerText = status.spoilerText, !spoilerText.isEmpty {
+ self.isContentWarningComposing.value = true
+ self.composeStatusAttribute.contentWarningContent.value = spoilerText
+ }
let initialComposeContent = mentionAccts.joined(separator: " ")
let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " "
diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift
index 99288a5e0..2940217e5 100644
--- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift
+++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift
@@ -284,13 +284,13 @@ struct ComposeToolbarView_Previews: PreviewProvider {
static var previews: some View {
UIViewPreview(width: 375) {
- let tootbarView = ComposeToolbarView()
- tootbarView.translatesAutoresizingMaskIntoConstraints = false
+ let toolbarView = ComposeToolbarView()
+ toolbarView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
- tootbarView.widthAnchor.constraint(equalToConstant: 375).priority(.defaultHigh),
- tootbarView.heightAnchor.constraint(equalToConstant: 64).priority(.defaultHigh),
+ toolbarView.widthAnchor.constraint(equalToConstant: 375).priority(.defaultHigh),
+ toolbarView.heightAnchor.constraint(equalToConstant: 64).priority(.defaultHigh),
])
- return tootbarView
+ return toolbarView
}
.previewLayout(.fixed(width: 375, height: 100))
}
diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift
index ad9a7472e..57b5dc639 100644
--- a/Mastodon/Scene/Notification/NotificationViewController.swift
+++ b/Mastodon/Scene/Notification/NotificationViewController.swift
@@ -36,6 +36,7 @@ final class NotificationViewController: UIViewController, NeedsDependency {
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
tableView.tableFooterView = UIView()
tableView.estimatedRowHeight = UITableView.automaticDimension
+ tableView.backgroundColor = .clear
return tableView
}()
@@ -45,13 +46,14 @@ final class NotificationViewController: UIViewController, NeedsDependency {
extension NotificationViewController {
override func viewDidLoad() {
super.viewDidLoad()
+
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
navigationItem.titleView = segmentControl
segmentControl.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .valueChanged)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
- tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
+ tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
@@ -65,6 +67,7 @@ extension NotificationViewController {
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
viewModel.setupDiffableDataSource(for: tableView, delegate: self, dependency: self)
viewModel.viewDidLoad.send()
+
// bind refresh control
viewModel.isFetchingLatestNotification
.receive(on: DispatchQueue.main)
@@ -83,6 +86,8 @@ extension NotificationViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
+ tableView.deselectRow(with: transitionCoordinator, animated: animated)
+
// needs trigger manually after onboarding dismiss
setNeedsStatusBarAppearanceUpdate()
}
@@ -159,11 +164,10 @@ extension NotificationViewController {
extension NotificationViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
- tableView.deselectRow(at: indexPath, animated: true)
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
switch item {
- case .notification(let objectID):
+ case .notification(let objectID, _):
let notification = context.managedObjectContext.object(with: objectID) as! MastodonNotification
if let status = notification.status {
let viewModel = ThreadViewModel(context: context, optionalStatus: status)
@@ -199,6 +203,7 @@ extension NotificationViewController: ContentOffsetAdjustableTimelineViewControl
}
}
+// MARK: - NotificationTableViewCellDelegate
extension NotificationViewController: NotificationTableViewCellDelegate {
func userAvatarDidPressed(notification: MastodonNotification) {
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account)
@@ -210,6 +215,18 @@ extension NotificationViewController: NotificationTableViewCellDelegate {
func parent() -> UIViewController {
self
}
+
+ func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) {
+ StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: self, cell: cell)
+ }
+
+ func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
+ StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: self, cell: cell)
+ }
+
+ func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
+ StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: self, cell: cell)
+ }
}
// MARK: - UIScrollViewDelegate
diff --git a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift
index 5bd2d92dd..cd28c5f5a 100644
--- a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift
+++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift
@@ -20,16 +20,13 @@ extension NotificationViewModel {
.autoconnect()
.share()
.eraseToAnyPublisher()
- guard let userid = activeMastodonAuthenticationBox.value?.userID else {
- return
- }
+
diffableDataSource = NotificationSection.tableViewDiffableDataSource(
for: tableView,
timestampUpdatePublisher: timestampUpdatePublisher,
managedObjectContext: context.managedObjectContext,
delegate: delegate,
- dependency: dependency,
- requestUserID: userid
+ dependency: dependency
)
}
}
@@ -67,9 +64,31 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate {
DispatchQueue.main.async {
let oldSnapshot = diffableDataSource.snapshot()
+ var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
+ for item in oldSnapshot.itemIdentifiers {
+ guard case let .notification(objectID, attribute) = item else { continue }
+ oldSnapshotAttributeDict[objectID] = attribute
+ }
var newSnapshot = NSDiffableDataSourceSnapshot()
newSnapshot.appendSections([.main])
- newSnapshot.appendItems(notifications.map { NotificationItem.notification(objectID: $0.objectID) }, toSection: .main)
+ let items: [NotificationItem] = notifications.map { notification in
+ let attribute: Item.StatusAttribute = oldSnapshotAttributeDict[notification.objectID] ?? Item.StatusAttribute()
+
+// let attribute: Item.StatusAttribute = {
+// if let attribute = oldSnapshotAttributeDict[notification.objectID] {
+// return attribute
+// } else if let status = notification.status {
+// let attribute = Item.StatusAttribute()
+// let isSensitive = status.sensitive || !(status.spoilerText ?? "").isEmpty
+// attribute.isRevealing.value = !isSensitive
+// return attribute
+// } else {
+// return Item.StatusAttribute()
+// }
+// }()
+ return NotificationItem.notification(objectID: notification.objectID, attribute: attribute)
+ }
+ newSnapshot.appendItems(items, toSection: .main)
if !notifications.isEmpty, self.noMoreNotification.value == false {
newSnapshot.appendItems([.bottomLoader], toSection: .main)
}
diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift
index 871adcaeb..7b76dd2f0 100644
--- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift
+++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift
@@ -8,6 +8,7 @@
import Combine
import Foundation
import UIKit
+import ActiveLabel
final class NotificationStatusTableViewCell: UITableViewCell, StatusCell {
static let actionImageBorderWidth: CGFloat = 2
@@ -78,8 +79,7 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell {
override func prepareForReuse() {
super.prepareForReuse()
avatatImageView.af.cancelImageRequest()
- statusView.isStatusTextSensitive = false
- statusView.cleanUpContentWarning()
+ statusView.updateContentWarningDisplay(isHidden: true, animated: false)
statusView.pollTableView.dataSource = nil
statusView.playerContainerView.reset()
statusView.playerContainerView.isHidden = true
@@ -99,6 +99,9 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell {
override func layoutSubviews() {
super.layoutSubviews()
+
+ // precondition: app is active
+ guard UIApplication.shared.applicationState == .active else { return }
DispatchQueue.main.async {
self.statusView.drawContentWarningImageView()
}
@@ -107,6 +110,8 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell {
extension NotificationStatusTableViewCell {
func configure() {
+ backgroundColor = Asset.Colors.Background.systemBackground.color
+
let containerStackView = UIStackView()
containerStackView.axis = .horizontal
containerStackView.alignment = .top
@@ -154,7 +159,6 @@ extension NotificationStatusTableViewCell {
actionImageView.centerYAnchor.constraint(equalTo: actionImageBackground.centerYAnchor)
])
-
let actionStackView = UIStackView()
actionStackView.axis = .horizontal
actionStackView.distribution = .fill
@@ -187,13 +191,12 @@ extension NotificationStatusTableViewCell {
statusBorder.trailingAnchor.constraint(equalTo: statusView.trailingAnchor, constant: 12),
])
+ statusView.delegate = self
statusStackView.addArrangedSubview(statusBorder)
containerStackView.addArrangedSubview(statusStackView)
- statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
- statusView.isUserInteractionEnabled = false
// remove item don't display
statusView.actionToolbarContainer.removeFromStackView()
// it affect stackView's height,need remove
@@ -206,4 +209,54 @@ extension NotificationStatusTableViewCell {
statusBorder.layer.borderColor = Asset.Colors.Border.notification.color.cgColor
actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor
}
+
+ override func setHighlighted(_ highlighted: Bool, animated: Bool) {
+ super.setHighlighted(highlighted, animated: animated)
+
+ resetContentOverlayBlurImageBackgroundColor(selected: highlighted)
+ }
+
+ override func setSelected(_ selected: Bool, animated: Bool) {
+ super.setSelected(selected, animated: animated)
+
+ resetContentOverlayBlurImageBackgroundColor(selected: selected)
+ }
+
+ private func resetContentOverlayBlurImageBackgroundColor(selected: Bool) {
+ let imageViewBackgroundColor: UIColor? = selected ? selectedBackgroundView?.backgroundColor : backgroundColor
+ statusView.contentWarningOverlayView.blurContentImageView.backgroundColor = imageViewBackgroundColor
+ }
+}
+
+// MARK: - StatusViewDelegate
+extension NotificationStatusTableViewCell: StatusViewDelegate {
+ func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) {
+ // do nothing
+ }
+
+ func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) {
+ // do nothing
+ }
+
+ func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) {
+ delegate?.notificationStatusTableViewCell(self, statusView: statusView, revealContentWarningButtonDidPressed: button)
+ }
+
+ func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
+ delegate?.notificationStatusTableViewCell(self, statusView: statusView, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
+ }
+
+ func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
+ delegate?.notificationStatusTableViewCell(self, statusView: statusView, playerContainerView: playerContainerView, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
+ }
+
+ func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) {
+ // do nothing
+ }
+
+ func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
+ // do nothing
+ }
+
+
}
diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift
index 60b43ac35..619bffa17 100644
--- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift
+++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift
@@ -16,6 +16,11 @@ protocol NotificationTableViewCellDelegate: AnyObject {
func parent() -> UIViewController
func userAvatarDidPressed(notification: MastodonNotification)
+
+ func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton)
+ func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
+ func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
+
}
final class NotificationTableViewCell: UITableViewCell {
diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift
index 3ca407caa..27336dc58 100644
--- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift
+++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift
@@ -64,7 +64,7 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
guard let spoilerText = targetStatus.spoilerText, !spoilerText.isEmpty else { return false }
return true
}()
- let attribute = oldSnapshotAttributeDict[status.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetStatus.sensitive)
+ let attribute = oldSnapshotAttributeDict[status.objectID] ?? Item.StatusAttribute()
items.append(Item.status(objectID: status.objectID, attribute: attribute))
if statusIDsWhichHasGap.contains(status.id) {
items.append(Item.publicMiddleLoader(statusID: status.id))
diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift
index b3c03a46b..54e25ed87 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,22 @@ 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),
+ ])
+
+ contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false
addSubview(contentWarningOverlayView)
NSLayoutConstraint.activate([
contentWarningOverlayView.topAnchor.constraint(equalTo: imageView.topAnchor),
@@ -137,11 +158,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 +182,25 @@ extension MosaicImageViewContainer {
container.addArrangedSubview(contentRightStackView)
var imageViews: [UIImageView] = []
+ var blurhashOverlayImageViews: [UIImageView] = []
for _ in 0..?
+ var pollTableViewHeightLaoutConstraint: NSLayoutConstraint!
+
+ let containerStackView = UIStackView()
+ let headerContainerView = UIView()
+ let authorContainerView = UIView()
static let reblogIconImage: UIImage = {
let font = UIFont.systemFont(ofSize: 13, weight: .medium)
@@ -52,13 +63,6 @@ final class StatusView: UIView {
return attributedString
}
- weak var delegate: StatusViewDelegate?
- var isStatusTextSensitive = false
- var pollTableViewDataSource: UITableViewDiffableDataSource?
- var pollTableViewHeightLaoutConstraint: NSLayoutConstraint!
-
- let headerContainerStackView = UIStackView()
-
let headerIconLabel: UILabel = {
let label = UILabel()
label.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage)
@@ -115,25 +119,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 +172,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()
@@ -231,9 +224,9 @@ extension StatusView {
func _init() {
// container: [reblog | author | status | action toolbar]
- let containerStackView = UIStackView()
+ // note: do not set spacing for nested stackView to avoid SDK layout conflict issue
containerStackView.axis = .vertical
- containerStackView.spacing = 10
+ // containerStackView.spacing = 10
containerStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(containerStackView)
NSLayoutConstraint.activate([
@@ -242,19 +235,30 @@ extension StatusView {
trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor),
bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor),
])
+ containerStackView.setContentHuggingPriority(.required - 1, for: .vertical)
// header container: [icon | info]
- containerStackView.addArrangedSubview(headerContainerStackView)
- headerContainerStackView.spacing = 4
+ let headerContainerStackView = UIStackView()
+ headerContainerStackView.axis = .horizontal
headerContainerStackView.addArrangedSubview(headerIconLabel)
headerContainerStackView.addArrangedSubview(headerInfoLabel)
headerIconLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
- // author container: [avatar | author meta container]
+ headerContainerStackView.translatesAutoresizingMaskIntoConstraints = false
+ headerContainerView.addSubview(headerContainerStackView)
+ NSLayoutConstraint.activate([
+ headerContainerStackView.topAnchor.constraint(equalTo: headerContainerView.topAnchor),
+ headerContainerStackView.leadingAnchor.constraint(equalTo: headerContainerView.leadingAnchor),
+ headerContainerStackView.trailingAnchor.constraint(equalTo: headerContainerView.trailingAnchor),
+ headerContainerView.bottomAnchor.constraint(equalTo: headerContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh),
+ ])
+ containerStackView.addArrangedSubview(headerContainerView)
+
+ // author container: [avatar | author meta container | reveal button]
let authorContainerStackView = UIStackView()
- containerStackView.addArrangedSubview(authorContainerStackView)
authorContainerStackView.axis = .horizontal
authorContainerStackView.spacing = StatusView.avatarToLabelSpacing
+ authorContainerStackView.distribution = .fill
// avatar
avatarView.translatesAutoresizingMaskIntoConstraints = false
@@ -310,45 +314,54 @@ 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]
+ authorContainerStackView.translatesAutoresizingMaskIntoConstraints = false
+ authorContainerView.addSubview(authorContainerStackView)
+ NSLayoutConstraint.activate([
+ authorContainerStackView.topAnchor.constraint(equalTo: authorContainerView.topAnchor),
+ authorContainerStackView.leadingAnchor.constraint(equalTo: authorContainerView.leadingAnchor),
+ authorContainerStackView.trailingAnchor.constraint(equalTo: authorContainerView.trailingAnchor),
+ authorContainerView.bottomAnchor.constraint(equalTo: authorContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh),
+ ])
+ containerStackView.addArrangedSubview(authorContainerView)
+
+ // status container: [status | image / video | audio | poll | poll status] (overlay with content warning)
containerStackView.addArrangedSubview(statusContainerStackView)
statusContainerStackView.axis = .vertical
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,22 +389,11 @@ 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)
- headerContainerStackView.isHidden = true
+ headerContainerView.isHidden = true
statusMosaicImageViewContainer.isHidden = true
pollTableView.isHidden = true
pollStatusStackView.isHidden = true
@@ -399,12 +401,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 +413,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 +421,61 @@ 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)
}
.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 !isHidden {
+ drawContentWarningImageView()
+ }
+
+ 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
+ }
+
+ contentWarningOverlayView.blurContentWarningTitleLabel.isHidden = isHidden
+ contentWarningOverlayView.blurContentWarningLabel.isHidden = isHidden
+ }
+
+ 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 +497,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 +517,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) {
@@ -525,7 +566,7 @@ struct StatusView_Previews: PreviewProvider {
.previewDisplayName("Normal")
UIViewPreview(width: 375) {
let statusView = StatusView()
- statusView.headerContainerStackView.isHidden = false
+ statusView.headerContainerView.isHidden = false
statusView.avatarButton.isHidden = true
statusView.avatarStackedContainerButton.isHidden = false
statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(
@@ -552,15 +593,15 @@ struct StatusView_Previews: PreviewProvider {
placeholderImage: avatarFlora
)
)
- statusView.headerContainerStackView.isHidden = false
+ statusView.headerContainerView.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))
@@ -573,15 +614,15 @@ struct StatusView_Previews: PreviewProvider {
placeholderImage: avatarFlora
)
)
- statusView.headerContainerStackView.isHidden = false
- statusView.isStatusTextSensitive = true
+ statusView.headerContainerView.isHidden = false
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 88004afa2..d546fea62 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, StatusCell {
var disposeBag = Set()
var pollCountdownSubscription: AnyCancellable?
var observations = Set()
+ private var selectionBackgroundViewObservation: NSKeyValueObservation?
let statusView = StatusView()
let threadMetaStackView = UIStackView()
@@ -70,10 +72,11 @@ final class StatusTableViewCell: UITableViewCell, StatusCell {
override func prepareForReuse() {
super.prepareForReuse()
selectionStyle = .default
- statusView.isStatusTextSensitive = false
- statusView.cleanUpContentWarning()
+ statusView.updateContentWarningDisplay(isHidden: true, animated: false)
+ statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true
statusView.pollTableView.dataSource = nil
statusView.playerContainerView.reset()
+ statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = true
statusView.playerContainerView.isHidden = true
threadMetaView.isHidden = true
disposeBag.removeAll()
@@ -92,8 +95,11 @@ final class StatusTableViewCell: UITableViewCell, StatusCell {
override func layoutSubviews() {
super.layoutSubviews()
+
+ // precondition: app is active
+ guard UIApplication.shared.applicationState == .active else { return }
DispatchQueue.main.async {
- self.statusView.drawContentWarningImageView()
+ self.statusView.drawContentWarningImageView()
}
}
@@ -103,7 +109,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 +155,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 +199,11 @@ extension StatusTableViewCell {
}
}
}
+
+ private func resetContentOverlayBlurImageBackgroundColor(selected: Bool) {
+ let imageViewBackgroundColor: UIColor? = selected ? selectedBackgroundView?.backgroundColor : backgroundColor
+ statusView.contentWarningOverlayView.blurContentImageView.backgroundColor = imageViewBackgroundColor
+ }
}
// MARK: - UITableViewDelegate
@@ -270,8 +293,12 @@ extension StatusTableViewCell: StatusViewDelegate {
delegate?.statusTableViewCell(self, statusView: statusView, avatarButtonDidPressed: button)
}
- func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) {
- delegate?.statusTableViewCell(self, statusView: statusView, contentWarningActionButtonPressed: button)
+ func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) {
+ delegate?.statusTableViewCell(self, statusView: statusView, revealContentWarningButtonDidPressed: button)
+ }
+
+ func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
+ delegate?.statusTableViewCell(self, statusView: statusView, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
}
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
diff --git a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift
index ce92ccb77..26e426add 100644
--- a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift
+++ b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift
@@ -6,6 +6,7 @@
//
import UIKit
+import Combine
import CoreDataStack
struct MosaicImageViewModel {
@@ -24,7 +25,12 @@ struct MosaicImageViewModel {
let url = URL(string: urlString) else {
continue
}
- metas.append(MosaicMeta(url: url, size: CGSize(width: width, height: height)))
+ let mosaicMeta = MosaicMeta(
+ url: url,
+ size: CGSize(width: width, height: height),
+ blurhash: element.blurhash
+ )
+ metas.append(mosaicMeta)
}
self.metas = metas
}
@@ -32,6 +38,39 @@ struct MosaicImageViewModel {
}
struct MosaicMeta {
+ static let edgeMaxLength: CGFloat = 20
+
let url: URL
let size: CGSize
+ let blurhash: String?
+
+ let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.MosaicMeta.working-queue", qos: .userInitiated, attributes: .concurrent)
+
+ func blurhashImagePublisher() -> AnyPublisher {
+ return Future { promise in
+ guard let blurhash = blurhash else {
+ promise(.success(nil))
+ return
+ }
+
+ let imageSize: CGSize = {
+ let aspectRadio = size.width / size.height
+ if size.width > size.height {
+ let width: CGFloat = MosaicMeta.edgeMaxLength
+ let height = width / aspectRadio
+ return CGSize(width: width, height: height)
+ } else {
+ let height: CGFloat = MosaicMeta.edgeMaxLength
+ let width = height * aspectRadio
+ return CGSize(width: width, height: height)
+ }
+ }()
+
+ workingQueue.async {
+ let image = UIImage(blurHash: blurhash, size: imageSize)
+ promise(.success(image))
+ }
+ }
+ .eraseToAnyPublisher()
+ }
}
diff --git a/Mastodon/State/DocumentStore.swift b/Mastodon/State/DocumentStore.swift
index b39a29245..8b3f88eb7 100644
--- a/Mastodon/State/DocumentStore.swift
+++ b/Mastodon/State/DocumentStore.swift
@@ -7,5 +7,10 @@
import UIKit
import Combine
+import MastodonSDK
-class DocumentStore: ObservableObject { }
+class DocumentStore: ObservableObject {
+ let blurhashImageCache = NSCache()
+ let appStartUpTimestamp = Date()
+ var defaultRevealStatusDict: [Mastodon.Entity.Status.ID: Bool] = [:]
+}
diff --git a/Mastodon/Vender/BlurHashDecode.swift b/Mastodon/Vender/BlurHashDecode.swift
new file mode 100644
index 000000000..7fe3b3985
--- /dev/null
+++ b/Mastodon/Vender/BlurHashDecode.swift
@@ -0,0 +1,146 @@
+import UIKit
+
+extension UIImage {
+ public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) {
+ guard blurHash.count >= 6 else { return nil }
+
+ let sizeFlag = String(blurHash[0]).decode83()
+ let numY = (sizeFlag / 9) + 1
+ let numX = (sizeFlag % 9) + 1
+
+ let quantisedMaximumValue = String(blurHash[1]).decode83()
+ let maximumValue = Float(quantisedMaximumValue + 1) / 166
+
+ guard blurHash.count == 4 + 2 * numX * numY else { return nil }
+
+ let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in
+ if i == 0 {
+ let value = String(blurHash[2 ..< 6]).decode83()
+ return decodeDC(value)
+ } else {
+ let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83()
+ return decodeAC(value, maximumValue: maximumValue * punch)
+ }
+ }
+
+ let width = Int(size.width)
+ let height = Int(size.height)
+ let bytesPerRow = width * 3
+ guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil }
+ CFDataSetLength(data, bytesPerRow * height)
+ guard let pixels = CFDataGetMutableBytePtr(data) else { return nil }
+
+ for y in 0 ..< height {
+ for x in 0 ..< width {
+ var r: Float = 0
+ var g: Float = 0
+ var b: Float = 0
+
+ for j in 0 ..< numY {
+ for i in 0 ..< numX {
+ let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height))
+ let colour = colours[i + j * numX]
+ r += colour.0 * basis
+ g += colour.1 * basis
+ b += colour.2 * basis
+ }
+ }
+
+ let intR = UInt8(linearTosRGB(r))
+ let intG = UInt8(linearTosRGB(g))
+ let intB = UInt8(linearTosRGB(b))
+
+ pixels[3 * x + 0 + y * bytesPerRow] = intR
+ pixels[3 * x + 1 + y * bytesPerRow] = intG
+ pixels[3 * x + 2 + y * bytesPerRow] = intB
+ }
+ }
+
+ let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
+
+ guard let provider = CGDataProvider(data: data) else { return nil }
+ guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow,
+ space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { return nil }
+
+ self.init(cgImage: cgImage)
+ }
+}
+
+private func decodeDC(_ value: Int) -> (Float, Float, Float) {
+ let intR = value >> 16
+ let intG = (value >> 8) & 255
+ let intB = value & 255
+ return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB))
+}
+
+private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
+ let quantR = value / (19 * 19)
+ let quantG = (value / 19) % 19
+ let quantB = value % 19
+
+ let rgb = (
+ signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
+ signPow((Float(quantG) - 9) / 9, 2) * maximumValue,
+ signPow((Float(quantB) - 9) / 9, 2) * maximumValue
+ )
+
+ return rgb
+}
+
+private func signPow(_ value: Float, _ exp: Float) -> Float {
+ return copysign(pow(abs(value), exp), value)
+}
+
+private func linearTosRGB(_ value: Float) -> Int {
+ let v = max(0, min(1, value))
+ if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) }
+ else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
+}
+
+private func sRGBToLinear(_ value: Type) -> Float {
+ let v = Float(Int64(value)) / 255
+ if v <= 0.04045 { return v / 12.92 }
+ else { return pow((v + 0.055) / 1.055, 2.4) }
+}
+
+private let encodeCharacters: [String] = {
+ return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
+}()
+
+private let decodeCharacters: [String: Int] = {
+ var dict: [String: Int] = [:]
+ for (index, character) in encodeCharacters.enumerated() {
+ dict[character] = index
+ }
+ return dict
+}()
+
+extension String {
+ func decode83() -> Int {
+ var value: Int = 0
+ for character in self {
+ if let digit = decodeCharacters[String(character)] {
+ value = value * 83 + digit
+ }
+ }
+ return value
+ }
+}
+
+private extension String {
+ subscript (offset: Int) -> Character {
+ return self[index(startIndex, offsetBy: offset)]
+ }
+
+ subscript (bounds: CountableClosedRange) -> Substring {
+ let start = index(startIndex, offsetBy: bounds.lowerBound)
+ let end = index(startIndex, offsetBy: bounds.upperBound)
+ return self[start...end]
+ }
+
+ subscript (bounds: CountableRange) -> Substring {
+ let start = index(startIndex, offsetBy: bounds.lowerBound)
+ let end = index(startIndex, offsetBy: bounds.upperBound)
+ return self[start.. String? {
+ let pixelWidth = Int(round(size.width * scale))
+ let pixelHeight = Int(round(size.height * scale))
+
+ let context = CGContext(
+ data: nil,
+ width: pixelWidth,
+ height: pixelHeight,
+ bitsPerComponent: 8,
+ bytesPerRow: pixelWidth * 4,
+ space: CGColorSpace(name: CGColorSpace.sRGB)!,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )!
+ context.scaleBy(x: scale, y: -scale)
+ context.translateBy(x: 0, y: -size.height)
+
+ UIGraphicsPushContext(context)
+ draw(at: .zero)
+ UIGraphicsPopContext()
+
+ guard let cgImage = context.makeImage(),
+ let dataProvider = cgImage.dataProvider,
+ let data = dataProvider.data,
+ let pixels = CFDataGetBytePtr(data) else {
+ assertionFailure("Unexpected error!")
+ return nil
+ }
+
+ let width = cgImage.width
+ let height = cgImage.height
+ let bytesPerRow = cgImage.bytesPerRow
+
+ var factors: [(Float, Float, Float)] = []
+ for y in 0 ..< components.1 {
+ for x in 0 ..< components.0 {
+ let normalisation: Float = (x == 0 && y == 0) ? 1 : 2
+ let factor = multiplyBasisFunction(pixels: pixels, width: width, height: height, bytesPerRow: bytesPerRow, bytesPerPixel: cgImage.bitsPerPixel / 8, pixelOffset: 0) {
+ normalisation * cos(Float.pi * Float(x) * $0 / Float(width)) as Float * cos(Float.pi * Float(y) * $1 / Float(height)) as Float
+ }
+ factors.append(factor)
+ }
+ }
+
+ let dc = factors.first!
+ let ac = factors.dropFirst()
+
+ var hash = ""
+
+ let sizeFlag = (components.0 - 1) + (components.1 - 1) * 9
+ hash += sizeFlag.encode83(length: 1)
+
+ let maximumValue: Float
+ if ac.count > 0 {
+ let actualMaximumValue = ac.map({ max(abs($0.0), abs($0.1), abs($0.2)) }).max()!
+ let quantisedMaximumValue = Int(max(0, min(82, floor(actualMaximumValue * 166 - 0.5))))
+ maximumValue = Float(quantisedMaximumValue + 1) / 166
+ hash += quantisedMaximumValue.encode83(length: 1)
+ } else {
+ maximumValue = 1
+ hash += 0.encode83(length: 1)
+ }
+
+ hash += encodeDC(dc).encode83(length: 4)
+
+ for factor in ac {
+ hash += encodeAC(factor, maximumValue: maximumValue).encode83(length: 2)
+ }
+
+ return hash
+ }
+
+ private func multiplyBasisFunction(pixels: UnsafePointer, width: Int, height: Int, bytesPerRow: Int, bytesPerPixel: Int, pixelOffset: Int, basisFunction: (Float, Float) -> Float) -> (Float, Float, Float) {
+ var r: Float = 0
+ var g: Float = 0
+ var b: Float = 0
+
+ let buffer = UnsafeBufferPointer(start: pixels, count: height * bytesPerRow)
+
+ for x in 0 ..< width {
+ for y in 0 ..< height {
+ let basis = basisFunction(Float(x), Float(y))
+ r += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 0 + y * bytesPerRow])
+ g += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 1 + y * bytesPerRow])
+ b += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 2 + y * bytesPerRow])
+ }
+ }
+
+ let scale = 1 / Float(width * height)
+
+ return (r * scale, g * scale, b * scale)
+ }
+}
+
+private func encodeDC(_ value: (Float, Float, Float)) -> Int {
+ let roundedR = linearTosRGB(value.0)
+ let roundedG = linearTosRGB(value.1)
+ let roundedB = linearTosRGB(value.2)
+ return (roundedR << 16) + (roundedG << 8) + roundedB
+}
+
+private func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int {
+ let quantR = Int(max(0, min(18, floor(signPow(value.0 / maximumValue, 0.5) * 9 + 9.5))))
+ let quantG = Int(max(0, min(18, floor(signPow(value.1 / maximumValue, 0.5) * 9 + 9.5))))
+ let quantB = Int(max(0, min(18, floor(signPow(value.2 / maximumValue, 0.5) * 9 + 9.5))))
+
+ return quantR * 19 * 19 + quantG * 19 + quantB
+}
+
+private func signPow(_ value: Float, _ exp: Float) -> Float {
+ return copysign(pow(abs(value), exp), value)
+}
+
+private func linearTosRGB(_ value: Float) -> Int {
+ let v = max(0, min(1, value))
+ if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) }
+ else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
+}
+
+private func sRGBToLinear(_ value: Type) -> Float {
+ let v = Float(Int64(value)) / 255
+ if v <= 0.04045 { return v / 12.92 }
+ else { return pow((v + 0.055) / 1.055, 2.4) }
+}
+
+private let encodeCharacters: [String] = {
+ return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
+}()
+
+extension BinaryInteger {
+ func encode83(length: Int) -> String {
+ var result = ""
+ for i in 1 ... length {
+ let digit = (Int(self) / pow(83, length - i)) % 83
+ result += encodeCharacters[Int(digit)]
+ }
+ return result
+ }
+}
+
+private func pow(_ base: Int, _ exponent: Int) -> Int {
+ return (0 ..< exponent).reduce(1) { value, _ in value * base }
+}