Merge branch 'develop' into feature/report
# Conflicts: # CoreDataStack/Entity/Status.swift
This commit is contained in:
commit
23d9853a71
|
@ -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
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
sudo gem install cocoapods-keys
|
||||||
|
pod install
|
|
@ -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
|
|
@ -194,6 +194,7 @@
|
||||||
<attribute name="language" optional="YES" attributeType="String"/>
|
<attribute name="language" optional="YES" attributeType="String"/>
|
||||||
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="repliesCount" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
|
<attribute name="repliesCount" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="revealedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
<attribute name="spoilerText" optional="YES" attributeType="String"/>
|
<attribute name="spoilerText" optional="YES" attributeType="String"/>
|
||||||
<attribute name="text" optional="YES" attributeType="String"/>
|
<attribute name="text" optional="YES" attributeType="String"/>
|
||||||
|
@ -263,9 +264,9 @@
|
||||||
<element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/>
|
<element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/>
|
||||||
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="104"/>
|
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="104"/>
|
||||||
<element name="Setting" positionX="72" positionY="162" width="128" height="149"/>
|
<element name="Setting" positionX="72" positionY="162" width="128" height="149"/>
|
||||||
<element name="Status" positionX="0" positionY="0" width="128" height="569"/>
|
<element name="Status" positionX="0" positionY="0" width="128" height="584"/>
|
||||||
<element name="Subscription" positionX="81" positionY="171" width="128" height="149"/>
|
<element name="Subscription" positionX="81" positionY="171" width="128" height="149"/>
|
||||||
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="149"/>
|
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="149"/>
|
||||||
<element name="Tag" positionX="0" positionY="0" width="128" height="134"/>
|
<element name="Tag" positionX="0" positionY="0" width="128" height="134"/>
|
||||||
</elements>
|
</elements>
|
||||||
</model>
|
</model>
|
||||||
|
|
|
@ -52,7 +52,7 @@ extension HomeTimelineIndex {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// internal method for Toot call
|
// internal method for status call
|
||||||
func softDelete() {
|
func softDelete() {
|
||||||
deletedAt = Date()
|
deletedAt = Date()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// Toot.swift
|
// Status.swift
|
||||||
// CoreDataStack
|
// CoreDataStack
|
||||||
//
|
//
|
||||||
// Created by MainasuK Cirno on 2021/1/27.
|
// 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 updatedAt: Date
|
||||||
@NSManaged public private(set) var deletedAt: Date?
|
@NSManaged public private(set) var deletedAt: Date?
|
||||||
|
@NSManaged public private(set) var revealedAt: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Status {
|
extension Status {
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func insert(
|
public static func insert(
|
||||||
into context: NSManagedObjectContext,
|
into context: NSManagedObjectContext,
|
||||||
property: Property,
|
property: Property,
|
||||||
author: MastodonUser,
|
author: MastodonUser,
|
||||||
|
@ -84,81 +86,81 @@ public extension Status {
|
||||||
bookmarkedBy: MastodonUser?,
|
bookmarkedBy: MastodonUser?,
|
||||||
pinnedBy: MastodonUser?
|
pinnedBy: MastodonUser?
|
||||||
) -> Status {
|
) -> Status {
|
||||||
let toot: Status = context.insertObject()
|
let status: Status = context.insertObject()
|
||||||
|
|
||||||
toot.identifier = property.identifier
|
status.identifier = property.identifier
|
||||||
toot.domain = property.domain
|
status.domain = property.domain
|
||||||
|
|
||||||
toot.id = property.id
|
status.id = property.id
|
||||||
toot.uri = property.uri
|
status.uri = property.uri
|
||||||
toot.createdAt = property.createdAt
|
status.createdAt = property.createdAt
|
||||||
toot.content = property.content
|
status.content = property.content
|
||||||
|
|
||||||
toot.visibility = property.visibility
|
status.visibility = property.visibility
|
||||||
toot.sensitive = property.sensitive
|
status.sensitive = property.sensitive
|
||||||
toot.spoilerText = property.spoilerText
|
status.spoilerText = property.spoilerText
|
||||||
toot.application = application
|
status.application = application
|
||||||
|
|
||||||
toot.reblogsCount = property.reblogsCount
|
status.reblogsCount = property.reblogsCount
|
||||||
toot.favouritesCount = property.favouritesCount
|
status.favouritesCount = property.favouritesCount
|
||||||
toot.repliesCount = property.repliesCount
|
status.repliesCount = property.repliesCount
|
||||||
|
|
||||||
toot.url = property.url
|
status.url = property.url
|
||||||
toot.inReplyToID = property.inReplyToID
|
status.inReplyToID = property.inReplyToID
|
||||||
toot.inReplyToAccountID = property.inReplyToAccountID
|
status.inReplyToAccountID = property.inReplyToAccountID
|
||||||
|
|
||||||
toot.language = property.language
|
status.language = property.language
|
||||||
toot.text = property.text
|
status.text = property.text
|
||||||
|
|
||||||
toot.author = author
|
status.author = author
|
||||||
toot.reblog = reblog
|
status.reblog = reblog
|
||||||
|
|
||||||
toot.pinnedBy = pinnedBy
|
status.pinnedBy = pinnedBy
|
||||||
toot.poll = poll
|
status.poll = poll
|
||||||
|
|
||||||
if let mentions = mentions {
|
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 {
|
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 {
|
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 {
|
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 {
|
if let favouritedBy = favouritedBy {
|
||||||
toot.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(favouritedBy)
|
status.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(favouritedBy)
|
||||||
}
|
}
|
||||||
if let rebloggedBy = rebloggedBy {
|
if let rebloggedBy = rebloggedBy {
|
||||||
toot.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(rebloggedBy)
|
status.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(rebloggedBy)
|
||||||
}
|
}
|
||||||
if let mutedBy = mutedBy {
|
if let mutedBy = mutedBy {
|
||||||
toot.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mutedBy)
|
status.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mutedBy)
|
||||||
}
|
}
|
||||||
if let bookmarkedBy = bookmarkedBy {
|
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 {
|
if self.reblogsCount.intValue != reblogsCount.intValue {
|
||||||
self.reblogsCount = reblogsCount
|
self.reblogsCount = reblogsCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(favouritesCount: NSNumber) {
|
public func update(favouritesCount: NSNumber) {
|
||||||
if self.favouritesCount.intValue != favouritesCount.intValue {
|
if self.favouritesCount.intValue != favouritesCount.intValue {
|
||||||
self.favouritesCount = favouritesCount
|
self.favouritesCount = favouritesCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(repliesCount: NSNumber?) {
|
public func update(repliesCount: NSNumber?) {
|
||||||
guard let count = repliesCount else {
|
guard let count = repliesCount else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -167,13 +169,13 @@ public extension Status {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(replyTo: Status?) {
|
public func update(replyTo: Status?) {
|
||||||
if self.replyTo != replyTo {
|
if self.replyTo != replyTo {
|
||||||
self.replyTo = replyTo
|
self.replyTo = replyTo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(liked: Bool, by mastodonUser: MastodonUser) {
|
public func update(liked: Bool, by mastodonUser: MastodonUser) {
|
||||||
if liked {
|
if liked {
|
||||||
if !(self.favouritedBy ?? Set()).contains(mastodonUser) {
|
if !(self.favouritedBy ?? Set()).contains(mastodonUser) {
|
||||||
self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(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 reblogged {
|
||||||
if !(self.rebloggedBy ?? Set()).contains(mastodonUser) {
|
if !(self.rebloggedBy ?? Set()).contains(mastodonUser) {
|
||||||
self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(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 muted {
|
||||||
if !(self.mutedBy ?? Set()).contains(mastodonUser) {
|
if !(self.mutedBy ?? Set()).contains(mastodonUser) {
|
||||||
self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(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 bookmarked {
|
||||||
if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) {
|
if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) {
|
||||||
self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(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
|
self.updatedAt = networkDate
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Status {
|
extension Status {
|
||||||
struct Property {
|
public struct Property {
|
||||||
|
|
||||||
public let identifier: ID
|
public let identifier: ID
|
||||||
public let domain: String
|
public let domain: String
|
||||||
|
@ -338,7 +344,4 @@ extension Status {
|
||||||
return NSPredicate(format: "%K != nil", #keyPath(Status.deletedAt))
|
return NSPredicate(format: "%K != nil", #keyPath(Status.deletedAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func author(author: MastodonUser) -> NSPredicate {
|
|
||||||
return NSPredicate(format: "%K == %@", #keyPath(Status.author), author)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,7 +58,8 @@
|
||||||
"user_reblogged": "%s reblogged",
|
"user_reblogged": "%s reblogged",
|
||||||
"user_replied_to": "Replied to %s",
|
"user_replied_to": "Replied to %s",
|
||||||
"show_post": "Show Post",
|
"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",
|
"media_content_warning": "Tap to reveal that may be sensitive",
|
||||||
"poll": {
|
"poll": {
|
||||||
"vote": "Vote",
|
"vote": "Vote",
|
||||||
|
|
|
@ -238,6 +238,8 @@
|
||||||
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; };
|
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; };
|
||||||
DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; };
|
DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; };
|
||||||
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; };
|
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 */; };
|
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 */; };
|
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; };
|
||||||
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.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 = "<group>"; };
|
DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = "<group>"; };
|
||||||
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = "<group>"; };
|
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = "<group>"; };
|
||||||
DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = "<group>"; };
|
DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = "<group>"; };
|
||||||
|
DB51D170262832380062B7A1 /* BlurHashDecode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
|
||||||
|
DB51D171262832380062B7A1 /* BlurHashEncode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = "<group>"; };
|
||||||
DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TwitterTextEditor+String.swift"; sourceTree = "<group>"; };
|
DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TwitterTextEditor+String.swift"; sourceTree = "<group>"; };
|
||||||
DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = "<group>"; };
|
DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = "<group>"; };
|
||||||
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = "<group>"; };
|
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1048,6 +1052,8 @@
|
||||||
DB2B3AE825E38850007045F9 /* UIViewPreview.swift */,
|
DB2B3AE825E38850007045F9 /* UIViewPreview.swift */,
|
||||||
DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */,
|
DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */,
|
||||||
DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */,
|
DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */,
|
||||||
|
DB51D170262832380062B7A1 /* BlurHashDecode.swift */,
|
||||||
|
DB51D171262832380062B7A1 /* BlurHashEncode.swift */,
|
||||||
);
|
);
|
||||||
path = Vender;
|
path = Vender;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -2618,6 +2624,7 @@
|
||||||
0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */,
|
0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */,
|
||||||
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */,
|
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */,
|
||||||
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
|
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
|
||||||
|
DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */,
|
||||||
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
|
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
|
||||||
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
|
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
|
||||||
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
|
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
|
||||||
|
@ -2643,6 +2650,7 @@
|
||||||
5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */,
|
5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */,
|
||||||
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
|
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
|
||||||
2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */,
|
2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */,
|
||||||
|
DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */,
|
||||||
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,
|
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,
|
||||||
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
|
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
|
||||||
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */,
|
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */,
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>20</integer>
|
<integer>13</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -5,6 +5,7 @@
|
||||||
// Created by sxiaojian on 2021/1/27.
|
// Created by sxiaojian on 2021/1/27.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
import CoreData
|
import CoreData
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
import Foundation
|
import Foundation
|
||||||
|
@ -33,59 +34,18 @@ enum Item {
|
||||||
case emptyStateHeader(attribute: EmptyStateHeaderAttribute)
|
case emptyStateHeader(attribute: EmptyStateHeaderAttribute)
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol StatusContentWarningAttribute {
|
|
||||||
var isStatusTextSensitive: Bool? { get set }
|
|
||||||
var isStatusSensitive: Bool? { get set }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Item {
|
extension Item {
|
||||||
class StatusAttribute: StatusContentWarningAttribute {
|
class StatusAttribute {
|
||||||
var isStatusTextSensitive: Bool?
|
|
||||||
var isStatusSensitive: Bool?
|
|
||||||
var isSeparatorLineHidden: Bool
|
var isSeparatorLineHidden: Bool
|
||||||
|
|
||||||
|
let isImageLoaded = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
let isRevealing = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
|
||||||
init(
|
init(isSeparatorLineHidden: Bool = false) {
|
||||||
isStatusTextSensitive: Bool? = nil,
|
|
||||||
isStatusSensitive: Bool? = nil,
|
|
||||||
isSeparatorLineHidden: Bool = false
|
|
||||||
) {
|
|
||||||
self.isStatusTextSensitive = isStatusTextSensitive
|
|
||||||
self.isStatusSensitive = isStatusSensitive
|
|
||||||
self.isSeparatorLineHidden = isSeparatorLineHidden
|
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 {
|
class EmptyStateHeaderAttribute: Hashable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
let reason: Reason
|
let reason: Reason
|
||||||
|
|
|
@ -9,7 +9,7 @@ import CoreData
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum NotificationItem {
|
enum NotificationItem {
|
||||||
case notification(objectID: NSManagedObjectID)
|
case notification(objectID: NSManagedObjectID, attribute: Item.StatusAttribute)
|
||||||
|
|
||||||
case bottomLoader
|
case bottomLoader
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,7 @@ enum NotificationItem {
|
||||||
extension NotificationItem: Equatable {
|
extension NotificationItem: Equatable {
|
||||||
static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool {
|
static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool {
|
||||||
switch (lhs, rhs) {
|
switch (lhs, rhs) {
|
||||||
case (.notification(let idLeft), .notification(let idRight)):
|
case (.notification(let idLeft, _), .notification(let idRight, _)):
|
||||||
return idLeft == idRight
|
return idLeft == idRight
|
||||||
case (.bottomLoader, .bottomLoader):
|
case (.bottomLoader, .bottomLoader):
|
||||||
return true
|
return true
|
||||||
|
@ -30,7 +30,7 @@ extension NotificationItem: Equatable {
|
||||||
extension NotificationItem: Hashable {
|
extension NotificationItem: Hashable {
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
switch self {
|
switch self {
|
||||||
case .notification(let id):
|
case .notification(let id, _):
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
case .bottomLoader:
|
case .bottomLoader:
|
||||||
hasher.combine(String(describing: NotificationItem.bottomLoader.self))
|
hasher.combine(String(describing: NotificationItem.bottomLoader.self))
|
||||||
|
|
|
@ -77,14 +77,15 @@ extension ComposeStatusSection {
|
||||||
return cell
|
return cell
|
||||||
case .input(let replyToStatusObjectID, let attribute):
|
case .input(let replyToStatusObjectID, let attribute):
|
||||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell
|
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 ?? ""
|
cell.textEditorView.text = attribute.composeContent.value ?? ""
|
||||||
managedObjectContext.perform {
|
managedObjectContext.perform {
|
||||||
guard let replyToStatusObjectID = replyToStatusObjectID,
|
guard let replyToStatusObjectID = replyToStatusObjectID,
|
||||||
let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
|
let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
|
||||||
cell.statusView.headerContainerStackView.isHidden = true
|
cell.statusView.headerContainerView.isHidden = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cell.statusView.headerContainerStackView.isHidden = false
|
cell.statusView.headerContainerView.isHidden = false
|
||||||
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
|
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
|
||||||
cell.statusView.headerInfoLabel.text = L10n.Scene.Compose.replyingToUser(replyTo.author.displayNameWithFallback)
|
cell.statusView.headerInfoLabel.text = L10n.Scene.Compose.replyingToUser(replyTo.author.displayNameWithFallback)
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,15 +22,14 @@ extension NotificationSection {
|
||||||
timestampUpdatePublisher: AnyPublisher<Date, Never>,
|
timestampUpdatePublisher: AnyPublisher<Date, Never>,
|
||||||
managedObjectContext: NSManagedObjectContext,
|
managedObjectContext: NSManagedObjectContext,
|
||||||
delegate: NotificationTableViewCellDelegate,
|
delegate: NotificationTableViewCellDelegate,
|
||||||
dependency: NeedsDependency,
|
dependency: NeedsDependency
|
||||||
requestUserID: String
|
|
||||||
) -> UITableViewDiffableDataSource<NotificationSection, NotificationItem> {
|
) -> UITableViewDiffableDataSource<NotificationSection, NotificationItem> {
|
||||||
UITableViewDiffableDataSource(tableView: tableView) {
|
UITableViewDiffableDataSource(tableView: tableView) {
|
||||||
[weak delegate, weak dependency]
|
[weak delegate, weak dependency]
|
||||||
(tableView, indexPath, notificationItem) -> UITableViewCell? in
|
(tableView, indexPath, notificationItem) -> UITableViewCell? in
|
||||||
guard let dependency = dependency else { return nil }
|
guard let dependency = dependency else { return nil }
|
||||||
switch notificationItem {
|
switch notificationItem {
|
||||||
case .notification(let objectID):
|
case .notification(let objectID, let attribute):
|
||||||
|
|
||||||
let notification = managedObjectContext.object(with: objectID) as! MastodonNotification
|
let notification = managedObjectContext.object(with: objectID) as! MastodonNotification
|
||||||
guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.typeRaw) else {
|
guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.typeRaw) else {
|
||||||
|
@ -46,14 +45,18 @@ extension NotificationSection {
|
||||||
if let status = notification.status {
|
if let status = notification.status {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell
|
||||||
cell.delegate = delegate
|
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)
|
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,
|
StatusSection.configure(
|
||||||
dependency: dependency,
|
cell: cell,
|
||||||
readableLayoutFrame: frame,
|
dependency: dependency,
|
||||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
readableLayoutFrame: frame,
|
||||||
status: status,
|
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||||
requestUserID: requestUserID,
|
status: status,
|
||||||
statusItemAttribute: Item.StatusAttribute(isStatusTextSensitive: false, isStatusSensitive: false))
|
requestUserID: requestUserID,
|
||||||
|
statusItemAttribute: attribute
|
||||||
|
)
|
||||||
timestampUpdatePublisher
|
timestampUpdatePublisher
|
||||||
.sink { _ in
|
.sink { _ in
|
||||||
let timeText = notification.createAt.shortTimeAgoSinceNow
|
let timeText = notification.createAt.shortTimeAgoSinceNow
|
||||||
|
|
|
@ -140,10 +140,7 @@ extension StatusSection {
|
||||||
status: Status,
|
status: Status,
|
||||||
requestUserID: String,
|
requestUserID: String,
|
||||||
statusItemAttribute: Item.StatusAttribute
|
statusItemAttribute: Item.StatusAttribute
|
||||||
) {
|
) {
|
||||||
// setup attribute
|
|
||||||
statusItemAttribute.setupForStatus(status: status.reblog ?? status)
|
|
||||||
|
|
||||||
// set header
|
// set header
|
||||||
StatusSection.configureHeader(cell: cell, status: status)
|
StatusSection.configureHeader(cell: cell, status: status)
|
||||||
ManagedObjectObserver.observe(object: status)
|
ManagedObjectObserver.observe(object: status)
|
||||||
|
@ -178,19 +175,6 @@ extension StatusSection {
|
||||||
// set text
|
// set text
|
||||||
cell.statusView.activeTextLabel.configure(content: (status.reblog ?? status).content)
|
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
|
// prepare media attachments
|
||||||
let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending }
|
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)
|
return CGSize(width: maxWidth, height: maxWidth * scale)
|
||||||
}()
|
}()
|
||||||
if mosiacImageViewModel.metas.count == 1 {
|
let blurhashImageCache = dependency.context.documentStore.blurhashImageCache
|
||||||
let meta = mosiacImageViewModel.metas[0]
|
let mosaics: [MosaicImageViewContainer.ConfigurableMosaic] = {
|
||||||
let imageView = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize)
|
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(
|
imageView.af.setImage(
|
||||||
withURL: meta.url,
|
withURL: meta.url,
|
||||||
placeholderImage: UIImage.placeholder(color: .systemFill),
|
placeholderImage: UIImage.placeholder(color: .systemFill),
|
||||||
imageTransition: .crossDissolve(0.2)
|
imageTransition: .crossDissolve(0.2)
|
||||||
)
|
) { response in
|
||||||
} else {
|
switch response.result {
|
||||||
let imageViews = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height)
|
case .success:
|
||||||
for (i, imageView) in imageViews.enumerated() {
|
statusItemAttribute.isImageLoaded.value = true
|
||||||
let meta = mosiacImageViewModel.metas[i]
|
case .failure:
|
||||||
imageView.af.setImage(
|
break
|
||||||
withURL: meta.url,
|
}
|
||||||
placeholderImage: UIImage.placeholder(color: .systemFill),
|
|
||||||
imageTransition: .crossDissolve(0.2)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
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
|
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
|
// set audio
|
||||||
if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first {
|
if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first {
|
||||||
|
@ -259,10 +286,6 @@ extension StatusSection {
|
||||||
return CGSize(width: maxWidth, height: maxWidth * scale)
|
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,
|
if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first,
|
||||||
let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment)
|
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?.pause()
|
||||||
cell.statusView.playerContainerView.playerViewController.player = nil
|
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
|
// set poll
|
||||||
let poll = (status.reblog ?? status).poll
|
let poll = (status.reblog ?? status).poll
|
||||||
StatusSection.configurePoll(
|
StatusSection.configurePoll(
|
||||||
|
@ -373,6 +424,88 @@ extension StatusSection {
|
||||||
.store(in: &cell.disposeBag)
|
.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(
|
static func configureThreadMeta(
|
||||||
cell: StatusTableViewCell,
|
cell: StatusTableViewCell,
|
||||||
status: Status
|
status: Status
|
||||||
|
@ -413,7 +546,7 @@ extension StatusSection {
|
||||||
status: Status
|
status: Status
|
||||||
) {
|
) {
|
||||||
if status.reblog != nil {
|
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.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage)
|
||||||
cell.statusView.headerInfoLabel.text = {
|
cell.statusView.headerInfoLabel.text = {
|
||||||
let author = status.author
|
let author = status.author
|
||||||
|
@ -421,7 +554,7 @@ extension StatusSection {
|
||||||
return L10n.Common.Controls.Status.userReblogged(name)
|
return L10n.Common.Controls.Status.userReblogged(name)
|
||||||
}()
|
}()
|
||||||
} else if status.inReplyToID != nil {
|
} 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.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
|
||||||
cell.statusView.headerInfoLabel.text = {
|
cell.statusView.headerInfoLabel.text = {
|
||||||
guard let replyTo = status.replyTo else {
|
guard let replyTo = status.replyTo else {
|
||||||
|
@ -432,7 +565,7 @@ extension StatusSection {
|
||||||
return L10n.Common.Controls.Status.userRepliedTo(name)
|
return L10n.Common.Controls.Status.userRepliedTo(name)
|
||||||
}()
|
}()
|
||||||
} else {
|
} else {
|
||||||
cell.statusView.headerContainerStackView.isHidden = true
|
cell.statusView.headerContainerView.isHidden = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -12,4 +12,9 @@ extension NSLayoutConstraint {
|
||||||
self.priority = priority
|
self.priority = priority
|
||||||
return self
|
return self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func identifier(_ identifier: String?) -> Self {
|
||||||
|
self.identifier = identifier
|
||||||
|
return self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -148,12 +148,16 @@ internal enum L10n {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
internal enum Status {
|
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
|
/// Tap to reveal that may be sensitive
|
||||||
internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning")
|
internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning")
|
||||||
/// Show Post
|
/// Show Post
|
||||||
internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost")
|
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
|
/// %@ reblogged
|
||||||
internal static func userReblogged(_ p1: Any) -> String {
|
internal static func userReblogged(_ p1: Any) -> String {
|
||||||
return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1))
|
return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1))
|
||||||
|
|
|
@ -28,6 +28,14 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
|
||||||
StatusProviderFacade.responseToStatusActiveLabelAction(provider: self, cell: cell, activeLabel: activeLabel, didTapEntity: entity)
|
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
|
// MARK: - ActionToolbarContainerDelegate
|
||||||
|
@ -45,25 +53,6 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
|
||||||
StatusProviderFacade.responseToStatusLikeAction(provider: self, cell: cell)
|
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
|
// MARK: - MosciaImageViewContainerDelegate
|
||||||
|
@ -74,37 +63,15 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
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) {
|
func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
||||||
contentWarningOverlayView.isUserInteractionEnabled = false
|
StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell)
|
||||||
statusTableViewCell(cell, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusTableViewCell(_ cell: StatusTableViewCell, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
func statusTableViewCell(_ cell: StatusTableViewCell, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
||||||
guard let diffableDataSource = self.tableViewDiffableDataSource else { return }
|
StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -415,6 +415,91 @@ extension StatusProviderFacade {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension StatusProviderFacade {
|
||||||
|
|
||||||
|
static func responseToStatusContentWarningRevealAction(dependency: NotificationViewController, cell: UITableViewCell) {
|
||||||
|
let status = Future<Status?, Never> { 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?, Never>) {
|
||||||
|
status
|
||||||
|
.compactMap { [weak dependency] status -> AnyPublisher<Status?, Never>? 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 {
|
extension StatusProviderFacade {
|
||||||
enum Target {
|
enum Target {
|
||||||
case primary // original status
|
case primary // original status
|
||||||
|
|
|
@ -50,6 +50,8 @@ Please check your internet connection.";
|
||||||
"Common.Controls.Firendship.UnblockUser" = "Unblock %@";
|
"Common.Controls.Firendship.UnblockUser" = "Unblock %@";
|
||||||
"Common.Controls.Firendship.Unmute" = "Unmute";
|
"Common.Controls.Firendship.Unmute" = "Unmute";
|
||||||
"Common.Controls.Firendship.UnmuteUser" = "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.MediaContentWarning" = "Tap to reveal that may be sensitive";
|
||||||
"Common.Controls.Status.Poll.Closed" = "Closed";
|
"Common.Controls.Status.Poll.Closed" = "Closed";
|
||||||
"Common.Controls.Status.Poll.TimeLeft" = "%@ left";
|
"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.Multiple" = "%d voters";
|
||||||
"Common.Controls.Status.Poll.VoterCount.Single" = "%d voter";
|
"Common.Controls.Status.Poll.VoterCount.Single" = "%d voter";
|
||||||
"Common.Controls.Status.ShowPost" = "Show Post";
|
"Common.Controls.Status.ShowPost" = "Show Post";
|
||||||
"Common.Controls.Status.StatusContentWarning" = "content warning";
|
|
||||||
"Common.Controls.Status.UserReblogged" = "%@ reblogged";
|
"Common.Controls.Status.UserReblogged" = "%@ reblogged";
|
||||||
"Common.Controls.Status.UserRepliedTo" = "Replied to %@";
|
"Common.Controls.Status.UserRepliedTo" = "Replied to %@";
|
||||||
"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view Artbot’s profile
|
"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view Artbot’s profile
|
||||||
|
|
|
@ -19,8 +19,7 @@ final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCel
|
||||||
override func prepareForReuse() {
|
override func prepareForReuse() {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
|
|
||||||
statusView.isStatusTextSensitive = false
|
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
|
||||||
statusView.cleanUpContentWarning()
|
|
||||||
disposeBag.removeAll()
|
disposeBag.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,18 +44,18 @@ extension ComposeRepliedToStatusContentCollectionViewCell {
|
||||||
|
|
||||||
private func _init() {
|
private func _init() {
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.systemBackground.color
|
|
||||||
|
|
||||||
|
statusView.actionToolbarContainer.isHidden = true
|
||||||
|
statusView.revealContentWarningButton.isHidden = true
|
||||||
|
|
||||||
statusView.translatesAutoresizingMaskIntoConstraints = false
|
statusView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
contentView.addSubview(statusView)
|
contentView.addSubview(statusView)
|
||||||
NSLayoutConstraint.activate([
|
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),
|
statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
|
||||||
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor),
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -90,6 +90,7 @@ extension ComposeStatusContentCollectionViewCell {
|
||||||
textEditorView.changeObserver = self
|
textEditorView.changeObserver = self
|
||||||
|
|
||||||
statusContentWarningEditorView.containerView.isHidden = true
|
statusContentWarningEditorView.containerView.isHidden = true
|
||||||
|
statusView.revealContentWarningButton.isHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -182,7 +182,7 @@ extension ComposeViewController {
|
||||||
)
|
)
|
||||||
|
|
||||||
// respond scrollView overlap change
|
// respond scrollView overlap change
|
||||||
view.layoutIfNeeded()
|
//view.layoutIfNeeded()
|
||||||
// update layout when keyboard show/dismiss
|
// update layout when keyboard show/dismiss
|
||||||
Publishers.CombineLatest4(
|
Publishers.CombineLatest4(
|
||||||
KeyboardResponderService.shared.isShow.eraseToAnyPublisher(),
|
KeyboardResponderService.shared.isShow.eraseToAnyPublisher(),
|
||||||
|
@ -210,7 +210,9 @@ extension ComposeViewController {
|
||||||
self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
|
self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
|
||||||
UIView.animate(withDuration: 0.3) {
|
UIView.animate(withDuration: 0.3) {
|
||||||
self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom
|
self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom
|
||||||
self.view.layoutIfNeeded()
|
if self.view.window != nil {
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.updateKeyboardBackground(isKeyboardDisplay: isShow)
|
self.updateKeyboardBackground(isKeyboardDisplay: isShow)
|
||||||
return
|
return
|
||||||
|
|
|
@ -112,6 +112,10 @@ final class ComposeViewModel {
|
||||||
for acct in mentionAccts {
|
for acct in mentionAccts {
|
||||||
UITextChecker.learnWord(acct)
|
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 initialComposeContent = mentionAccts.joined(separator: " ")
|
||||||
let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " "
|
let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " "
|
||||||
|
|
|
@ -284,13 +284,13 @@ struct ComposeToolbarView_Previews: PreviewProvider {
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
UIViewPreview(width: 375) {
|
UIViewPreview(width: 375) {
|
||||||
let tootbarView = ComposeToolbarView()
|
let toolbarView = ComposeToolbarView()
|
||||||
tootbarView.translatesAutoresizingMaskIntoConstraints = false
|
toolbarView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
tootbarView.widthAnchor.constraint(equalToConstant: 375).priority(.defaultHigh),
|
toolbarView.widthAnchor.constraint(equalToConstant: 375).priority(.defaultHigh),
|
||||||
tootbarView.heightAnchor.constraint(equalToConstant: 64).priority(.defaultHigh),
|
toolbarView.heightAnchor.constraint(equalToConstant: 64).priority(.defaultHigh),
|
||||||
])
|
])
|
||||||
return tootbarView
|
return toolbarView
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 375, height: 100))
|
.previewLayout(.fixed(width: 375, height: 100))
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,6 +36,7 @@ final class NotificationViewController: UIViewController, NeedsDependency {
|
||||||
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
|
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
|
||||||
tableView.tableFooterView = UIView()
|
tableView.tableFooterView = UIView()
|
||||||
tableView.estimatedRowHeight = UITableView.automaticDimension
|
tableView.estimatedRowHeight = UITableView.automaticDimension
|
||||||
|
tableView.backgroundColor = .clear
|
||||||
return tableView
|
return tableView
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -45,13 +46,14 @@ final class NotificationViewController: UIViewController, NeedsDependency {
|
||||||
extension NotificationViewController {
|
extension NotificationViewController {
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
|
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
|
||||||
navigationItem.titleView = segmentControl
|
navigationItem.titleView = segmentControl
|
||||||
segmentControl.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .valueChanged)
|
segmentControl.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .valueChanged)
|
||||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
@ -65,6 +67,7 @@ extension NotificationViewController {
|
||||||
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
|
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
|
||||||
viewModel.setupDiffableDataSource(for: tableView, delegate: self, dependency: self)
|
viewModel.setupDiffableDataSource(for: tableView, delegate: self, dependency: self)
|
||||||
viewModel.viewDidLoad.send()
|
viewModel.viewDidLoad.send()
|
||||||
|
|
||||||
// bind refresh control
|
// bind refresh control
|
||||||
viewModel.isFetchingLatestNotification
|
viewModel.isFetchingLatestNotification
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
|
@ -83,6 +86,8 @@ extension NotificationViewController {
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
tableView.deselectRow(with: transitionCoordinator, animated: animated)
|
||||||
|
|
||||||
// needs trigger manually after onboarding dismiss
|
// needs trigger manually after onboarding dismiss
|
||||||
setNeedsStatusBarAppearanceUpdate()
|
setNeedsStatusBarAppearanceUpdate()
|
||||||
}
|
}
|
||||||
|
@ -159,11 +164,10 @@ extension NotificationViewController {
|
||||||
extension NotificationViewController: UITableViewDelegate {
|
extension NotificationViewController: UITableViewDelegate {
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
|
||||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
switch item {
|
switch item {
|
||||||
case .notification(let objectID):
|
case .notification(let objectID, _):
|
||||||
let notification = context.managedObjectContext.object(with: objectID) as! MastodonNotification
|
let notification = context.managedObjectContext.object(with: objectID) as! MastodonNotification
|
||||||
if let status = notification.status {
|
if let status = notification.status {
|
||||||
let viewModel = ThreadViewModel(context: context, optionalStatus: status)
|
let viewModel = ThreadViewModel(context: context, optionalStatus: status)
|
||||||
|
@ -199,6 +203,7 @@ extension NotificationViewController: ContentOffsetAdjustableTimelineViewControl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - NotificationTableViewCellDelegate
|
||||||
extension NotificationViewController: NotificationTableViewCellDelegate {
|
extension NotificationViewController: NotificationTableViewCellDelegate {
|
||||||
func userAvatarDidPressed(notification: MastodonNotification) {
|
func userAvatarDidPressed(notification: MastodonNotification) {
|
||||||
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account)
|
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account)
|
||||||
|
@ -210,6 +215,18 @@ extension NotificationViewController: NotificationTableViewCellDelegate {
|
||||||
func parent() -> UIViewController {
|
func parent() -> UIViewController {
|
||||||
self
|
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
|
// MARK: - UIScrollViewDelegate
|
||||||
|
|
|
@ -20,16 +20,13 @@ extension NotificationViewModel {
|
||||||
.autoconnect()
|
.autoconnect()
|
||||||
.share()
|
.share()
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
guard let userid = activeMastodonAuthenticationBox.value?.userID else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
diffableDataSource = NotificationSection.tableViewDiffableDataSource(
|
diffableDataSource = NotificationSection.tableViewDiffableDataSource(
|
||||||
for: tableView,
|
for: tableView,
|
||||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||||
managedObjectContext: context.managedObjectContext,
|
managedObjectContext: context.managedObjectContext,
|
||||||
delegate: delegate,
|
delegate: delegate,
|
||||||
dependency: dependency,
|
dependency: dependency
|
||||||
requestUserID: userid
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -67,9 +64,31 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate {
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let oldSnapshot = diffableDataSource.snapshot()
|
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<NotificationSection, NotificationItem>()
|
var newSnapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>()
|
||||||
newSnapshot.appendSections([.main])
|
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 {
|
if !notifications.isEmpty, self.noMoreNotification.value == false {
|
||||||
newSnapshot.appendItems([.bottomLoader], toSection: .main)
|
newSnapshot.appendItems([.bottomLoader], toSection: .main)
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import ActiveLabel
|
||||||
|
|
||||||
final class NotificationStatusTableViewCell: UITableViewCell, StatusCell {
|
final class NotificationStatusTableViewCell: UITableViewCell, StatusCell {
|
||||||
static let actionImageBorderWidth: CGFloat = 2
|
static let actionImageBorderWidth: CGFloat = 2
|
||||||
|
@ -78,8 +79,7 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell {
|
||||||
override func prepareForReuse() {
|
override func prepareForReuse() {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
avatatImageView.af.cancelImageRequest()
|
avatatImageView.af.cancelImageRequest()
|
||||||
statusView.isStatusTextSensitive = false
|
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
|
||||||
statusView.cleanUpContentWarning()
|
|
||||||
statusView.pollTableView.dataSource = nil
|
statusView.pollTableView.dataSource = nil
|
||||||
statusView.playerContainerView.reset()
|
statusView.playerContainerView.reset()
|
||||||
statusView.playerContainerView.isHidden = true
|
statusView.playerContainerView.isHidden = true
|
||||||
|
@ -99,6 +99,9 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell {
|
||||||
|
|
||||||
override func layoutSubviews() {
|
override func layoutSubviews() {
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
// precondition: app is active
|
||||||
|
guard UIApplication.shared.applicationState == .active else { return }
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.statusView.drawContentWarningImageView()
|
self.statusView.drawContentWarningImageView()
|
||||||
}
|
}
|
||||||
|
@ -107,6 +110,8 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell {
|
||||||
|
|
||||||
extension NotificationStatusTableViewCell {
|
extension NotificationStatusTableViewCell {
|
||||||
func configure() {
|
func configure() {
|
||||||
|
backgroundColor = Asset.Colors.Background.systemBackground.color
|
||||||
|
|
||||||
let containerStackView = UIStackView()
|
let containerStackView = UIStackView()
|
||||||
containerStackView.axis = .horizontal
|
containerStackView.axis = .horizontal
|
||||||
containerStackView.alignment = .top
|
containerStackView.alignment = .top
|
||||||
|
@ -154,7 +159,6 @@ extension NotificationStatusTableViewCell {
|
||||||
actionImageView.centerYAnchor.constraint(equalTo: actionImageBackground.centerYAnchor)
|
actionImageView.centerYAnchor.constraint(equalTo: actionImageBackground.centerYAnchor)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
let actionStackView = UIStackView()
|
let actionStackView = UIStackView()
|
||||||
actionStackView.axis = .horizontal
|
actionStackView.axis = .horizontal
|
||||||
actionStackView.distribution = .fill
|
actionStackView.distribution = .fill
|
||||||
|
@ -187,13 +191,12 @@ extension NotificationStatusTableViewCell {
|
||||||
statusBorder.trailingAnchor.constraint(equalTo: statusView.trailingAnchor, constant: 12),
|
statusBorder.trailingAnchor.constraint(equalTo: statusView.trailingAnchor, constant: 12),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
statusView.delegate = self
|
||||||
|
|
||||||
statusStackView.addArrangedSubview(statusBorder)
|
statusStackView.addArrangedSubview(statusBorder)
|
||||||
|
|
||||||
containerStackView.addArrangedSubview(statusStackView)
|
containerStackView.addArrangedSubview(statusStackView)
|
||||||
|
|
||||||
statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
|
|
||||||
statusView.isUserInteractionEnabled = false
|
|
||||||
// remove item don't display
|
// remove item don't display
|
||||||
statusView.actionToolbarContainer.removeFromStackView()
|
statusView.actionToolbarContainer.removeFromStackView()
|
||||||
// it affect stackView's height,need remove
|
// it affect stackView's height,need remove
|
||||||
|
@ -206,4 +209,54 @@ extension NotificationStatusTableViewCell {
|
||||||
statusBorder.layer.borderColor = Asset.Colors.Border.notification.color.cgColor
|
statusBorder.layer.borderColor = Asset.Colors.Border.notification.color.cgColor
|
||||||
actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,11 @@ protocol NotificationTableViewCellDelegate: AnyObject {
|
||||||
func parent() -> UIViewController
|
func parent() -> UIViewController
|
||||||
|
|
||||||
func userAvatarDidPressed(notification: MastodonNotification)
|
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 {
|
final class NotificationTableViewCell: UITableViewCell {
|
||||||
|
|
|
@ -64,7 +64,7 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
|
||||||
guard let spoilerText = targetStatus.spoilerText, !spoilerText.isEmpty else { return false }
|
guard let spoilerText = targetStatus.spoilerText, !spoilerText.isEmpty else { return false }
|
||||||
return true
|
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))
|
items.append(Item.status(objectID: status.objectID, attribute: attribute))
|
||||||
if statusIDsWhichHasGap.contains(status.id) {
|
if statusIDsWhichHasGap.contains(status.id) {
|
||||||
items.append(Item.publicMiddleLoader(statusID: status.id))
|
items.append(Item.publicMiddleLoader(statusID: status.id))
|
||||||
|
|
|
@ -34,9 +34,11 @@ final class MosaicImageViewContainer: UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var blurhashOverlayImageViews: [UIImageView] = []
|
||||||
|
|
||||||
let contentWarningOverlayView: ContentWarningOverlayView = {
|
let contentWarningOverlayView: ContentWarningOverlayView = {
|
||||||
let contentWarningOverlayView = ContentWarningOverlayView()
|
let contentWarningOverlayView = ContentWarningOverlayView()
|
||||||
|
contentWarningOverlayView.configure(style: .visualEffectView)
|
||||||
return contentWarningOverlayView
|
return contentWarningOverlayView
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -96,11 +98,14 @@ extension MosaicImageViewContainer {
|
||||||
contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0
|
contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0
|
||||||
contentWarningOverlayView.isUserInteractionEnabled = true
|
contentWarningOverlayView.isUserInteractionEnabled = true
|
||||||
imageViews = []
|
imageViews = []
|
||||||
|
blurhashOverlayImageViews = []
|
||||||
|
|
||||||
container.spacing = 1
|
container.spacing = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupImageView(aspectRatio: CGSize, maxSize: CGSize) -> UIImageView {
|
typealias ConfigurableMosaic = (imageView: UIImageView, blurhashOverlayImageView: UIImageView)
|
||||||
|
|
||||||
|
func setupImageView(aspectRatio: CGSize, maxSize: CGSize) -> ConfigurableMosaic {
|
||||||
reset()
|
reset()
|
||||||
|
|
||||||
let contentView = UIView()
|
let contentView = UIView()
|
||||||
|
@ -130,6 +135,22 @@ extension MosaicImageViewContainer {
|
||||||
containerHeightLayoutConstraint.constant = floor(rect.height)
|
containerHeightLayoutConstraint.constant = floor(rect.height)
|
||||||
containerHeightLayoutConstraint.isActive = true
|
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)
|
addSubview(contentWarningOverlayView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
contentWarningOverlayView.topAnchor.constraint(equalTo: imageView.topAnchor),
|
contentWarningOverlayView.topAnchor.constraint(equalTo: imageView.topAnchor),
|
||||||
|
@ -137,11 +158,11 @@ extension MosaicImageViewContainer {
|
||||||
contentWarningOverlayView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
|
contentWarningOverlayView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
|
||||||
contentWarningOverlayView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
|
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()
|
reset()
|
||||||
guard count > 1 else {
|
guard count > 1 else {
|
||||||
return []
|
return []
|
||||||
|
@ -161,16 +182,25 @@ extension MosaicImageViewContainer {
|
||||||
container.addArrangedSubview(contentRightStackView)
|
container.addArrangedSubview(contentRightStackView)
|
||||||
|
|
||||||
var imageViews: [UIImageView] = []
|
var imageViews: [UIImageView] = []
|
||||||
|
var blurhashOverlayImageViews: [UIImageView] = []
|
||||||
for _ in 0..<count {
|
for _ in 0..<count {
|
||||||
imageViews.append(UIImageView())
|
imageViews.append(UIImageView())
|
||||||
|
blurhashOverlayImageViews.append(UIImageView())
|
||||||
}
|
}
|
||||||
self.imageViews.append(contentsOf: imageViews)
|
self.imageViews.append(contentsOf: imageViews)
|
||||||
|
self.blurhashOverlayImageViews.append(contentsOf: blurhashOverlayImageViews)
|
||||||
imageViews.forEach { imageView in
|
imageViews.forEach { imageView in
|
||||||
imageView.layer.masksToBounds = true
|
imageView.layer.masksToBounds = true
|
||||||
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
|
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
|
||||||
imageView.layer.cornerCurve = .continuous
|
imageView.layer.cornerCurve = .continuous
|
||||||
imageView.contentMode = .scaleAspectFill
|
imageView.contentMode = .scaleAspectFill
|
||||||
}
|
}
|
||||||
|
blurhashOverlayImageViews.forEach { imageView in
|
||||||
|
imageView.layer.masksToBounds = true
|
||||||
|
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
|
||||||
|
imageView.layer.cornerCurve = .continuous
|
||||||
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
}
|
||||||
if count == 2 {
|
if count == 2 {
|
||||||
contentLeftStackView.addArrangedSubview(imageViews[0])
|
contentLeftStackView.addArrangedSubview(imageViews[0])
|
||||||
contentRightStackView.addArrangedSubview(imageViews[1])
|
contentRightStackView.addArrangedSubview(imageViews[1])
|
||||||
|
@ -178,9 +208,16 @@ extension MosaicImageViewContainer {
|
||||||
case .rightToLeft:
|
case .rightToLeft:
|
||||||
imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
||||||
imageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
imageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
||||||
|
|
||||||
|
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
||||||
|
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
||||||
|
|
||||||
default:
|
default:
|
||||||
imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
||||||
imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
||||||
|
|
||||||
|
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
||||||
|
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if count == 3 {
|
} else if count == 3 {
|
||||||
|
@ -192,10 +229,18 @@ extension MosaicImageViewContainer {
|
||||||
imageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
imageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
||||||
imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
|
imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
|
||||||
imageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
|
imageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
|
||||||
|
|
||||||
|
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
||||||
|
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
|
||||||
|
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
|
||||||
default:
|
default:
|
||||||
imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
||||||
imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
|
imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
|
||||||
imageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
|
imageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
|
||||||
|
|
||||||
|
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
||||||
|
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
|
||||||
|
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
|
||||||
}
|
}
|
||||||
} else if count == 4 {
|
} else if count == 4 {
|
||||||
contentLeftStackView.addArrangedSubview(imageViews[0])
|
contentLeftStackView.addArrangedSubview(imageViews[0])
|
||||||
|
@ -208,14 +253,36 @@ extension MosaicImageViewContainer {
|
||||||
imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
|
imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
|
||||||
imageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
|
imageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
|
||||||
imageViews[3].layer.maskedCorners = [.layerMinXMaxYCorner]
|
imageViews[3].layer.maskedCorners = [.layerMinXMaxYCorner]
|
||||||
|
|
||||||
|
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner]
|
||||||
|
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
|
||||||
|
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
|
||||||
|
blurhashOverlayImageViews[3].layer.maskedCorners = [.layerMinXMaxYCorner]
|
||||||
default:
|
default:
|
||||||
imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner]
|
imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner]
|
||||||
imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
|
imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
|
||||||
imageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
|
imageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
|
||||||
imageViews[3].layer.maskedCorners = [.layerMaxXMaxYCorner]
|
imageViews[3].layer.maskedCorners = [.layerMaxXMaxYCorner]
|
||||||
|
|
||||||
|
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMinXMinYCorner]
|
||||||
|
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
|
||||||
|
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
|
||||||
|
blurhashOverlayImageViews[3].layer.maskedCorners = [.layerMaxXMaxYCorner]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (imageView, blurhashOverlayImageView) in zip(imageViews, blurhashOverlayImageViews) {
|
||||||
|
blurhashOverlayImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
container.addSubview(blurhashOverlayImageView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
blurhashOverlayImageView.topAnchor.constraint(equalTo: imageView.topAnchor),
|
||||||
|
blurhashOverlayImageView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
|
||||||
|
blurhashOverlayImageView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
|
||||||
|
blurhashOverlayImageView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
addSubview(contentWarningOverlayView)
|
addSubview(contentWarningOverlayView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
contentWarningOverlayView.topAnchor.constraint(equalTo: container.topAnchor),
|
contentWarningOverlayView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||||
|
@ -224,7 +291,7 @@ extension MosaicImageViewContainer {
|
||||||
contentWarningOverlayView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
contentWarningOverlayView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||||
])
|
])
|
||||||
|
|
||||||
return imageViews
|
return zip(imageViews, blurhashOverlayImageViews).map { ($0, $1) }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -260,7 +327,7 @@ struct MosaicImageView_Previews: PreviewProvider {
|
||||||
UIViewPreview(width: 375) {
|
UIViewPreview(width: 375) {
|
||||||
let view = MosaicImageViewContainer()
|
let view = MosaicImageViewContainer()
|
||||||
let image = images[3]
|
let image = images[3]
|
||||||
let imageView = view.setupImageView(
|
let (imageView, _) = view.setupImageView(
|
||||||
aspectRatio: image.size,
|
aspectRatio: image.size,
|
||||||
maxSize: CGSize(width: 375, height: 400)
|
maxSize: CGSize(width: 375, height: 400)
|
||||||
)
|
)
|
||||||
|
@ -272,7 +339,7 @@ struct MosaicImageView_Previews: PreviewProvider {
|
||||||
UIViewPreview(width: 375) {
|
UIViewPreview(width: 375) {
|
||||||
let view = MosaicImageViewContainer()
|
let view = MosaicImageViewContainer()
|
||||||
let image = images[1]
|
let image = images[1]
|
||||||
let imageView = view.setupImageView(
|
let (imageView, _) = view.setupImageView(
|
||||||
aspectRatio: image.size,
|
aspectRatio: image.size,
|
||||||
maxSize: CGSize(width: 375, height: 400)
|
maxSize: CGSize(width: 375, height: 400)
|
||||||
)
|
)
|
||||||
|
@ -287,8 +354,9 @@ struct MosaicImageView_Previews: PreviewProvider {
|
||||||
UIViewPreview(width: 375) {
|
UIViewPreview(width: 375) {
|
||||||
let view = MosaicImageViewContainer()
|
let view = MosaicImageViewContainer()
|
||||||
let images = self.images.prefix(2)
|
let images = self.images.prefix(2)
|
||||||
let imageViews = view.setupImageViews(count: images.count, maxHeight: 162)
|
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
|
||||||
for (i, imageView) in imageViews.enumerated() {
|
for (i, mosiac) in mosaics.enumerated() {
|
||||||
|
let (imageView, blurhashOverlayImageView) = mosiac
|
||||||
imageView.image = images[i]
|
imageView.image = images[i]
|
||||||
}
|
}
|
||||||
return view
|
return view
|
||||||
|
@ -298,8 +366,9 @@ struct MosaicImageView_Previews: PreviewProvider {
|
||||||
UIViewPreview(width: 375) {
|
UIViewPreview(width: 375) {
|
||||||
let view = MosaicImageViewContainer()
|
let view = MosaicImageViewContainer()
|
||||||
let images = self.images.prefix(3)
|
let images = self.images.prefix(3)
|
||||||
let imageViews = view.setupImageViews(count: images.count, maxHeight: 162)
|
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
|
||||||
for (i, imageView) in imageViews.enumerated() {
|
for (i, mosiac) in mosaics.enumerated() {
|
||||||
|
let (imageView, blurhashOverlayImageView) = mosiac
|
||||||
imageView.image = images[i]
|
imageView.image = images[i]
|
||||||
}
|
}
|
||||||
return view
|
return view
|
||||||
|
@ -309,8 +378,9 @@ struct MosaicImageView_Previews: PreviewProvider {
|
||||||
UIViewPreview(width: 375) {
|
UIViewPreview(width: 375) {
|
||||||
let view = MosaicImageViewContainer()
|
let view = MosaicImageViewContainer()
|
||||||
let images = self.images.prefix(4)
|
let images = self.images.prefix(4)
|
||||||
let imageViews = view.setupImageViews(count: images.count, maxHeight: 162)
|
let mosaics = view.setupImageViews(count: images.count, maxHeight: 162)
|
||||||
for (i, imageView) in imageViews.enumerated() {
|
for (i, mosiac) in mosaics.enumerated() {
|
||||||
|
let (imageView, blurhashOverlayImageView) = mosiac
|
||||||
imageView.image = images[i]
|
imageView.image = images[i]
|
||||||
}
|
}
|
||||||
return view
|
return view
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
// Created by xiaojian sun on 2021/3/10.
|
// Created by xiaojian sun on 2021/3/10.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
import AVKit
|
import AVKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
@ -21,6 +22,7 @@ final class PlayerContainerView: UIView {
|
||||||
|
|
||||||
let contentWarningOverlayView: ContentWarningOverlayView = {
|
let contentWarningOverlayView: ContentWarningOverlayView = {
|
||||||
let contentWarningOverlayView = ContentWarningOverlayView()
|
let contentWarningOverlayView = ContentWarningOverlayView()
|
||||||
|
contentWarningOverlayView.update(cornerRadius: PlayerContainerView.cornerRadius)
|
||||||
return contentWarningOverlayView
|
return contentWarningOverlayView
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -70,6 +72,7 @@ extension PlayerContainerView {
|
||||||
mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.required - 1),
|
mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.required - 1),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
addSubview(contentWarningOverlayView)
|
addSubview(contentWarningOverlayView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
contentWarningOverlayView.topAnchor.constraint(equalTo: topAnchor),
|
contentWarningOverlayView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
@ -93,6 +96,7 @@ extension PlayerContainerView {
|
||||||
// MARK: - ContentWarningOverlayViewDelegate
|
// MARK: - ContentWarningOverlayViewDelegate
|
||||||
extension PlayerContainerView: ContentWarningOverlayViewDelegate {
|
extension PlayerContainerView: ContentWarningOverlayViewDelegate {
|
||||||
func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) {
|
func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
delegate?.playerContainerView(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
|
delegate?.playerContainerView(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,14 +17,38 @@ class ContentWarningOverlayView: UIView {
|
||||||
|
|
||||||
static let cornerRadius: CGFloat = 4
|
static let cornerRadius: CGFloat = 4
|
||||||
static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial)
|
static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial)
|
||||||
|
|
||||||
let blurVisualEffectView = UIVisualEffectView(effect: ContentWarningOverlayView.blurVisualEffect)
|
let blurVisualEffectView = UIVisualEffectView(effect: ContentWarningOverlayView.blurVisualEffect)
|
||||||
let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: ContentWarningOverlayView.blurVisualEffect))
|
let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: ContentWarningOverlayView.blurVisualEffect))
|
||||||
|
let vibrancyContentWarningLabel: UILabel = {
|
||||||
let contentWarningLabel: UILabel = {
|
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15))
|
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15))
|
||||||
label.text = L10n.Common.Controls.Status.mediaContentWarning
|
label.text = L10n.Common.Controls.Status.mediaContentWarning
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
|
label.numberOfLines = 0
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
let blurContentImageView: UIImageView = {
|
||||||
|
let imageView = UIImageView()
|
||||||
|
imageView.layer.masksToBounds = false
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
let blurContentWarningTitleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17))
|
||||||
|
label.text = L10n.Common.Controls.Status.mediaContentWarning
|
||||||
|
label.textColor = Asset.Colors.Label.primary.color
|
||||||
|
label.textAlignment = .center
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
let blurContentWarningLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15))
|
||||||
|
label.text = L10n.Common.Controls.Status.mediaContentWarning
|
||||||
|
label.textColor = Asset.Colors.Label.secondary.color
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.layer.setupShadow()
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -46,8 +70,9 @@ class ContentWarningOverlayView: UIView {
|
||||||
extension ContentWarningOverlayView {
|
extension ContentWarningOverlayView {
|
||||||
private func _init() {
|
private func _init() {
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
translatesAutoresizingMaskIntoConstraints = false
|
isUserInteractionEnabled = true
|
||||||
|
|
||||||
|
// visual effect style
|
||||||
// add blur visual effect view in the setup method
|
// add blur visual effect view in the setup method
|
||||||
blurVisualEffectView.layer.masksToBounds = true
|
blurVisualEffectView.layer.masksToBounds = true
|
||||||
blurVisualEffectView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
|
blurVisualEffectView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
|
||||||
|
@ -62,12 +87,12 @@ extension ContentWarningOverlayView {
|
||||||
vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.bottomAnchor),
|
vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.bottomAnchor),
|
||||||
])
|
])
|
||||||
|
|
||||||
contentWarningLabel.translatesAutoresizingMaskIntoConstraints = false
|
vibrancyContentWarningLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
vibrancyVisualEffectView.contentView.addSubview(contentWarningLabel)
|
vibrancyVisualEffectView.contentView.addSubview(vibrancyContentWarningLabel)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
contentWarningLabel.leadingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.leadingAnchor),
|
vibrancyContentWarningLabel.leadingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.leadingAnchor),
|
||||||
contentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor),
|
vibrancyContentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor),
|
||||||
contentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor),
|
vibrancyContentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor),
|
||||||
])
|
])
|
||||||
|
|
||||||
blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
|
blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
@ -78,12 +103,90 @@ extension ContentWarningOverlayView {
|
||||||
blurVisualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
blurVisualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
blurVisualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
blurVisualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// blur image style
|
||||||
|
blurContentImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(blurContentImageView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
blurContentImageView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
blurContentImageView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
blurContentImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
blurContentImageView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
let blurContentWarningLabelContainer = UIStackView()
|
||||||
|
blurContentWarningLabelContainer.axis = .vertical
|
||||||
|
blurContentWarningLabelContainer.spacing = 4
|
||||||
|
blurContentWarningLabelContainer.alignment = .center
|
||||||
|
|
||||||
|
blurContentWarningLabelContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
blurContentImageView.addSubview(blurContentWarningLabelContainer)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
blurContentWarningLabelContainer.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
blurContentWarningLabelContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
blurContentWarningLabelContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
blurContentWarningLabelContainer.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
let topPaddingView = UIView()
|
||||||
|
let bottomPaddingView = UIView()
|
||||||
|
topPaddingView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
blurContentWarningLabelContainer.addArrangedSubview(topPaddingView)
|
||||||
|
blurContentWarningLabelContainer.addArrangedSubview(blurContentWarningTitleLabel)
|
||||||
|
blurContentWarningLabelContainer.addArrangedSubview(blurContentWarningLabel)
|
||||||
|
bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
blurContentWarningLabelContainer.addArrangedSubview(bottomPaddingView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
topPaddingView.heightAnchor.constraint(equalTo: bottomPaddingView.heightAnchor, multiplier: 1.0).priority(.defaultHigh),
|
||||||
|
])
|
||||||
|
blurContentWarningTitleLabel.setContentHuggingPriority(.defaultHigh + 2, for: .vertical)
|
||||||
|
blurContentWarningLabel.setContentHuggingPriority(.defaultHigh + 1, for: .vertical)
|
||||||
|
|
||||||
tapGestureRecognizer.addTarget(self, action: #selector(ContentWarningOverlayView.tapGestureRecognizerHandler(_:)))
|
tapGestureRecognizer.addTarget(self, action: #selector(ContentWarningOverlayView.tapGestureRecognizerHandler(_:)))
|
||||||
addGestureRecognizer(tapGestureRecognizer)
|
addGestureRecognizer(tapGestureRecognizer)
|
||||||
|
|
||||||
|
configure(style: .visualEffectView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ContentWarningOverlayView {
|
||||||
|
|
||||||
|
enum Style {
|
||||||
|
case visualEffectView
|
||||||
|
case blurContentImageView
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(style: Style) {
|
||||||
|
switch style {
|
||||||
|
case .visualEffectView:
|
||||||
|
blurVisualEffectView.isHidden = false
|
||||||
|
vibrancyVisualEffectView.isHidden = false
|
||||||
|
blurContentImageView.isHidden = true
|
||||||
|
case .blurContentImageView:
|
||||||
|
blurVisualEffectView.isHidden = true
|
||||||
|
vibrancyVisualEffectView.isHidden = true
|
||||||
|
blurContentImageView.isHidden = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(isRevealing: Bool, style: Style) {
|
||||||
|
switch style {
|
||||||
|
case .visualEffectView:
|
||||||
|
blurVisualEffectView.effect = isRevealing ? nil : ContentWarningOverlayView.blurVisualEffect
|
||||||
|
vibrancyVisualEffectView.alpha = isRevealing ? 0 : 1
|
||||||
|
isUserInteractionEnabled = !isRevealing
|
||||||
|
case .blurContentImageView:
|
||||||
|
assertionFailure("not handle here")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(cornerRadius: CGFloat) {
|
||||||
|
blurVisualEffectView.layer.cornerRadius = cornerRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
extension ContentWarningOverlayView {
|
extension ContentWarningOverlayView {
|
||||||
@objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
|
@objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
|
|
@ -14,7 +14,8 @@ import AlamofireImage
|
||||||
protocol StatusViewDelegate: class {
|
protocol StatusViewDelegate: class {
|
||||||
func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel)
|
func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel)
|
||||||
func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton)
|
func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton)
|
||||||
func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
|
func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton)
|
||||||
|
func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||||
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||||
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
|
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
|
||||||
func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
|
func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
|
||||||
|
@ -28,6 +29,16 @@ final class StatusView: UIView {
|
||||||
static let avatarImageCornerRadius: CGFloat = 4
|
static let avatarImageCornerRadius: CGFloat = 4
|
||||||
static let avatarToLabelSpacing: CGFloat = 5
|
static let avatarToLabelSpacing: CGFloat = 5
|
||||||
static let contentWarningBlurRadius: CGFloat = 12
|
static let contentWarningBlurRadius: CGFloat = 12
|
||||||
|
static let containerStackViewSpacing: CGFloat = 10
|
||||||
|
|
||||||
|
weak var delegate: StatusViewDelegate?
|
||||||
|
private var needsDrawContentOverlay = false
|
||||||
|
var pollTableViewDataSource: UITableViewDiffableDataSource<PollSection, PollItem>?
|
||||||
|
var pollTableViewHeightLaoutConstraint: NSLayoutConstraint!
|
||||||
|
|
||||||
|
let containerStackView = UIStackView()
|
||||||
|
let headerContainerView = UIView()
|
||||||
|
let authorContainerView = UIView()
|
||||||
|
|
||||||
static let reblogIconImage: UIImage = {
|
static let reblogIconImage: UIImage = {
|
||||||
let font = UIFont.systemFont(ofSize: 13, weight: .medium)
|
let font = UIFont.systemFont(ofSize: 13, weight: .medium)
|
||||||
|
@ -52,13 +63,6 @@ final class StatusView: UIView {
|
||||||
return attributedString
|
return attributedString
|
||||||
}
|
}
|
||||||
|
|
||||||
weak var delegate: StatusViewDelegate?
|
|
||||||
var isStatusTextSensitive = false
|
|
||||||
var pollTableViewDataSource: UITableViewDiffableDataSource<PollSection, PollItem>?
|
|
||||||
var pollTableViewHeightLaoutConstraint: NSLayoutConstraint!
|
|
||||||
|
|
||||||
let headerContainerStackView = UIStackView()
|
|
||||||
|
|
||||||
let headerIconLabel: UILabel = {
|
let headerIconLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage)
|
label.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage)
|
||||||
|
@ -115,25 +119,14 @@ final class StatusView: UIView {
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let statusContainerStackView = UIStackView()
|
let revealContentWarningButton: UIButton = {
|
||||||
let statusTextContainerView = UIView()
|
let button = HighlightDimmableButton()
|
||||||
let statusContentWarningContainerStackView = UIStackView()
|
button.setImage(UIImage(systemName: "eye", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .medium)), for: .normal)
|
||||||
var statusContentWarningContainerStackViewBottomLayoutConstraint: NSLayoutConstraint!
|
button.tintColor = Asset.Colors.Button.normal.color
|
||||||
|
|
||||||
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)
|
|
||||||
return button
|
return button
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
let statusContainerStackView = UIStackView()
|
||||||
let statusMosaicImageViewContainer = MosaicImageViewContainer()
|
let statusMosaicImageViewContainer = MosaicImageViewContainer()
|
||||||
|
|
||||||
let pollTableView: PollTableView = {
|
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
|
// do not use visual effect view due to we blur text only without background
|
||||||
let contentWarningBlurContentImageView: UIImageView = {
|
let contentWarningOverlayView: ContentWarningOverlayView = {
|
||||||
let imageView = UIImageView()
|
let contentWarningOverlayView = ContentWarningOverlayView()
|
||||||
imageView.backgroundColor = Asset.Colors.Background.systemBackground.color
|
contentWarningOverlayView.layer.masksToBounds = false
|
||||||
imageView.layer.masksToBounds = false
|
contentWarningOverlayView.configure(style: .blurContentImageView)
|
||||||
return imageView
|
return contentWarningOverlayView
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let playerContainerView = PlayerContainerView()
|
let playerContainerView = PlayerContainerView()
|
||||||
|
@ -231,9 +224,9 @@ extension StatusView {
|
||||||
|
|
||||||
func _init() {
|
func _init() {
|
||||||
// container: [reblog | author | status | action toolbar]
|
// 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.axis = .vertical
|
||||||
containerStackView.spacing = 10
|
// containerStackView.spacing = 10
|
||||||
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
addSubview(containerStackView)
|
addSubview(containerStackView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
@ -242,19 +235,30 @@ extension StatusView {
|
||||||
trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor),
|
trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor),
|
||||||
bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor),
|
bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor),
|
||||||
])
|
])
|
||||||
|
containerStackView.setContentHuggingPriority(.required - 1, for: .vertical)
|
||||||
|
|
||||||
// header container: [icon | info]
|
// header container: [icon | info]
|
||||||
containerStackView.addArrangedSubview(headerContainerStackView)
|
let headerContainerStackView = UIStackView()
|
||||||
headerContainerStackView.spacing = 4
|
headerContainerStackView.axis = .horizontal
|
||||||
headerContainerStackView.addArrangedSubview(headerIconLabel)
|
headerContainerStackView.addArrangedSubview(headerIconLabel)
|
||||||
headerContainerStackView.addArrangedSubview(headerInfoLabel)
|
headerContainerStackView.addArrangedSubview(headerInfoLabel)
|
||||||
headerIconLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
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()
|
let authorContainerStackView = UIStackView()
|
||||||
containerStackView.addArrangedSubview(authorContainerStackView)
|
|
||||||
authorContainerStackView.axis = .horizontal
|
authorContainerStackView.axis = .horizontal
|
||||||
authorContainerStackView.spacing = StatusView.avatarToLabelSpacing
|
authorContainerStackView.spacing = StatusView.avatarToLabelSpacing
|
||||||
|
authorContainerStackView.distribution = .fill
|
||||||
|
|
||||||
// avatar
|
// avatar
|
||||||
avatarView.translatesAutoresizingMaskIntoConstraints = false
|
avatarView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
@ -310,45 +314,54 @@ extension StatusView {
|
||||||
authorMetaContainerStackView.addArrangedSubview(subtitleContainerStackView)
|
authorMetaContainerStackView.addArrangedSubview(subtitleContainerStackView)
|
||||||
subtitleContainerStackView.axis = .horizontal
|
subtitleContainerStackView.axis = .horizontal
|
||||||
subtitleContainerStackView.addArrangedSubview(usernameLabel)
|
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)
|
containerStackView.addArrangedSubview(statusContainerStackView)
|
||||||
statusContainerStackView.axis = .vertical
|
statusContainerStackView.axis = .vertical
|
||||||
statusContainerStackView.spacing = 10
|
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)
|
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
|
pollTableView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
statusContainerStackView.addArrangedSubview(pollTableView)
|
statusContainerStackView.addArrangedSubview(pollTableView)
|
||||||
pollTableViewHeightLaoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1)
|
pollTableViewHeightLaoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1)
|
||||||
|
@ -376,22 +389,11 @@ extension StatusView {
|
||||||
pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||||
pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, 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
|
// action toolbar container
|
||||||
containerStackView.addArrangedSubview(actionToolbarContainer)
|
containerStackView.addArrangedSubview(actionToolbarContainer)
|
||||||
actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||||
|
|
||||||
headerContainerStackView.isHidden = true
|
headerContainerView.isHidden = true
|
||||||
statusMosaicImageViewContainer.isHidden = true
|
statusMosaicImageViewContainer.isHidden = true
|
||||||
pollTableView.isHidden = true
|
pollTableView.isHidden = true
|
||||||
pollStatusStackView.isHidden = true
|
pollStatusStackView.isHidden = true
|
||||||
|
@ -399,12 +401,11 @@ extension StatusView {
|
||||||
playerContainerView.isHidden = true
|
playerContainerView.isHidden = true
|
||||||
|
|
||||||
avatarStackedContainerButton.isHidden = true
|
avatarStackedContainerButton.isHidden = true
|
||||||
contentWarningBlurContentImageView.isHidden = true
|
contentWarningOverlayView.isHidden = true
|
||||||
statusContentWarningContainerStackView.isHidden = true
|
|
||||||
statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false
|
|
||||||
|
|
||||||
activeTextLabel.delegate = self
|
activeTextLabel.delegate = self
|
||||||
playerContainerView.delegate = self
|
playerContainerView.delegate = self
|
||||||
|
contentWarningOverlayView.delegate = self
|
||||||
|
|
||||||
headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:)))
|
headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:)))
|
||||||
headerInfoLabel.isUserInteractionEnabled = true
|
headerInfoLabel.isUserInteractionEnabled = true
|
||||||
|
@ -412,7 +413,7 @@ extension StatusView {
|
||||||
|
|
||||||
avatarButton.addTarget(self, action: #selector(StatusView.avatarButtonDidPressed(_:)), for: .touchUpInside)
|
avatarButton.addTarget(self, action: #selector(StatusView.avatarButtonDidPressed(_:)), for: .touchUpInside)
|
||||||
avatarStackedContainerButton.addTarget(self, action: #selector(StatusView.avatarStackedContainerButtonDidPressed(_:)), 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)
|
pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -420,30 +421,61 @@ extension StatusView {
|
||||||
|
|
||||||
extension StatusView {
|
extension StatusView {
|
||||||
|
|
||||||
func cleanUpContentWarning() {
|
private func cleanUpContentWarning() {
|
||||||
contentWarningBlurContentImageView.image = nil
|
contentWarningOverlayView.blurContentImageView.image = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func drawContentWarningImageView() {
|
func drawContentWarningImageView() {
|
||||||
guard activeTextLabel.frame != .zero,
|
guard window != nil else {
|
||||||
isStatusTextSensitive,
|
|
||||||
let text = activeTextLabel.text, !text.isEmpty else {
|
|
||||||
cleanUpContentWarning()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let image = UIGraphicsImageRenderer(size: activeTextLabel.frame.size).image { context in
|
guard needsDrawContentOverlay, statusContainerStackView.frame != .zero else {
|
||||||
activeTextLabel.draw(activeTextLabel.bounds)
|
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)
|
.blur(radius: StatusView.contentWarningBlurRadius)
|
||||||
contentWarningBlurContentImageView.contentScaleFactor = traitCollection.displayScale
|
contentWarningOverlayView.blurContentImageView.contentScaleFactor = traitCollection.displayScale
|
||||||
contentWarningBlurContentImageView.image = image
|
contentWarningOverlayView.blurContentImageView.image = image
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateContentWarningDisplay(isHidden: Bool) {
|
func updateContentWarningDisplay(isHidden: Bool, animated: Bool) {
|
||||||
contentWarningBlurContentImageView.isHidden = isHidden
|
needsDrawContentOverlay = !isHidden
|
||||||
statusContentWarningContainerStackView.isHidden = isHidden
|
|
||||||
statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = !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)
|
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)
|
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) {
|
@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
|
// MARK: - PlayerContainerViewDelegate
|
||||||
extension StatusView: PlayerContainerViewDelegate {
|
extension StatusView: PlayerContainerViewDelegate {
|
||||||
func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
||||||
|
@ -525,7 +566,7 @@ struct StatusView_Previews: PreviewProvider {
|
||||||
.previewDisplayName("Normal")
|
.previewDisplayName("Normal")
|
||||||
UIViewPreview(width: 375) {
|
UIViewPreview(width: 375) {
|
||||||
let statusView = StatusView()
|
let statusView = StatusView()
|
||||||
statusView.headerContainerStackView.isHidden = false
|
statusView.headerContainerView.isHidden = false
|
||||||
statusView.avatarButton.isHidden = true
|
statusView.avatarButton.isHidden = true
|
||||||
statusView.avatarStackedContainerButton.isHidden = false
|
statusView.avatarStackedContainerButton.isHidden = false
|
||||||
statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(
|
statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(
|
||||||
|
@ -552,15 +593,15 @@ struct StatusView_Previews: PreviewProvider {
|
||||||
placeholderImage: avatarFlora
|
placeholderImage: avatarFlora
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
statusView.headerContainerStackView.isHidden = false
|
statusView.headerContainerView.isHidden = false
|
||||||
let images = MosaicImageView_Previews.images
|
let images = MosaicImageView_Previews.images
|
||||||
let imageViews = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162)
|
let mosaics = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162)
|
||||||
for (i, imageView) in imageViews.enumerated() {
|
for (i, mosaic) in mosaics.enumerated() {
|
||||||
|
let (imageView, _) = mosaic
|
||||||
imageView.image = images[i]
|
imageView.image = images[i]
|
||||||
}
|
}
|
||||||
statusView.statusMosaicImageViewContainer.isHidden = false
|
statusView.statusMosaicImageViewContainer.isHidden = false
|
||||||
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true
|
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true
|
||||||
statusView.isStatusTextSensitive = false
|
|
||||||
return statusView
|
return statusView
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 375, height: 380))
|
.previewLayout(.fixed(width: 375, height: 380))
|
||||||
|
@ -573,15 +614,15 @@ struct StatusView_Previews: PreviewProvider {
|
||||||
placeholderImage: avatarFlora
|
placeholderImage: avatarFlora
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
statusView.headerContainerStackView.isHidden = false
|
statusView.headerContainerView.isHidden = false
|
||||||
statusView.isStatusTextSensitive = true
|
|
||||||
statusView.setNeedsLayout()
|
statusView.setNeedsLayout()
|
||||||
statusView.layoutIfNeeded()
|
statusView.layoutIfNeeded()
|
||||||
|
statusView.updateContentWarningDisplay(isHidden: false, animated: false)
|
||||||
statusView.drawContentWarningImageView()
|
statusView.drawContentWarningImageView()
|
||||||
statusView.updateContentWarningDisplay(isHidden: false)
|
|
||||||
let images = MosaicImageView_Previews.images
|
let images = MosaicImageView_Previews.images
|
||||||
let imageViews = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162)
|
let mosaics = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162)
|
||||||
for (i, imageView) in imageViews.enumerated() {
|
for (i, mosaic) in mosaics.enumerated() {
|
||||||
|
let (imageView, _) = mosaic
|
||||||
imageView.image = images[i]
|
imageView.image = images[i]
|
||||||
}
|
}
|
||||||
statusView.statusMosaicImageViewContainer.isHidden = false
|
statusView.statusMosaicImageViewContainer.isHidden = false
|
||||||
|
|
|
@ -22,7 +22,8 @@ protocol StatusTableViewCellDelegate: class {
|
||||||
|
|
||||||
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel)
|
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel)
|
||||||
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton)
|
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, pollVoteButtonPressed button: UIButton)
|
||||||
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
|
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
|
||||||
|
|
||||||
|
@ -55,6 +56,7 @@ final class StatusTableViewCell: UITableViewCell, StatusCell {
|
||||||
var disposeBag = Set<AnyCancellable>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
var pollCountdownSubscription: AnyCancellable?
|
var pollCountdownSubscription: AnyCancellable?
|
||||||
var observations = Set<NSKeyValueObservation>()
|
var observations = Set<NSKeyValueObservation>()
|
||||||
|
private var selectionBackgroundViewObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
let statusView = StatusView()
|
let statusView = StatusView()
|
||||||
let threadMetaStackView = UIStackView()
|
let threadMetaStackView = UIStackView()
|
||||||
|
@ -70,10 +72,11 @@ final class StatusTableViewCell: UITableViewCell, StatusCell {
|
||||||
override func prepareForReuse() {
|
override func prepareForReuse() {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
selectionStyle = .default
|
selectionStyle = .default
|
||||||
statusView.isStatusTextSensitive = false
|
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
|
||||||
statusView.cleanUpContentWarning()
|
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true
|
||||||
statusView.pollTableView.dataSource = nil
|
statusView.pollTableView.dataSource = nil
|
||||||
statusView.playerContainerView.reset()
|
statusView.playerContainerView.reset()
|
||||||
|
statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = true
|
||||||
statusView.playerContainerView.isHidden = true
|
statusView.playerContainerView.isHidden = true
|
||||||
threadMetaView.isHidden = true
|
threadMetaView.isHidden = true
|
||||||
disposeBag.removeAll()
|
disposeBag.removeAll()
|
||||||
|
@ -92,8 +95,11 @@ final class StatusTableViewCell: UITableViewCell, StatusCell {
|
||||||
|
|
||||||
override func layoutSubviews() {
|
override func layoutSubviews() {
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
// precondition: app is active
|
||||||
|
guard UIApplication.shared.applicationState == .active else { return }
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.statusView.drawContentWarningImageView()
|
self.statusView.drawContentWarningImageView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,7 +109,6 @@ extension StatusTableViewCell {
|
||||||
|
|
||||||
private func _init() {
|
private func _init() {
|
||||||
backgroundColor = Asset.Colors.Background.systemBackground.color
|
backgroundColor = Asset.Colors.Background.systemBackground.color
|
||||||
statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.systemBackground.color
|
|
||||||
|
|
||||||
statusView.translatesAutoresizingMaskIntoConstraints = false
|
statusView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
contentView.addSubview(statusView)
|
contentView.addSubview(statusView)
|
||||||
|
@ -150,9 +155,22 @@ extension StatusTableViewCell {
|
||||||
resetSeparatorLineLayout()
|
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 {
|
extension StatusTableViewCell {
|
||||||
|
|
||||||
private func resetSeparatorLineLayout() {
|
private func resetSeparatorLineLayout() {
|
||||||
separatorLineToEdgeLeadingLayoutConstraint.isActive = false
|
separatorLineToEdgeLeadingLayoutConstraint.isActive = false
|
||||||
separatorLineToEdgeTrailingLayoutConstraint.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
|
// MARK: - UITableViewDelegate
|
||||||
|
@ -270,8 +293,12 @@ extension StatusTableViewCell: StatusViewDelegate {
|
||||||
delegate?.statusTableViewCell(self, statusView: statusView, avatarButtonDidPressed: button)
|
delegate?.statusTableViewCell(self, statusView: statusView, avatarButtonDidPressed: button)
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) {
|
func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) {
|
||||||
delegate?.statusTableViewCell(self, statusView: statusView, contentWarningActionButtonPressed: button)
|
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) {
|
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) {
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
|
|
||||||
struct MosaicImageViewModel {
|
struct MosaicImageViewModel {
|
||||||
|
@ -24,7 +25,12 @@ struct MosaicImageViewModel {
|
||||||
let url = URL(string: urlString) else {
|
let url = URL(string: urlString) else {
|
||||||
continue
|
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
|
self.metas = metas
|
||||||
}
|
}
|
||||||
|
@ -32,6 +38,39 @@ struct MosaicImageViewModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MosaicMeta {
|
struct MosaicMeta {
|
||||||
|
static let edgeMaxLength: CGFloat = 20
|
||||||
|
|
||||||
let url: URL
|
let url: URL
|
||||||
let size: CGSize
|
let size: CGSize
|
||||||
|
let blurhash: String?
|
||||||
|
|
||||||
|
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.MosaicMeta.working-queue", qos: .userInitiated, attributes: .concurrent)
|
||||||
|
|
||||||
|
func blurhashImagePublisher() -> AnyPublisher<UIImage?, Never> {
|
||||||
|
return Future { promise in
|
||||||
|
guard let blurhash = blurhash else {
|
||||||
|
promise(.success(nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageSize: CGSize = {
|
||||||
|
let aspectRadio = size.width / size.height
|
||||||
|
if size.width > size.height {
|
||||||
|
let width: CGFloat = MosaicMeta.edgeMaxLength
|
||||||
|
let height = width / aspectRadio
|
||||||
|
return CGSize(width: width, height: height)
|
||||||
|
} else {
|
||||||
|
let height: CGFloat = MosaicMeta.edgeMaxLength
|
||||||
|
let width = height * aspectRadio
|
||||||
|
return CGSize(width: width, height: height)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
workingQueue.async {
|
||||||
|
let image = UIImage(blurHash: blurhash, size: imageSize)
|
||||||
|
promise(.success(image))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,5 +7,10 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
class DocumentStore: ObservableObject { }
|
class DocumentStore: ObservableObject {
|
||||||
|
let blurhashImageCache = NSCache<NSString, NSData>()
|
||||||
|
let appStartUpTimestamp = Date()
|
||||||
|
var defaultRevealStatusDict: [Mastodon.Entity.Status.ID: Bool] = [:]
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,146 @@
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIImage {
|
||||||
|
public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) {
|
||||||
|
guard blurHash.count >= 6 else { return nil }
|
||||||
|
|
||||||
|
let sizeFlag = String(blurHash[0]).decode83()
|
||||||
|
let numY = (sizeFlag / 9) + 1
|
||||||
|
let numX = (sizeFlag % 9) + 1
|
||||||
|
|
||||||
|
let quantisedMaximumValue = String(blurHash[1]).decode83()
|
||||||
|
let maximumValue = Float(quantisedMaximumValue + 1) / 166
|
||||||
|
|
||||||
|
guard blurHash.count == 4 + 2 * numX * numY else { return nil }
|
||||||
|
|
||||||
|
let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in
|
||||||
|
if i == 0 {
|
||||||
|
let value = String(blurHash[2 ..< 6]).decode83()
|
||||||
|
return decodeDC(value)
|
||||||
|
} else {
|
||||||
|
let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83()
|
||||||
|
return decodeAC(value, maximumValue: maximumValue * punch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = Int(size.width)
|
||||||
|
let height = Int(size.height)
|
||||||
|
let bytesPerRow = width * 3
|
||||||
|
guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil }
|
||||||
|
CFDataSetLength(data, bytesPerRow * height)
|
||||||
|
guard let pixels = CFDataGetMutableBytePtr(data) else { return nil }
|
||||||
|
|
||||||
|
for y in 0 ..< height {
|
||||||
|
for x in 0 ..< width {
|
||||||
|
var r: Float = 0
|
||||||
|
var g: Float = 0
|
||||||
|
var b: Float = 0
|
||||||
|
|
||||||
|
for j in 0 ..< numY {
|
||||||
|
for i in 0 ..< numX {
|
||||||
|
let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height))
|
||||||
|
let colour = colours[i + j * numX]
|
||||||
|
r += colour.0 * basis
|
||||||
|
g += colour.1 * basis
|
||||||
|
b += colour.2 * basis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let intR = UInt8(linearTosRGB(r))
|
||||||
|
let intG = UInt8(linearTosRGB(g))
|
||||||
|
let intB = UInt8(linearTosRGB(b))
|
||||||
|
|
||||||
|
pixels[3 * x + 0 + y * bytesPerRow] = intR
|
||||||
|
pixels[3 * x + 1 + y * bytesPerRow] = intG
|
||||||
|
pixels[3 * x + 2 + y * bytesPerRow] = intB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
|
||||||
|
|
||||||
|
guard let provider = CGDataProvider(data: data) else { return nil }
|
||||||
|
guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow,
|
||||||
|
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { return nil }
|
||||||
|
|
||||||
|
self.init(cgImage: cgImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeDC(_ value: Int) -> (Float, Float, Float) {
|
||||||
|
let intR = value >> 16
|
||||||
|
let intG = (value >> 8) & 255
|
||||||
|
let intB = value & 255
|
||||||
|
return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
|
||||||
|
let quantR = value / (19 * 19)
|
||||||
|
let quantG = (value / 19) % 19
|
||||||
|
let quantB = value % 19
|
||||||
|
|
||||||
|
let rgb = (
|
||||||
|
signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
|
||||||
|
signPow((Float(quantG) - 9) / 9, 2) * maximumValue,
|
||||||
|
signPow((Float(quantB) - 9) / 9, 2) * maximumValue
|
||||||
|
)
|
||||||
|
|
||||||
|
return rgb
|
||||||
|
}
|
||||||
|
|
||||||
|
private func signPow(_ value: Float, _ exp: Float) -> Float {
|
||||||
|
return copysign(pow(abs(value), exp), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func linearTosRGB(_ value: Float) -> Int {
|
||||||
|
let v = max(0, min(1, value))
|
||||||
|
if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) }
|
||||||
|
else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
|
||||||
|
let v = Float(Int64(value)) / 255
|
||||||
|
if v <= 0.04045 { return v / 12.92 }
|
||||||
|
else { return pow((v + 0.055) / 1.055, 2.4) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private let encodeCharacters: [String] = {
|
||||||
|
return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let decodeCharacters: [String: Int] = {
|
||||||
|
var dict: [String: Int] = [:]
|
||||||
|
for (index, character) in encodeCharacters.enumerated() {
|
||||||
|
dict[character] = index
|
||||||
|
}
|
||||||
|
return dict
|
||||||
|
}()
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
func decode83() -> Int {
|
||||||
|
var value: Int = 0
|
||||||
|
for character in self {
|
||||||
|
if let digit = decodeCharacters[String(character)] {
|
||||||
|
value = value * 83 + digit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
subscript (offset: Int) -> Character {
|
||||||
|
return self[index(startIndex, offsetBy: offset)]
|
||||||
|
}
|
||||||
|
|
||||||
|
subscript (bounds: CountableClosedRange<Int>) -> Substring {
|
||||||
|
let start = index(startIndex, offsetBy: bounds.lowerBound)
|
||||||
|
let end = index(startIndex, offsetBy: bounds.upperBound)
|
||||||
|
return self[start...end]
|
||||||
|
}
|
||||||
|
|
||||||
|
subscript (bounds: CountableRange<Int>) -> Substring {
|
||||||
|
let start = index(startIndex, offsetBy: bounds.lowerBound)
|
||||||
|
let end = index(startIndex, offsetBy: bounds.upperBound)
|
||||||
|
return self[start..<end]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,145 @@
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIImage {
|
||||||
|
public func blurHash(numberOfComponents components: (Int, Int)) -> String? {
|
||||||
|
let pixelWidth = Int(round(size.width * scale))
|
||||||
|
let pixelHeight = Int(round(size.height * scale))
|
||||||
|
|
||||||
|
let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: pixelWidth,
|
||||||
|
height: pixelHeight,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: pixelWidth * 4,
|
||||||
|
space: CGColorSpace(name: CGColorSpace.sRGB)!,
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
)!
|
||||||
|
context.scaleBy(x: scale, y: -scale)
|
||||||
|
context.translateBy(x: 0, y: -size.height)
|
||||||
|
|
||||||
|
UIGraphicsPushContext(context)
|
||||||
|
draw(at: .zero)
|
||||||
|
UIGraphicsPopContext()
|
||||||
|
|
||||||
|
guard let cgImage = context.makeImage(),
|
||||||
|
let dataProvider = cgImage.dataProvider,
|
||||||
|
let data = dataProvider.data,
|
||||||
|
let pixels = CFDataGetBytePtr(data) else {
|
||||||
|
assertionFailure("Unexpected error!")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = cgImage.width
|
||||||
|
let height = cgImage.height
|
||||||
|
let bytesPerRow = cgImage.bytesPerRow
|
||||||
|
|
||||||
|
var factors: [(Float, Float, Float)] = []
|
||||||
|
for y in 0 ..< components.1 {
|
||||||
|
for x in 0 ..< components.0 {
|
||||||
|
let normalisation: Float = (x == 0 && y == 0) ? 1 : 2
|
||||||
|
let factor = multiplyBasisFunction(pixels: pixels, width: width, height: height, bytesPerRow: bytesPerRow, bytesPerPixel: cgImage.bitsPerPixel / 8, pixelOffset: 0) {
|
||||||
|
normalisation * cos(Float.pi * Float(x) * $0 / Float(width)) as Float * cos(Float.pi * Float(y) * $1 / Float(height)) as Float
|
||||||
|
}
|
||||||
|
factors.append(factor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dc = factors.first!
|
||||||
|
let ac = factors.dropFirst()
|
||||||
|
|
||||||
|
var hash = ""
|
||||||
|
|
||||||
|
let sizeFlag = (components.0 - 1) + (components.1 - 1) * 9
|
||||||
|
hash += sizeFlag.encode83(length: 1)
|
||||||
|
|
||||||
|
let maximumValue: Float
|
||||||
|
if ac.count > 0 {
|
||||||
|
let actualMaximumValue = ac.map({ max(abs($0.0), abs($0.1), abs($0.2)) }).max()!
|
||||||
|
let quantisedMaximumValue = Int(max(0, min(82, floor(actualMaximumValue * 166 - 0.5))))
|
||||||
|
maximumValue = Float(quantisedMaximumValue + 1) / 166
|
||||||
|
hash += quantisedMaximumValue.encode83(length: 1)
|
||||||
|
} else {
|
||||||
|
maximumValue = 1
|
||||||
|
hash += 0.encode83(length: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash += encodeDC(dc).encode83(length: 4)
|
||||||
|
|
||||||
|
for factor in ac {
|
||||||
|
hash += encodeAC(factor, maximumValue: maximumValue).encode83(length: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
private func multiplyBasisFunction(pixels: UnsafePointer<UInt8>, width: Int, height: Int, bytesPerRow: Int, bytesPerPixel: Int, pixelOffset: Int, basisFunction: (Float, Float) -> Float) -> (Float, Float, Float) {
|
||||||
|
var r: Float = 0
|
||||||
|
var g: Float = 0
|
||||||
|
var b: Float = 0
|
||||||
|
|
||||||
|
let buffer = UnsafeBufferPointer(start: pixels, count: height * bytesPerRow)
|
||||||
|
|
||||||
|
for x in 0 ..< width {
|
||||||
|
for y in 0 ..< height {
|
||||||
|
let basis = basisFunction(Float(x), Float(y))
|
||||||
|
r += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 0 + y * bytesPerRow])
|
||||||
|
g += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 1 + y * bytesPerRow])
|
||||||
|
b += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 2 + y * bytesPerRow])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let scale = 1 / Float(width * height)
|
||||||
|
|
||||||
|
return (r * scale, g * scale, b * scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeDC(_ value: (Float, Float, Float)) -> Int {
|
||||||
|
let roundedR = linearTosRGB(value.0)
|
||||||
|
let roundedG = linearTosRGB(value.1)
|
||||||
|
let roundedB = linearTosRGB(value.2)
|
||||||
|
return (roundedR << 16) + (roundedG << 8) + roundedB
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int {
|
||||||
|
let quantR = Int(max(0, min(18, floor(signPow(value.0 / maximumValue, 0.5) * 9 + 9.5))))
|
||||||
|
let quantG = Int(max(0, min(18, floor(signPow(value.1 / maximumValue, 0.5) * 9 + 9.5))))
|
||||||
|
let quantB = Int(max(0, min(18, floor(signPow(value.2 / maximumValue, 0.5) * 9 + 9.5))))
|
||||||
|
|
||||||
|
return quantR * 19 * 19 + quantG * 19 + quantB
|
||||||
|
}
|
||||||
|
|
||||||
|
private func signPow(_ value: Float, _ exp: Float) -> Float {
|
||||||
|
return copysign(pow(abs(value), exp), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func linearTosRGB(_ value: Float) -> Int {
|
||||||
|
let v = max(0, min(1, value))
|
||||||
|
if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) }
|
||||||
|
else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
|
||||||
|
let v = Float(Int64(value)) / 255
|
||||||
|
if v <= 0.04045 { return v / 12.92 }
|
||||||
|
else { return pow((v + 0.055) / 1.055, 2.4) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private let encodeCharacters: [String] = {
|
||||||
|
return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
|
||||||
|
}()
|
||||||
|
|
||||||
|
extension BinaryInteger {
|
||||||
|
func encode83(length: Int) -> String {
|
||||||
|
var result = ""
|
||||||
|
for i in 1 ... length {
|
||||||
|
let digit = (Int(self) / pow(83, length - i)) % 83
|
||||||
|
result += encodeCharacters[Int(digit)]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pow(_ base: Int, _ exponent: Int) -> Int {
|
||||||
|
return (0 ..< exponent).reduce(1) { value, _ in value * base }
|
||||||
|
}
|
Loading…
Reference in New Issue