diff --git a/Localization/app.json b/Localization/app.json
index a631f61d..15506104 100644
--- a/Localization/app.json
+++ b/Localization/app.json
@@ -121,9 +121,8 @@
"user_replied_to": "Replied to %s",
"show_post": "Show Post",
"show_user_profile": "Show user profile",
- "content_warning": "content warning",
- "content_warning_text": "cw: %s",
- "media_content_warning": "Tap to reveal that may be sensitive",
+ "content_warning": "Content Warning",
+ "media_content_warning": "Tap anywhere to reveal",
"poll": {
"vote": "Vote",
"vote_count": {
diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj
index ad1dfca0..29da2ad3 100644
--- a/Mastodon.xcodeproj/project.pbxproj
+++ b/Mastodon.xcodeproj/project.pbxproj
@@ -192,6 +192,7 @@
DB040ED126538E3D00BEE9D8 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ED026538E3C00BEE9D8 /* Trie.swift */; };
DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; };
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; };
+ DB0E2D2E26833FF700865C3C /* NukeFLAnimatedImagePlugin in Frameworks */ = {isa = PBXBuildFile; productRef = DB0E2D2D26833FF700865C3C /* NukeFLAnimatedImagePlugin */; };
DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */; };
DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; };
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; };
@@ -1127,6 +1128,7 @@
DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */,
DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */,
DBAC6483267D0B21007FE9FD /* DifferenceKit in Frameworks */,
+ DB0E2D2E26833FF700865C3C /* NukeFLAnimatedImagePlugin in Frameworks */,
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */,
DBAC64A1267E6D02007FE9FD /* Fuzi in Frameworks */,
DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */,
@@ -2647,6 +2649,7 @@
DBAC649D267DFE43007FE9FD /* DiffableDataSources */,
DBAC64A0267E6D02007FE9FD /* Fuzi */,
DBF7A0FB26830C33004176A2 /* FPSIndicator */,
+ DB0E2D2D26833FF700865C3C /* NukeFLAnimatedImagePlugin */,
);
productName = Mastodon;
productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */;
@@ -2839,6 +2842,7 @@
DBAC649C267DFE43007FE9FD /* XCRemoteSwiftPackageReference "DiffableDataSources" */,
DBAC649F267E6D01007FE9FD /* XCRemoteSwiftPackageReference "Fuzi" */,
DBF7A0FA26830C33004176A2 /* XCRemoteSwiftPackageReference "FPSIndicator" */,
+ DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */,
);
productRefGroup = DB427DD325BAA00100D1B89D /* Products */;
projectDirPath = "";
@@ -4722,6 +4726,14 @@
minimumVersion = 0.1.1;
};
};
+ DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/kean/Nuke-FLAnimatedImage-Plugin.git";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 8.0.0;
+ };
+ };
DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Alamofire/AlamofireImage.git";
@@ -4847,6 +4859,11 @@
package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */;
productName = CommonOSLog;
};
+ DB0E2D2D26833FF700865C3C /* NukeFLAnimatedImagePlugin */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */;
+ productName = NukeFLAnimatedImagePlugin;
+ };
DB3D0FF225BAA61700EAA174 /* AlamofireImage */ = {
isa = XCSwiftPackageProductDependency;
package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */;
diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
index 70bbb246..5bf05338 100644
--- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -12,12 +12,12 @@
CoreDataStack.xcscheme_^#shared#^_
orderHint
- 30
+ 21
Mastodon - ASDK.xcscheme_^#shared#^_
orderHint
- 3
+ 4
Mastodon - RTL.xcscheme_^#shared#^_
@@ -37,7 +37,7 @@
NotificationService.xcscheme_^#shared#^_
orderHint
- 27
+ 22
SuppressBuildableAutocreation
diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved
index e82cf3c0..bf58fb3c 100644
--- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -64,6 +64,15 @@
"version": "1.2.0"
}
},
+ {
+ "package": "FLAnimatedImage",
+ "repositoryURL": "https://github.com/Flipboard/FLAnimatedImage",
+ "state": {
+ "branch": null,
+ "revision": "e7f9fd4681ae41bf6f3056db08af4f401d61da52",
+ "version": "1.0.16"
+ }
+ },
{
"package": "FPSIndicator",
"repositoryURL": "https://github.com/MainasuK/FPSIndicator.git",
@@ -109,6 +118,15 @@
"version": "10.3.0"
}
},
+ {
+ "package": "NukeFLAnimatedImagePlugin",
+ "repositoryURL": "https://github.com/kean/Nuke-FLAnimatedImage-Plugin.git",
+ "state": {
+ "branch": null,
+ "revision": "b59c346a7d536336db3b0f12c72c6e53ee709e16",
+ "version": "8.0.0"
+ }
+ },
{
"package": "Pageboy",
"repositoryURL": "https://github.com/uias/Pageboy",
diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift
index fe40cfd6..635d0a57 100644
--- a/Mastodon/Diffiable/Item/Item.swift
+++ b/Mastodon/Diffiable/Item/Item.swift
@@ -41,8 +41,14 @@ enum Item {
extension Item {
class StatusAttribute {
var isSeparatorLineHidden: Bool
-
+
+ /// is media loaded or not
let isImageLoaded = CurrentValueSubject(false)
+
+ /// flag for current sensitive content reveal state
+ ///
+ /// - true: displaying sensitive content
+ /// - false: displaying content warning overlay
let isRevealing = CurrentValueSubject(false)
init(isSeparatorLineHidden: Bool = false) {
diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift
index d71e5ea7..37a937f4 100644
--- a/Mastodon/Diffiable/Section/NotificationSection.swift
+++ b/Mastodon/Diffiable/Section/NotificationSection.swift
@@ -54,7 +54,6 @@ extension NotificationSection {
cell: cell,
dependency: dependency,
readableLayoutFrame: frame,
- timestampUpdatePublisher: timestampUpdatePublisher,
status: status,
requestUserID: requestUserID,
statusItemAttribute: attribute
diff --git a/Mastodon/Diffiable/Section/ReportSection.swift b/Mastodon/Diffiable/Section/ReportSection.swift
index 6faaae6c..f45f4a79 100644
--- a/Mastodon/Diffiable/Section/ReportSection.swift
+++ b/Mastodon/Diffiable/Section/ReportSection.swift
@@ -43,7 +43,6 @@ extension ReportSection {
cell: cell,
dependency: dependency,
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
- timestampUpdatePublisher: timestampUpdatePublisher,
status: status,
requestUserID: requestUserID,
statusItemAttribute: attribute
diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift
index d8e7c1f4..b1843c75 100644
--- a/Mastodon/Diffiable/Section/StatusSection.swift
+++ b/Mastodon/Diffiable/Section/StatusSection.swift
@@ -19,7 +19,6 @@ import AsyncDisplayKit
protocol StatusCell: DisposeBagCollectable {
var statusView: StatusView { get }
- var pollCountdownSubscription: AnyCancellable? { get set }
}
enum StatusSection: Equatable, Hashable {
@@ -76,24 +75,24 @@ extension StatusSection {
switch item {
case .homeTimelineIndex(objectID: let objectID, let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
+ let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex
+
+ // note: force check optional for status
+ // status maybe here when delete in thread scene
+ guard let status = timelineIndex?.status,
+ let userID = timelineIndex?.userID else {
+ return cell
+ }
// configure cell
- managedObjectContext.performAndWait {
- let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex
- // note: force check optional for status
- // status maybe here when delete in thread scene
- guard let status = timelineIndex?.status,
- let userID = timelineIndex?.userID else { return }
- StatusSection.configure(
- cell: cell,
- dependency: dependency,
- readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
- timestampUpdatePublisher: timestampUpdatePublisher,
- status: status,
- requestUserID: userID,
- statusItemAttribute: attribute
- )
- }
+ configureStatusTableViewCell(
+ cell: cell,
+ dependency: dependency,
+ readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
+ status: status,
+ requestUserID: userID,
+ statusItemAttribute: attribute
+ )
cell.delegate = statusTableViewCellDelegate
cell.isAccessibilityElement = true
return cell
@@ -111,7 +110,6 @@ extension StatusSection {
cell: cell,
dependency: dependency,
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
- timestampUpdatePublisher: timestampUpdatePublisher,
status: status,
requestUserID: requestUserID,
statusItemAttribute: attribute
@@ -187,12 +185,29 @@ extension StatusSection {
}
extension StatusSection {
+
+ static func configureStatusTableViewCell(
+ cell: StatusTableViewCell,
+ dependency: NeedsDependency,
+ readableLayoutFrame: CGRect?,
+ status: Status,
+ requestUserID: String,
+ statusItemAttribute: Item.StatusAttribute
+ ) {
+ configure(
+ cell: cell,
+ dependency: dependency,
+ readableLayoutFrame: readableLayoutFrame,
+ status: status,
+ requestUserID: requestUserID,
+ statusItemAttribute: statusItemAttribute
+ )
+ }
static func configure(
cell: StatusCell,
dependency: NeedsDependency,
readableLayoutFrame: CGRect?,
- timestampUpdatePublisher: AnyPublisher,
status: Status,
requestUserID: String,
statusItemAttribute: Item.StatusAttribute
@@ -212,273 +227,28 @@ extension StatusSection {
.store(in: &cell.disposeBag)
// set header
- StatusSection.configureHeader(cell: cell, status: status)
- ManagedObjectObserver.observe(object: status)
- .receive(on: RunLoop.main)
- .sink { _ in
- // do nothing
- } receiveValue: { [weak cell] change in
+ StatusSection.configureStatusViewHeader(cell: cell, status: status)
+ // set author: name + username + avatar
+ StatusSection.configureStatusViewAuthor(cell: cell, status: status)
+ // set timestamp
+ let createdAt = (status.reblog ?? status).createdAt
+ cell.statusView.dateLabel.text = createdAt.slowedTimeAgoSinceNow
+ AppContext.shared.timestampUpdatePublisher
+ .receive(on: RunLoop.main) // will be paused when scrolling (on purpose)
+ .sink { [weak cell] _ in
guard let cell = cell else { return }
- guard case .update(let object) = change.changeType,
- let newStatus = object as? Status else { return }
- StatusSection.configureHeader(cell: cell, status: newStatus)
+ cell.statusView.dateLabel.text = createdAt.slowedTimeAgoSinceNow
+ cell.statusView.dateLabel.accessibilityLabel = createdAt.slowedTimeAgoSinceNow
}
.store(in: &cell.disposeBag)
-
- // set name username
- let nameText: String = {
- let author = (status.reblog ?? status).author
- return author.displayName.isEmpty ? author.username : author.displayName
- }()
- MastodonStatusContent.parseResult(content: nameText, emojiDict: (status.reblog ?? status).author.emojiDict)
- .receive(on: DispatchQueue.main)
- .sink { [weak cell] parseResult in
- guard let cell = cell else { return }
- cell.statusView.nameLabel.configure(contentParseResult: parseResult)
- }
- .store(in: &cell.disposeBag)
- cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct
-
- // set avatar
- if let reblog = status.reblog {
- cell.statusView.avatarButton.isHidden = true
- cell.statusView.avatarStackedContainerButton.isHidden = false
- cell.statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: reblog.author.avatarImageURL()))
- cell.statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL()))
- } else {
- cell.statusView.avatarButton.isHidden = false
- cell.statusView.avatarStackedContainerButton.isHidden = true
- cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL()))
- }
-
- // set text
-// func configureStatusContent() {
-// let content = (status.reblog ?? status).content
-// let emojiDict = (status.reblog ?? status).emojiDict
-// if let cachedParseResult = AppContext.shared.statusContentCacheService.parseResult(content: content, emojiDict: emojiDict) {
-// cell.statusView.activeTextLabel.configure(contentParseResult: cachedParseResult)
-// } else {
-// cell.statusView.activeTextLabel.configure(
-// content: (status.reblog ?? status).content,
-// emojiDict: (status.reblog ?? status).emojiDict
-// )
-// }
-// }
-// configureStatusContent()
- cell.statusView.activeTextLabel.configure(
- content: (status.reblog ?? status).content,
- emojiDict: (status.reblog ?? status).emojiDict
+ // set content
+ StatusSection.configureStatusContent(
+ cell: cell,
+ status: status,
+ readableLayoutFrame: readableLayoutFrame,
+ statusItemAttribute: statusItemAttribute
)
- cell.statusView.activeTextLabel.accessibilityLanguage = (status.reblog ?? status).language
-
- // set visibility
- if let visibility = (status.reblog ?? status).visibility {
- cell.statusView.updateVisibility(visibility: visibility)
-
- cell.statusView.revealContentWarningButton.publisher(for: \.isHidden)
- .receive(on: DispatchQueue.main)
- .sink { [weak cell] isHidden in
- cell?.statusView.visibilityImageView.isHidden = !isHidden
- }
- .store(in: &cell.disposeBag)
- } else {
- cell.statusView.visibilityImageView.isHidden = true
- }
-
- // prepare media attachments
- let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending }
-
- // set image
- let mosaicImageViewModel = MosaicImageViewModel(mediaAttachments: mediaAttachments)
- let imageViewMaxSize: CGSize = {
- let maxWidth: CGFloat = {
- // use timelinePostView width as container width
- // that width follows readable width and keep constant width after rotate
- let containerFrame = readableLayoutFrame ?? cell.statusView.frame
- var containerWidth = containerFrame.width
- containerWidth -= 10
- containerWidth -= StatusView.avatarImageSize.width
- return containerWidth
- }()
- let scale: CGFloat = {
- switch mosaicImageViewModel.metas.count {
- case 1: return 1.3
- default: return 0.7
- }
- }()
- return CGSize(width: maxWidth, height: floor(maxWidth * scale))
- }()
- let mosaics: [MosaicImageViewContainer.ConfigurableMosaic] = {
- if mosaicImageViewModel.metas.count == 1 {
- let meta = mosaicImageViewModel.metas[0]
- let mosaic = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize)
- return [mosaic]
- } else {
- let mosaics = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosaicImageViewModel.metas.count, maxSize: imageViewMaxSize)
- return mosaics
- }
- }()
- for (i, mosaic) in mosaics.enumerated() {
- let imageView = mosaic.imageView
- let blurhashOverlayImageView = mosaic.blurhashOverlayImageView
- let meta = mosaicImageViewModel.metas[i]
-
- // set blurhash image
- meta.blurhashImagePublisher()
- .sink { image in
- blurhashOverlayImageView.image = image
- }
- .store(in: &cell.disposeBag)
-
- let isSingleMosaicLayout = mosaics.count == 1
-
- // set image
- let imageSize = CGSize(
- width: mosaic.imageViewSize.width * imageView.traitCollection.displayScale,
- height: mosaic.imageViewSize.height * imageView.traitCollection.displayScale
- )
- let request = ImageRequest(
- url: meta.url,
- processors: [
- ImageProcessors.Resize(
- size: imageSize,
- unit: .pixels,
- contentMode: isSingleMosaicLayout ? .aspectFill : .aspectFit,
- crop: isSingleMosaicLayout
- )
- ]
- )
- let options = ImageLoadingOptions(
- transition: .fadeIn(duration: 0.2)
- )
-
- Nuke.loadImage(
- with: request,
- options: options,
- into: imageView
- ) { result in
- switch result {
- case .failure:
- break
- case .success:
- statusItemAttribute.isImageLoaded.value = true
- }
- }
-
- imageView.accessibilityLabel = meta.altText
- Publishers.CombineLatest(
- statusItemAttribute.isImageLoaded,
- statusItemAttribute.isRevealing
- )
- .receive(on: DispatchQueue.main) // needs call immediately
- .sink { [weak cell] isImageLoaded, isMediaRevealing in
- guard let cell = cell else { return }
- 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 = mosaicImageViewModel.metas.isEmpty
-
- // set audio
- if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first {
- cell.statusView.audioView.isHidden = false
- AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment, audioService: dependency.context.audioPlaybackService)
- } else {
- cell.statusView.audioView.isHidden = true
- }
-
- // set GIF & video
- let playerViewMaxSize: CGSize = {
- let maxWidth: CGFloat = {
- // use statusView width as container width
- // that width follows readable width and keep constant width after rotate
- let containerFrame = readableLayoutFrame ?? cell.statusView.frame
- return containerFrame.width
- }()
- let scale: CGFloat = 1.3
- return CGSize(width: maxWidth, height: floor(maxWidth * scale))
- }()
-
- if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first,
- let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) {
- var parent: UIViewController?
- var playerViewControllerDelegate: AVPlayerViewControllerDelegate? = nil
- switch cell {
- case is StatusTableViewCell:
- let statusTableViewCell = cell as! StatusTableViewCell
- parent = statusTableViewCell.delegate?.parent()
- playerViewControllerDelegate = statusTableViewCell.delegate?.playerViewControllerDelegate
- case is NotificationStatusTableViewCell:
- let notificationTableViewCell = cell as! NotificationStatusTableViewCell
- parent = notificationTableViewCell.delegate?.parent()
- case is ReportedStatusTableViewCell:
- let reportTableViewCell = cell as! ReportedStatusTableViewCell
- parent = reportTableViewCell.dependency
- default:
- parent = nil
- assertionFailure("unknown cell")
- }
- let playerContainerView = cell.statusView.playerContainerView
- let playerViewController = playerContainerView.setupPlayer(
- aspectRatio: videoPlayerViewModel.videoSize,
- maxSize: playerViewMaxSize,
- parent: parent
- )
- playerViewController.delegate = playerViewControllerDelegate
- playerViewController.player = videoPlayerViewModel.player
- playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif
- playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind)
- switch videoPlayerViewModel.videoKind {
- case .gif:
- playerContainerView.setMediaIndicator(isHidden: false)
- case .video:
- playerContainerView.setMediaIndicator(isHidden: true)
- }
- playerContainerView.isHidden = false
-
- // set blurhash overlay
- playerContainerView.isReadyForDisplay
- .receive(on: DispatchQueue.main)
- .sink { [weak playerContainerView] isReadyForDisplay in
- guard let playerContainerView = playerContainerView else { return }
- playerContainerView.blurhashOverlayImageView.alpha = isReadyForDisplay ? 0 : 1
- }
- .store(in: &cell.disposeBag)
-
- if let blurhash = videoAttachment.blurhash,
- let url = URL(string: videoAttachment.url) {
- AppContext.shared.blurhashImageCacheService.image(
- blurhash: blurhash,
- size: playerContainerView.playerViewController.view.frame.size,
- url: url
- )
- .sink { image in
- playerContainerView.blurhashOverlayImageView.image = image
- }
- .store(in: &cell.disposeBag)
- }
-
- } else {
- cell.statusView.playerContainerView.playerViewController.player?.pause()
- cell.statusView.playerContainerView.playerViewController.player = nil
- }
-
- // set text content warning
+ // set content warning
StatusSection.configureContentWarningOverlay(
statusView: cell.statusView,
status: status,
@@ -486,36 +256,14 @@ extension StatusSection {
documentStore: dependency.context.documentStore,
animated: false
)
- // observe model change
- ManagedObjectObserver.observe(object: status)
- .receive(on: RunLoop.main)
- .sink { _ in
- // do nothing
- } receiveValue: { [weak dependency, weak cell] change in
- guard let cell = cell else { return }
- guard let dependency = dependency else { return }
- guard case .update(let object) = change.changeType,
- let status = object as? Status else { return }
- StatusSection.configureContentWarningOverlay(
- statusView: cell.statusView,
- status: status,
- attribute: statusItemAttribute,
- documentStore: dependency.context.documentStore,
- animated: true
- )
- }
- .store(in: &cell.disposeBag)
-
// set poll
- let poll = (status.reblog ?? status).poll
StatusSection.configurePoll(
cell: cell,
- poll: poll,
+ poll: (status.reblog ?? status).poll,
requestUserID: requestUserID,
- updateProgressAnimated: false,
- timestampUpdatePublisher: timestampUpdatePublisher
+ updateProgressAnimated: false
)
- if let poll = poll {
+ if let poll = (status.reblog ?? status).poll {
ManagedObjectObserver.observe(object: poll)
.sink { _ in
// do nothing
@@ -527,56 +275,66 @@ extension StatusSection {
cell: cell,
poll: newPoll,
requestUserID: requestUserID,
- updateProgressAnimated: true,
- timestampUpdatePublisher: timestampUpdatePublisher
+ updateProgressAnimated: true
)
}
.store(in: &cell.disposeBag)
}
-
- if let statusTableViewCell = cell as? StatusTableViewCell {
- // toolbar
+ // set action toolbar
+ if let cell = cell as? StatusTableViewCell {
StatusSection.configureActionToolBar(
- cell: statusTableViewCell,
+ cell: cell,
dependency: dependency,
status: status,
requestUserID: requestUserID
)
+
// separator line
- statusTableViewCell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden
+ cell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden
}
-
- // set date
- let createdAt = (status.reblog ?? status).createdAt
- cell.statusView.dateLabel.text = createdAt.slowedTimeAgoSinceNow
- timestampUpdatePublisher
- .sink { [weak cell] _ in
+
+ // listen model changed
+ ManagedObjectObserver.observe(object: status)
+ .receive(on: RunLoop.main)
+ .sink { _ in
+ // do nothing
+ } receiveValue: { [weak cell] change in
guard let cell = cell else { return }
- cell.statusView.dateLabel.text = createdAt.slowedTimeAgoSinceNow
- cell.statusView.dateLabel.accessibilityLabel = createdAt.slowedTimeAgoSinceNow
+ guard case .update(let object) = change.changeType,
+ let status = object as? Status, !status.isDeleted else {
+ return
+ }
+ // update header
+ StatusSection.configureStatusViewHeader(cell: cell, status: status)
}
.store(in: &cell.disposeBag)
-
- // observe model change
ManagedObjectObserver.observe(object: status.reblog ?? status)
.receive(on: RunLoop.main)
.sink { _ in
// do nothing
- } receiveValue: { [weak dependency, weak cell] change in
- guard let dependency = dependency else { return }
+ } receiveValue: { [weak cell] change in
+ guard let cell = cell else { return }
guard case .update(let object) = change.changeType,
- let status = object as? Status,
- !status.isDeleted else { return }
- guard let statusTableViewCell = cell as? StatusTableViewCell else { return }
- StatusSection.configureActionToolBar(
- cell: statusTableViewCell,
- dependency: dependency,
+ let status = object as? Status, !status.isDeleted else {
+ return
+ }
+ // update content warning overlay
+ StatusSection.configureContentWarningOverlay(
+ statusView: cell.statusView,
status: status,
- requestUserID: requestUserID
+ attribute: statusItemAttribute,
+ documentStore: dependency.context.documentStore,
+ animated: true
)
-
- os_log("%{public}s[%{public}ld], %{public}s: reblog count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.reblogsCount.intValue)
- os_log("%{public}s[%{public}ld], %{public}s: like count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.favouritesCount.intValue)
+ // update action toolbar
+ if let cell = cell as? StatusTableViewCell {
+ StatusSection.configureActionToolBar(
+ cell: cell,
+ dependency: dependency,
+ status: status,
+ requestUserID: requestUserID
+ )
+ }
}
.store(in: &cell.disposeBag)
}
@@ -593,7 +351,7 @@ extension StatusSection {
if spoilerText.isEmpty {
return L10n.Common.Controls.Status.contentWarning
} else {
- return L10n.Common.Controls.Status.contentWarningText(spoilerText)
+ return spoilerText
}
}()
let appStartUpTimestamp = documentStore.appStartUpTimestamp
@@ -643,12 +401,12 @@ extension StatusSection {
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)
+ statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: true, style: .media)
+ statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: true, style: .media)
} else {
statusView.updateRevealContentWarningButton(isRevealing: false)
- statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: false, style: .visualEffectView)
- statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: false, style: .visualEffectView)
+ statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: false, style: .media)
+ statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: false, style: .media)
}
}
if animated {
@@ -697,9 +455,8 @@ extension StatusSection {
cell.threadMetaView.isHidden = false
}
-
- static func configureHeader(
+ static func configureStatusViewHeader(
cell: StatusCell,
status: Status
) {
@@ -743,80 +500,255 @@ extension StatusSection {
cell.statusView.headerInfoLabel.isAccessibilityElement = false
}
}
-
- static func configureActionToolBar(
- cell: StatusTableViewCell,
- dependency: NeedsDependency,
- status: Status,
- requestUserID: String
+
+ static func configureStatusViewAuthor(
+ cell: StatusCell,
+ status: Status
) {
- let status = status.reblog ?? status
-
- // set reply
- let replyCountTitle: String = {
- let count = status.repliesCount?.intValue ?? 0
- return StatusSection.formattedNumberTitleForActionButton(count)
- }()
- cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal)
- cell.statusView.actionToolbarContainer.replyButton.accessibilityValue = status.repliesCount.flatMap {
- L10n.Common.Controls.Timeline.Accessibility.countReplies($0.intValue)
- } ?? nil
- // set reblog
- let isReblogged = status.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
- let reblogCountTitle: String = {
- let count = status.reblogsCount.intValue
- return StatusSection.formattedNumberTitleForActionButton(count)
- }()
- cell.statusView.actionToolbarContainer.reblogButton.setTitle(reblogCountTitle, for: .normal)
- cell.statusView.actionToolbarContainer.isReblogButtonHighlight = isReblogged
- cell.statusView.actionToolbarContainer.reblogButton.accessibilityLabel = isReblogged ? L10n.Common.Controls.Status.Actions.unreblog : L10n.Common.Controls.Status.Actions.reblog
- cell.statusView.actionToolbarContainer.reblogButton.accessibilityValue = {
- guard status.reblogsCount.intValue > 0 else { return nil }
- return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.reblogsCount.intValue)
- }()
- // set like
- let isLike = status.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
- let favoriteCountTitle: String = {
- let count = status.favouritesCount.intValue
- return StatusSection.formattedNumberTitleForActionButton(count)
- }()
- cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal)
- cell.statusView.actionToolbarContainer.isFavoriteButtonHighlight = isLike
- cell.statusView.actionToolbarContainer.favoriteButton.accessibilityLabel = isLike ? L10n.Common.Controls.Status.Actions.unfavorite : L10n.Common.Controls.Status.Actions.favorite
- cell.statusView.actionToolbarContainer.favoriteButton.accessibilityValue = {
- guard status.favouritesCount.intValue > 0 else { return nil }
- return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.favouritesCount.intValue)
- }()
- Publishers.CombineLatest(
- dependency.context.blockDomainService.blockedDomains.setFailureType(to: ManagedObjectObserver.Error.self),
- ManagedObjectObserver.observe(object: status.authorForUserProvider)
+ // name
+ let author = (status.reblog ?? status).author
+ let nameContent = author.displayNameWithFallback
+ cell.statusView.nameLabel.configure(content: nameContent, emojiDict: author.emojiDict)
+ // username
+ cell.statusView.usernameLabel.text = "@" + author.acct
+ // avatar
+ if let reblog = status.reblog {
+ cell.statusView.avatarImageView.isHidden = true
+ cell.statusView.avatarStackedContainerButton.isHidden = false
+ cell.statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: reblog.author.avatarImageURL()))
+ cell.statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL()))
+ } else {
+ cell.statusView.avatarImageView.isHidden = false
+ cell.statusView.avatarStackedContainerButton.isHidden = true
+ cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL()))
+ }
+ }
+
+ static func configureStatusContent(
+ cell: StatusCell,
+ status: Status,
+ readableLayoutFrame: CGRect?,
+ statusItemAttribute: Item.StatusAttribute
+ ) {
+ // set content
+ cell.statusView.activeTextLabel.configure(
+ content: (status.reblog ?? status).content,
+ emojiDict: (status.reblog ?? status).emojiDict
)
- .receive(on: RunLoop.main)
- .sink(receiveCompletion: { _ in
- // do nothing
- }, receiveValue: { [weak dependency, weak cell] _, change in
- guard let cell = cell else { return }
- guard let dependency = dependency else { return }
- switch change.changeType {
- case .delete:
- return
- case .update(_):
- break
- case .none:
- break
+ cell.statusView.activeTextLabel.accessibilityLanguage = (status.reblog ?? status).language
+
+ // set visibility
+ if let visibility = (status.reblog ?? status).visibility {
+ cell.statusView.updateVisibility(visibility: visibility)
+
+ cell.statusView.revealContentWarningButton.publisher(for: \.isHidden)
+ .receive(on: DispatchQueue.main)
+ .sink { [weak cell] isHidden in
+ cell?.statusView.visibilityImageView.isHidden = !isHidden
+ }
+ .store(in: &cell.disposeBag)
+ } else {
+ cell.statusView.visibilityImageView.isHidden = true
+ }
+
+ // prepare media attachments
+ let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending }
+
+ // set image
+ let mosaicImageViewModel = MosaicImageViewModel(mediaAttachments: mediaAttachments)
+ let imageViewMaxSize: CGSize = {
+ let maxWidth: CGFloat = {
+ // use timelinePostView width as container width
+ // that width follows readable width and keep constant width after rotate
+ let containerFrame = readableLayoutFrame ?? cell.statusView.frame
+ var containerWidth = containerFrame.width
+ containerWidth -= 10
+ containerWidth -= StatusView.avatarImageSize.width
+ return containerWidth
+ }()
+ let scale: CGFloat = {
+ switch mosaicImageViewModel.metas.count {
+ case 1: return 1.3
+ default: return 0.7
+ }
+ }()
+ return CGSize(width: maxWidth, height: floor(maxWidth * scale))
+ }()
+ let mosaics: [MosaicImageViewContainer.ConfigurableMosaic] = {
+ if mosaicImageViewModel.metas.count == 1 {
+ let meta = mosaicImageViewModel.metas[0]
+ let mosaic = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize)
+ return [mosaic]
+ } else {
+ let mosaics = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosaicImageViewModel.metas.count, maxSize: imageViewMaxSize)
+ return mosaics
}
- StatusSection.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status)
- })
- .store(in: &cell.disposeBag)
- self.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status)
+ }()
+ for (i, mosaic) in mosaics.enumerated() {
+ let imageView = mosaic.imageView
+ let blurhashOverlayImageView = mosaic.blurhashOverlayImageView
+ let meta = mosaicImageViewModel.metas[i]
+
+ // set blurhash image
+ meta.blurhashImagePublisher()
+ .sink { image in
+ blurhashOverlayImageView.image = image
+ }
+ .store(in: &cell.disposeBag)
+
+ let isSingleMosaicLayout = mosaics.count == 1
+
+ // set image
+ let imageSize = CGSize(
+ width: mosaic.imageViewSize.width * imageView.traitCollection.displayScale,
+ height: mosaic.imageViewSize.height * imageView.traitCollection.displayScale
+ )
+ let request = ImageRequest(
+ url: meta.url,
+ processors: [
+ ImageProcessors.Resize(
+ size: imageSize,
+ unit: .pixels,
+ contentMode: isSingleMosaicLayout ? .aspectFill : .aspectFit,
+ crop: isSingleMosaicLayout
+ )
+ ]
+ )
+ let options = ImageLoadingOptions(
+ transition: .fadeIn(duration: 0.2)
+ )
+
+ Nuke.loadImage(
+ with: request,
+ options: options,
+ into: imageView
+ ) { result in
+ switch result {
+ case .failure:
+ break
+ case .success:
+ statusItemAttribute.isImageLoaded.value = true
+ }
+ }
+
+ imageView.accessibilityLabel = meta.altText
+
+ // setup media content overlay trigger
+ Publishers.CombineLatest(
+ statusItemAttribute.isImageLoaded,
+ statusItemAttribute.isRevealing
+ )
+ .receive(on: DispatchQueue.main) // needs call immediately
+ .sink { [weak cell] isImageLoaded, isMediaRevealing in
+ guard let _ = cell else { return }
+ guard isImageLoaded else {
+ // always display blurhash image when before image loaded
+ blurhashOverlayImageView.alpha = 1
+ blurhashOverlayImageView.isHidden = false
+ return
+ }
+
+ // display blurhash image depends on revealing state
+ let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
+ animator.addAnimations {
+ blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1
+ }
+ animator.startAnimation()
+ }
+ .store(in: &cell.disposeBag)
+ }
+ cell.statusView.statusMosaicImageViewContainer.isHidden = mosaicImageViewModel.metas.isEmpty
+
+ // set audio
+ if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first {
+ cell.statusView.audioView.isHidden = false
+ AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment, audioService: AppContext.shared.audioPlaybackService)
+ } else {
+ cell.statusView.audioView.isHidden = true
+ }
+
+ // set GIF & video
+ let playerViewMaxSize: CGSize = {
+ let maxWidth: CGFloat = {
+ // use statusView width as container width
+ // that width follows readable width and keep constant width after rotate
+ let containerFrame = readableLayoutFrame ?? cell.statusView.frame
+ return containerFrame.width
+ }()
+ let scale: CGFloat = 1.3
+ return CGSize(width: maxWidth, height: floor(maxWidth * scale))
+ }()
+
+ if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first,
+ let videoPlayerViewModel = AppContext.shared.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) {
+ var parent: UIViewController?
+ var playerViewControllerDelegate: AVPlayerViewControllerDelegate? = nil
+ switch cell {
+ case is StatusTableViewCell:
+ let statusTableViewCell = cell as! StatusTableViewCell
+ parent = statusTableViewCell.delegate?.parent()
+ playerViewControllerDelegate = statusTableViewCell.delegate?.playerViewControllerDelegate
+ case is NotificationStatusTableViewCell:
+ let notificationTableViewCell = cell as! NotificationStatusTableViewCell
+ parent = notificationTableViewCell.delegate?.parent()
+ case is ReportedStatusTableViewCell:
+ let reportTableViewCell = cell as! ReportedStatusTableViewCell
+ parent = reportTableViewCell.dependency
+ default:
+ parent = nil
+ assertionFailure("unknown cell")
+ }
+ let playerContainerView = cell.statusView.playerContainerView
+ let playerViewController = playerContainerView.setupPlayer(
+ aspectRatio: videoPlayerViewModel.videoSize,
+ maxSize: playerViewMaxSize,
+ parent: parent
+ )
+ playerViewController.delegate = playerViewControllerDelegate
+ playerViewController.player = videoPlayerViewModel.player
+ playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif
+ playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind)
+ switch videoPlayerViewModel.videoKind {
+ case .gif:
+ playerContainerView.setMediaIndicator(isHidden: false)
+ case .video:
+ playerContainerView.setMediaIndicator(isHidden: true)
+ }
+ playerContainerView.isHidden = false
+
+ // set blurhash overlay
+ playerContainerView.isReadyForDisplay
+ .receive(on: DispatchQueue.main)
+ .sink { [weak playerContainerView] isReadyForDisplay in
+ guard let playerContainerView = playerContainerView else { return }
+ playerContainerView.blurhashOverlayImageView.alpha = isReadyForDisplay ? 0 : 1
+ }
+ .store(in: &cell.disposeBag)
+
+ if let blurhash = videoAttachment.blurhash,
+ let url = URL(string: videoAttachment.url) {
+ AppContext.shared.blurhashImageCacheService.image(
+ blurhash: blurhash,
+ size: playerContainerView.playerViewController.view.frame.size,
+ url: url
+ )
+ .sink { image in
+ playerContainerView.blurhashOverlayImageView.image = image
+ }
+ .store(in: &cell.disposeBag)
+ }
+
+ } else {
+ cell.statusView.playerContainerView.playerViewController.player?.pause()
+ cell.statusView.playerContainerView.playerViewController.player = nil
+ }
}
static func configurePoll(
cell: StatusCell,
poll: Poll?,
requestUserID: String,
- updateProgressAnimated: Bool,
- timestampUpdatePublisher: AnyPublisher
+ updateProgressAnimated: Bool
) {
guard let poll = poll,
let managedObjectContext = poll.managedObjectContext
@@ -847,17 +779,16 @@ extension StatusSection {
}
}()
if poll.expired {
- cell.pollCountdownSubscription = nil
+ cell.statusView.pollCountdownSubscription = nil
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.closed
} else if let expiresAt = poll.expiresAt {
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow)
- cell.pollCountdownSubscription = timestampUpdatePublisher
+ cell.statusView.pollCountdownSubscription = AppContext.shared.timestampUpdatePublisher
.sink { _ in
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow)
}
} else {
- // assertionFailure()
- cell.pollCountdownSubscription = nil
+ cell.statusView.pollCountdownSubscription = nil
cell.statusView.pollCountdownLabel.text = "-"
}
@@ -920,7 +851,78 @@ extension StatusSection {
snapshot.appendItems(pollItems, toSection: .main)
cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
}
-
+
+ static func configureActionToolBar(
+ cell: StatusTableViewCell,
+ dependency: NeedsDependency,
+ status: Status,
+ requestUserID: String
+ ) {
+ let status = status.reblog ?? status
+
+ // set reply
+ let replyCountTitle: String = {
+ let count = status.repliesCount?.intValue ?? 0
+ return StatusSection.formattedNumberTitleForActionButton(count)
+ }()
+ cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal)
+ cell.statusView.actionToolbarContainer.replyButton.accessibilityValue = status.repliesCount.flatMap {
+ L10n.Common.Controls.Timeline.Accessibility.countReplies($0.intValue)
+ } ?? nil
+ // set reblog
+ let isReblogged = status.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
+ let reblogCountTitle: String = {
+ let count = status.reblogsCount.intValue
+ return StatusSection.formattedNumberTitleForActionButton(count)
+ }()
+ cell.statusView.actionToolbarContainer.reblogButton.setTitle(reblogCountTitle, for: .normal)
+ cell.statusView.actionToolbarContainer.isReblogButtonHighlight = isReblogged
+ cell.statusView.actionToolbarContainer.reblogButton.accessibilityLabel = isReblogged ? L10n.Common.Controls.Status.Actions.unreblog : L10n.Common.Controls.Status.Actions.reblog
+ cell.statusView.actionToolbarContainer.reblogButton.accessibilityValue = {
+ guard status.reblogsCount.intValue > 0 else { return nil }
+ return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.reblogsCount.intValue)
+ }()
+ // set like
+ let isLike = status.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
+ let favoriteCountTitle: String = {
+ let count = status.favouritesCount.intValue
+ return StatusSection.formattedNumberTitleForActionButton(count)
+ }()
+ cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal)
+ cell.statusView.actionToolbarContainer.isFavoriteButtonHighlight = isLike
+ cell.statusView.actionToolbarContainer.favoriteButton.accessibilityLabel = isLike ? L10n.Common.Controls.Status.Actions.unfavorite : L10n.Common.Controls.Status.Actions.favorite
+ cell.statusView.actionToolbarContainer.favoriteButton.accessibilityValue = {
+ guard status.favouritesCount.intValue > 0 else { return nil }
+ return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.favouritesCount.intValue)
+ }()
+ Publishers.CombineLatest(
+ dependency.context.blockDomainService.blockedDomains.setFailureType(to: ManagedObjectObserver.Error.self),
+ ManagedObjectObserver.observe(object: status.authorForUserProvider)
+ )
+ .receive(on: RunLoop.main)
+ .sink(receiveCompletion: { _ in
+ // do nothing
+ }, receiveValue: { [weak dependency, weak cell] _, change in
+ guard let cell = cell else { return }
+ guard let dependency = dependency else { return }
+ switch change.changeType {
+ case .delete:
+ return
+ case .update(_):
+ break
+ case .none:
+ break
+ }
+ StatusSection.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status)
+ })
+ .store(in: &cell.disposeBag)
+ self.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status)
+ }
+
+}
+
+
+extension StatusSection {
static func configureEmptyStateHeader(
cell: TimelineHeaderTableViewCell,
attribute: Item.EmptyStateHeaderAttribute
diff --git a/Mastodon/Extension/ActiveLabel.swift b/Mastodon/Extension/ActiveLabel.swift
index 9e5c53ee..a9b7880f 100644
--- a/Mastodon/Extension/ActiveLabel.swift
+++ b/Mastodon/Extension/ActiveLabel.swift
@@ -29,9 +29,6 @@ extension ActiveLabel {
hashtagColor = Asset.Colors.brandBlue.color
URLColor = Asset.Colors.brandBlue.color
emojiPlaceholderColor = .systemFill
- #if DEBUG
- text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
- #endif
accessibilityContainerType = .semanticGroup
diff --git a/Mastodon/Extension/UIView.swift b/Mastodon/Extension/UIView.swift
index e62ba3cb..05940f7b 100644
--- a/Mastodon/Extension/UIView.swift
+++ b/Mastodon/Extension/UIView.swift
@@ -22,7 +22,7 @@ extension UIView {
}
-// MARK: - Convinience view appearance modification method
+// MARK: - Convenience view appearance modification method
extension UIView {
@discardableResult
func applyCornerRadius(radius: CGFloat) -> Self {
diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift
index a680db4a..4740c938 100644
--- a/Mastodon/Generated/Assets.swift
+++ b/Mastodon/Generated/Assets.swift
@@ -58,6 +58,9 @@ internal enum Asset {
internal static let disabled = ColorAsset(name: "Colors/Button/disabled")
internal static let inactive = ColorAsset(name: "Colors/Button/inactive")
}
+ internal enum ContentWarningOverlay {
+ internal static let background = ColorAsset(name: "Colors/ContentWarningOverlay/background")
+ }
internal enum Icon {
internal static let plus = ColorAsset(name: "Colors/Icon/plus")
}
diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift
index 5ef06414..78d72d0b 100644
--- a/Mastodon/Generated/Strings.swift
+++ b/Mastodon/Generated/Strings.swift
@@ -254,13 +254,9 @@ internal enum L10n {
}
}
internal enum Status {
- /// content warning
+ /// 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 anywhere to reveal
internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning")
/// Show Post
internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost")
diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift
index 74bbd039..9fe56ab4 100644
--- a/Mastodon/Protocol/AvatarConfigurableView.swift
+++ b/Mastodon/Protocol/AvatarConfigurableView.swift
@@ -7,7 +7,8 @@
import UIKit
import AlamofireImage
-import Kingfisher
+import FLAnimatedImage
+import Nuke
protocol AvatarConfigurableView {
static var configurableAvatarImageSize: CGSize { get }
@@ -31,13 +32,7 @@ extension AvatarConfigurableView {
}
return placeholderImage
}()
-
- // cancel previous task
- configurableAvatarImageView?.af.cancelImageRequest()
- configurableAvatarImageView?.kf.cancelDownloadTask()
- configurableAvatarButton?.af.cancelImageRequest(for: .normal)
- configurableAvatarButton?.kf.cancelImageDownloadTask()
-
+
// reset layer attributes
configurableAvatarImageView?.layer.masksToBounds = false
configurableAvatarImageView?.layer.cornerRadius = 0
@@ -55,85 +50,50 @@ extension AvatarConfigurableView {
avatarConfigurableView(self, didFinishConfiguration: configuration)
}
- let filter = ScaledToSizeWithRoundedCornersFilter(
- size: Self.configurableAvatarImageSize,
- radius: configuration.keepImageCorner ? 0 : Self.configurableAvatarImageCornerRadius
- )
-
- // set placeholder if no asset
- guard let avatarImageURL = configuration.avatarImageURL else {
- configurableAvatarImageView?.image = placeholderImage
- configurableAvatarImageView?.layer.masksToBounds = true
- configurableAvatarImageView?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
- configurableAvatarImageView?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular
-
- configurableAvatarButton?.setImage(placeholderImage, for: .normal)
- configurableAvatarButton?.layer.masksToBounds = true
- configurableAvatarButton?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
- configurableAvatarButton?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular
+ guard let imageDisplayingView: ImageDisplayingView = configurableAvatarImageView ?? configurableAvatarButton?.imageView else {
return
}
- if let avatarImageView = configurableAvatarImageView {
- // set avatar (GIF using Kingfisher)
- switch avatarImageURL.pathExtension {
- case "gif":
- avatarImageView.kf.setImage(
- with: avatarImageURL,
- placeholder: placeholderImage,
- options: [
- .transition(.fade(0.2))
- ]
- )
- avatarImageView.layer.masksToBounds = true
- avatarImageView.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
- avatarImageView.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular
-
- default:
- avatarImageView.af.setImage(
- withURL: avatarImageURL,
- placeholderImage: placeholderImage,
- filter: filter,
- imageTransition: .crossDissolve(0.3),
- runImageTransitionIfCached: false,
- completion: nil
- )
-
- if Self.configurableAvatarImageCornerRadius > 0, configuration.keepImageCorner {
- configurableAvatarImageView?.layer.masksToBounds = true
- configurableAvatarImageView?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
- configurableAvatarImageView?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular
- }
+ // set corner radius (due to GIF won't crop)
+ imageDisplayingView.layer.masksToBounds = true
+ imageDisplayingView.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
+ imageDisplayingView.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular
+
+ // set border
+ configureLayerBorder(view: imageDisplayingView, configuration: configuration)
+
+
+ // set image
+ let url = configuration.avatarImageURL
+ let processors: [ImageProcessing] = [
+ ImageProcessors.Resize(
+ size: Self.configurableAvatarImageSize,
+ unit: .points,
+ contentMode: .aspectFill,
+ crop: false
+ ),
+ ImageProcessors.RoundedCorners(
+ radius: Self.configurableAvatarImageCornerRadius
+ )
+ ]
+
+ let request = ImageRequest(url: url, processors: processors)
+ let options = ImageLoadingOptions(
+ placeholder: placeholderImage,
+ transition: .fadeIn(duration: 0.2)
+ )
+
+ Nuke.loadImage(
+ with: request,
+ options: options,
+ into: imageDisplayingView
+ ) { result in
+ switch result {
+ case .failure:
+ break
+ case .success:
+ break
}
-
- configureLayerBorder(view: avatarImageView, configuration: configuration)
- }
-
- if let avatarButton = configurableAvatarButton {
- switch avatarImageURL.pathExtension {
- case "gif":
- avatarButton.kf.setImage(
- with: avatarImageURL,
- for: .normal,
- placeholder: placeholderImage,
- options: [
- .transition(.fade(0.2))
- ]
- )
- avatarButton.layer.masksToBounds = true
- avatarButton.layer.cornerRadius = Self.configurableAvatarImageCornerRadius
- avatarButton.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous : .circular
- default:
- avatarButton.af.setImage(
- for: .normal,
- url: avatarImageURL,
- placeholderImage: placeholderImage,
- filter: filter,
- completion: nil
- )
- }
-
- configureLayerBorder(view: avatarButton, configuration: configuration)
}
}
diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift
index 3b96299d..60d61ecd 100644
--- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift
+++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift
@@ -20,7 +20,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .secondary, provider: self, cell: cell)
}
- func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton) {
+ func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarImageViewDidPressed imageView: UIImageView) {
StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .primary, provider: self, cell: cell)
}
diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift
index 98fa2d2c..d7ce7858 100644
--- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift
+++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift
@@ -66,6 +66,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
.store(in: &disposeBag)
status(for: cell, indexPath: indexPath)
+ .receive(on: RunLoop.main)
.sink { [weak self] status in
guard let self = self else { return }
let status = status?.reblog ?? status
diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
index 4d5ea5fc..3122de95 100644
--- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
+++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
@@ -541,8 +541,9 @@ extension StatusProviderFacade {
.compactMap { [weak dependency] status -> AnyPublisher? in
guard let dependency = dependency else { return nil }
guard let _status = status else { return nil }
- return dependency.context.managedObjectContext.performChanges {
- guard let status = dependency.context.managedObjectContext.object(with: _status.objectID) as? Status else { return }
+ let managedObjectContext = dependency.context.backgroundManagedObjectContext
+ return managedObjectContext.performChanges {
+ guard let status = 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 {
@@ -560,7 +561,11 @@ extension StatusProviderFacade {
// toggle reveal
dependency.context.documentStore.defaultRevealStatusDict[status.id] = false
status.update(isReveal: !isRevealing)
- status.reblog?.update(isReveal: !isRevealing)
+
+ if let reblog = status.reblog {
+ dependency.context.documentStore.defaultRevealStatusDict[reblog.id] = false
+ reblog.update(isReveal: !isRevealing)
+ }
// pause video playback if isRevealing before toggle
if isRevealing, let attachment = (status.reblog ?? status).mediaAttachments?.first,
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/ContentWarningOverlay/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/ContentWarningOverlay/Contents.json
new file mode 100644
index 00000000..6e965652
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/ContentWarningOverlay/Contents.json
@@ -0,0 +1,9 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "provides-namespace" : true
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/ContentWarningOverlay/background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/ContentWarningOverlay/background.colorset/Contents.json
new file mode 100644
index 00000000..87b9a135
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Colors/ContentWarningOverlay/background.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "235",
+ "green" : "229",
+ "red" : "221"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.922",
+ "green" : "0.898",
+ "red" : "0.867"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings
index 79f2a889..98d43de0 100644
--- a/Mastodon/Resources/ar.lproj/Localizable.strings
+++ b/Mastodon/Resources/ar.lproj/Localizable.strings
@@ -94,9 +94,8 @@ Please check your internet connection.";
"Common.Controls.Status.Actions.Reply" = "Reply";
"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite";
"Common.Controls.Status.Actions.Unreblog" = "Unreblog";
-"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.ContentWarning" = "Content Warning";
+"Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal";
"Common.Controls.Status.Poll.Closed" = "Closed";
"Common.Controls.Status.Poll.TimeLeft" = "%@ left";
"Common.Controls.Status.Poll.Vote" = "Vote";
diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings
index 79f2a889..98d43de0 100644
--- a/Mastodon/Resources/en.lproj/Localizable.strings
+++ b/Mastodon/Resources/en.lproj/Localizable.strings
@@ -94,9 +94,8 @@ Please check your internet connection.";
"Common.Controls.Status.Actions.Reply" = "Reply";
"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite";
"Common.Controls.Status.Actions.Unreblog" = "Unreblog";
-"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.ContentWarning" = "Content Warning";
+"Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal";
"Common.Controls.Status.Poll.Closed" = "Closed";
"Common.Controls.Status.Poll.TimeLeft" = "%@ left";
"Common.Controls.Status.Poll.Vote" = "Vote";
diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift
index d4bd7b8f..f6ca84c9 100644
--- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift
+++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift
@@ -104,16 +104,7 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell {
super.init(coder: coder)
configure()
}
-
- override func layoutSubviews() {
- super.layoutSubviews()
-
- // precondition: app is active
- guard UIApplication.shared.applicationState == .active else { return }
- DispatchQueue.main.async {
- self.statusView.drawContentWarningImageView()
- }
- }
+
}
extension NotificationStatusTableViewCell {
@@ -231,32 +222,17 @@ extension NotificationStatusTableViewCell {
statusBorder.layer.borderColor = Asset.Colors.Border.notification.color.cgColor
actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor
}
-
- override func setHighlighted(_ highlighted: Bool, animated: Bool) {
- super.setHighlighted(highlighted, animated: animated)
-
- resetContentOverlayBlurImageBackgroundColor(selected: highlighted)
- }
-
- override func setSelected(_ selected: Bool, animated: Bool) {
- super.setSelected(selected, animated: animated)
-
- resetContentOverlayBlurImageBackgroundColor(selected: selected)
- }
-
- private func resetContentOverlayBlurImageBackgroundColor(selected: Bool) {
- let imageViewBackgroundColor: UIColor? = selected ? selectedBackgroundView?.backgroundColor : backgroundColor
- statusView.contentWarningOverlayView.blurContentImageView.backgroundColor = imageViewBackgroundColor
- }
+
}
// MARK: - StatusViewDelegate
extension NotificationStatusTableViewCell: StatusViewDelegate {
+
func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) {
// do nothing
}
-
- func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) {
+
+ func statusView(_ statusView: StatusView, avatarImageViewDidPressed imageView: UIImageView) {
// do nothing
}
diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift
index e289cb49..2b938bac 100644
--- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift
+++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift
@@ -9,6 +9,7 @@ import os.log
import UIKit
import ActiveLabel
import TwitterTextEditor
+import FLAnimatedImage
protocol ProfileHeaderViewDelegate: AnyObject {
func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView)
@@ -66,7 +67,7 @@ final class ProfileHeaderView: UIView {
}()
let avatarImageView: UIImageView = {
- let imageView = UIImageView()
+ let imageView = FLAnimatedImageView()
let placeholderImage = UIImage
.placeholder(size: ProfileHeaderView.avatarImageViewSize, color: Asset.Colors.Background.systemGroupedBackground.color)
.af.imageRounded(withCornerRadius: ProfileHeaderView.avatarImageViewCornerRadius, divideRadiusByImageScale: false)
diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift
index 95198312..3a71a64b 100644
--- a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift
+++ b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift
@@ -19,7 +19,6 @@ final class ReportedStatusTableViewCell: UITableViewCell, StatusCell {
weak var dependency: ReportViewController?
var disposeBag = Set()
- var pollCountdownSubscription: AnyCancellable?
var observations = Set()
let statusView = StatusView()
@@ -62,16 +61,6 @@ final class ReportedStatusTableViewCell: UITableViewCell, StatusCell {
_init()
}
- override func layoutSubviews() {
- super.layoutSubviews()
-
- // precondition: app is active
- guard UIApplication.shared.applicationState == .active else { return }
- DispatchQueue.main.async {
- self.statusView.drawContentWarningImageView()
- }
- }
-
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
super.setHighlighted(highlighted, animated: animated)
if highlighted {
@@ -134,7 +123,6 @@ extension ReportedStatusTableViewCell {
statusView.delegate = self
statusView.statusMosaicImageViewContainer.delegate = self
statusView.actionToolbarContainer.isHidden = true
- statusView.contentWarningOverlayView.blurContentImageView.backgroundColor = backgroundColor
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
@@ -188,12 +176,13 @@ extension ReportedStatusTableViewCell: MosaicImageViewContainerDelegate {
}
extension ReportedStatusTableViewCell: StatusViewDelegate {
+
func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) {
}
-
- func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) {
+
+ func statusView(_ statusView: StatusView, avatarImageViewDidPressed imageView: UIImageView) {
}
-
+
func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) {
guard let dependency = self.dependency else { return }
StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: dependency, cell: self)
diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift
index 4a6c61d8..73f2fe3d 100644
--- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift
+++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift
@@ -42,7 +42,7 @@ final class MosaicImageViewContainer: UIView {
let contentWarningOverlayView: ContentWarningOverlayView = {
let contentWarningOverlayView = ContentWarningOverlayView()
- contentWarningOverlayView.configure(style: .visualEffectView)
+ contentWarningOverlayView.configure(style: .media)
return contentWarningOverlayView
}()
diff --git a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift
index 9d9f627d..88576e8a 100644
--- a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift
+++ b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift
@@ -29,19 +29,22 @@ class ContentWarningOverlayView: UIView {
label.isAccessibilityElement = false
return label
}()
-
- let blurContentImageView: UIImageView = {
- let imageView = UIImageView()
- imageView.layer.masksToBounds = false
- return imageView
+
+ // for status style overlay
+ let contentOverlayView: UIView = {
+ let view = UIView()
+ view.backgroundColor = Asset.Colors.ContentWarningOverlay.background.color
+ view.applyCornerRadius(radius: ContentWarningOverlayView.cornerRadius)
+ return view
}()
let blurContentWarningTitleLabel: UILabel = {
let label = UILabel()
- label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17), maximumPointSize: 23)
+ label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 23)
label.text = L10n.Common.Controls.Status.mediaContentWarning
label.textColor = Asset.Colors.Label.primary.color
label.textAlignment = .center
label.isAccessibilityElement = false
+ label.numberOfLines = 2
return label
}()
let blurContentWarningLabel: UILabel = {
@@ -50,8 +53,8 @@ class ContentWarningOverlayView: UIView {
label.text = L10n.Common.Controls.Status.mediaContentWarning
label.textColor = Asset.Colors.Label.secondary.color
label.textAlignment = .center
- label.layer.setupShadow()
label.isAccessibilityElement = false
+ label.numberOfLines = 2
return label
}()
@@ -108,13 +111,13 @@ extension ContentWarningOverlayView {
])
// blur image style
- blurContentImageView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(blurContentImageView)
+ contentOverlayView.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(contentOverlayView)
NSLayoutConstraint.activate([
- blurContentImageView.topAnchor.constraint(equalTo: topAnchor),
- blurContentImageView.leadingAnchor.constraint(equalTo: leadingAnchor),
- blurContentImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
- blurContentImageView.bottomAnchor.constraint(equalTo: bottomAnchor),
+ contentOverlayView.topAnchor.constraint(equalTo: topAnchor),
+ contentOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
+ contentOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
+ contentOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
let blurContentWarningLabelContainer = UIStackView()
@@ -123,7 +126,7 @@ extension ContentWarningOverlayView {
blurContentWarningLabelContainer.alignment = .center
blurContentWarningLabelContainer.translatesAutoresizingMaskIntoConstraints = false
- blurContentImageView.addSubview(blurContentWarningLabelContainer)
+ contentOverlayView.addSubview(blurContentWarningLabelContainer)
NSLayoutConstraint.activate([
blurContentWarningLabelContainer.topAnchor.constraint(equalTo: topAnchor),
blurContentWarningLabelContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
@@ -143,42 +146,43 @@ extension ContentWarningOverlayView {
topPaddingView.heightAnchor.constraint(equalTo: bottomPaddingView.heightAnchor, multiplier: 1.0).priority(.defaultHigh),
])
blurContentWarningTitleLabel.setContentHuggingPriority(.defaultHigh + 2, for: .vertical)
+ blurContentWarningTitleLabel.setContentCompressionResistancePriority(.defaultHigh + 1, for: .vertical)
blurContentWarningLabel.setContentHuggingPriority(.defaultHigh + 1, for: .vertical)
tapGestureRecognizer.addTarget(self, action: #selector(ContentWarningOverlayView.tapGestureRecognizerHandler(_:)))
addGestureRecognizer(tapGestureRecognizer)
- configure(style: .visualEffectView)
+ configure(style: .media)
}
}
extension ContentWarningOverlayView {
enum Style {
- case visualEffectView
- case blurContentImageView
+ case media // visualEffectView for media
+ case contentWarning // overlay for post
}
func configure(style: Style) {
switch style {
- case .visualEffectView:
+ case .media:
blurVisualEffectView.isHidden = false
vibrancyVisualEffectView.isHidden = false
- blurContentImageView.isHidden = true
- case .blurContentImageView:
+ contentOverlayView.isHidden = true
+ case .contentWarning:
blurVisualEffectView.isHidden = true
vibrancyVisualEffectView.isHidden = true
- blurContentImageView.isHidden = false
+ contentOverlayView.isHidden = false
}
}
func update(isRevealing: Bool, style: Style) {
switch style {
- case .visualEffectView:
+ case .media:
blurVisualEffectView.effect = isRevealing ? nil : ContentWarningOverlayView.blurVisualEffect
vibrancyVisualEffectView.alpha = isRevealing ? 0 : 1
isUserInteractionEnabled = !isRevealing
- case .blurContentImageView:
+ case .contentWarning:
assertionFailure("not handle here")
break
}
diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift
index 7cb2aef8..e6e4dcfc 100644
--- a/Mastodon/Scene/Share/View/Content/StatusView.swift
+++ b/Mastodon/Scene/Share/View/Content/StatusView.swift
@@ -7,13 +7,15 @@
import os.log
import UIKit
+import Combine
import AVKit
import ActiveLabel
import AlamofireImage
+import FLAnimatedImage
protocol StatusViewDelegate: AnyObject {
func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel)
- func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton)
+ func statusView(_ statusView: StatusView, avatarImageViewDidPressed imageView: UIImageView)
func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton)
func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
@@ -24,6 +26,7 @@ protocol StatusViewDelegate: AnyObject {
final class StatusView: UIView {
var statusPollTableViewHeightObservation: NSKeyValueObservation?
+ var pollCountdownSubscription: AnyCancellable?
static let avatarImageSize = CGSize(width: 42, height: 42)
static let avatarImageCornerRadius: CGFloat = 4
@@ -32,9 +35,9 @@ final class StatusView: UIView {
static let containerStackViewSpacing: CGFloat = 10
weak var delegate: StatusViewDelegate?
- private var needsDrawContentOverlay = false
+
var pollTableViewDataSource: UITableViewDiffableDataSource?
- var pollTableViewHeightLaoutConstraint: NSLayoutConstraint!
+ var pollTableViewHeightLayoutConstraint: NSLayoutConstraint!
let containerStackView = UIStackView()
let headerContainerView = UIView()
@@ -82,18 +85,11 @@ final class StatusView: UIView {
view.accessibilityLabel = L10n.Common.Controls.Status.showUserProfile
return view
}()
- let avatarButton: UIButton = {
- let button = HighlightDimmableButton(type: .custom)
- let placeholderImage = UIImage.placeholder(size: avatarImageSize, color: .systemFill)
- .af.imageRounded(withCornerRadius: StatusView.avatarImageCornerRadius, divideRadiusByImageScale: true)
- button.setImage(placeholderImage, for: .normal)
- return button
- }()
+ let avatarImageView: UIImageView = FLAnimatedImageView()
let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton()
let nameLabel: ActiveLabel = {
let label = ActiveLabel(style: .statusName)
- label.text = "Alice"
return label
}()
@@ -185,8 +181,8 @@ final class StatusView: UIView {
// do not use visual effect view due to we blur text only without background
let contentWarningOverlayView: ContentWarningOverlayView = {
let contentWarningOverlayView = ContentWarningOverlayView()
- contentWarningOverlayView.layer.masksToBounds = false
- contentWarningOverlayView.configure(style: .blurContentImageView)
+ contentWarningOverlayView.configure(style: .contentWarning)
+ contentWarningOverlayView.layer.masksToBounds = true
return contentWarningOverlayView
}()
@@ -218,15 +214,6 @@ final class StatusView: UIView {
_init()
}
- override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
- super.traitCollectionDidChange(previousTraitCollection)
-
- // update blur image when interface style changed
- if previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle {
- drawContentWarningImageView()
- }
- }
-
deinit {
statusPollTableViewHeightObservation = nil
}
@@ -249,6 +236,7 @@ extension StatusView {
bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor),
])
containerStackView.setContentHuggingPriority(.required - 1, for: .vertical)
+ containerStackView.setContentCompressionResistancePriority(.required - 1, for: .vertical)
// header container: [icon | info]
let headerContainerStackView = UIStackView()
@@ -281,13 +269,13 @@ extension StatusView {
avatarView.widthAnchor.constraint(equalToConstant: StatusView.avatarImageSize.width).priority(.required - 1),
avatarView.heightAnchor.constraint(equalToConstant: StatusView.avatarImageSize.height).priority(.required - 1),
])
- avatarButton.translatesAutoresizingMaskIntoConstraints = false
- avatarView.addSubview(avatarButton)
+ avatarImageView.translatesAutoresizingMaskIntoConstraints = false
+ avatarView.addSubview(avatarImageView)
NSLayoutConstraint.activate([
- avatarButton.topAnchor.constraint(equalTo: avatarView.topAnchor),
- avatarButton.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor),
- avatarButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor),
- avatarButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor),
+ avatarImageView.topAnchor.constraint(equalTo: avatarView.topAnchor),
+ avatarImageView.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor),
+ avatarImageView.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor),
+ avatarImageView.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor),
])
avatarStackedContainerButton.translatesAutoresizingMaskIntoConstraints = false
avatarView.addSubview(avatarStackedContainerButton)
@@ -355,18 +343,20 @@ extension StatusView {
contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addSubview(contentWarningOverlayView)
NSLayoutConstraint.activate([
- statusContainerStackView.topAnchor.constraint(equalTo: contentWarningOverlayView.topAnchor, constant: StatusView.contentWarningBlurRadius).priority(.defaultHigh),
- statusContainerStackView.leftAnchor.constraint(equalTo: contentWarningOverlayView.leftAnchor, constant: StatusView.contentWarningBlurRadius).priority(.defaultHigh),
- contentWarningOverlayView.rightAnchor.constraint(equalTo: statusContainerStackView.rightAnchor, constant: StatusView.contentWarningBlurRadius).priority(.defaultHigh),
- // only layout to top and left & right then draw image to fit size
+ statusContainerStackView.topAnchor.constraint(equalTo: contentWarningOverlayView.topAnchor).priority(.defaultHigh),
+ statusContainerStackView.leftAnchor.constraint(equalTo: contentWarningOverlayView.leftAnchor).priority(.defaultHigh),
+ contentWarningOverlayView.rightAnchor.constraint(equalTo: statusContainerStackView.rightAnchor).priority(.defaultHigh),
+ contentWarningOverlayView.bottomAnchor.constraint(equalTo: statusContainerStackView.bottomAnchor).priority(.defaultHigh),
])
- // avoid overlay clip author view
- containerStackView.bringSubviewToFront(authorContainerView)
+ // avoid overlay behind other views
+ defer {
+ containerStackView.bringSubviewToFront(authorContainerView)
+ }
// status
statusContainerStackView.addArrangedSubview(activeTextLabel)
activeTextLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
-
+
// image
statusContainerStackView.addArrangedSubview(statusMosaicImageViewContainer)
@@ -382,18 +372,18 @@ extension StatusView {
pollTableView.translatesAutoresizingMaskIntoConstraints = false
statusContainerStackView.addArrangedSubview(pollTableView)
- pollTableViewHeightLaoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1)
+ pollTableViewHeightLayoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1)
NSLayoutConstraint.activate([
- pollTableViewHeightLaoutConstraint,
+ pollTableViewHeightLayoutConstraint,
])
statusPollTableViewHeightObservation = pollTableView.observe(\.contentSize, options: .new, changeHandler: { [weak self] tableView, _ in
guard let self = self else { return }
guard self.pollTableView.contentSize.height != .zero else {
- self.pollTableViewHeightLaoutConstraint.constant = 44
+ self.pollTableViewHeightLayoutConstraint.constant = 44
return
}
- self.pollTableViewHeightLaoutConstraint.constant = self.pollTableView.contentSize.height
+ self.pollTableViewHeightLayoutConstraint.constant = self.pollTableView.contentSize.height
})
statusContainerStackView.addArrangedSubview(pollStatusStackView)
@@ -409,6 +399,7 @@ extension StatusView {
// action toolbar container
containerStackView.addArrangedSubview(actionToolbarContainer)
+ containerStackView.sendSubviewToBack(actionToolbarContainer)
actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
headerContainerView.isHidden = true
@@ -428,8 +419,12 @@ extension StatusView {
headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:)))
headerInfoLabel.isUserInteractionEnabled = true
headerInfoLabel.addGestureRecognizer(headerInfoLabelTapGestureRecognizer)
-
- avatarButton.addTarget(self, action: #selector(StatusView.avatarButtonDidPressed(_:)), for: .touchUpInside)
+
+ let avatarImageViewTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
+ avatarImageViewTapGestureRecognizer.addTarget(self, action: #selector(StatusView.avatarImageViewDidPressed(_:)))
+ avatarImageView.addGestureRecognizer(avatarImageViewTapGestureRecognizer)
+ avatarImageView.isUserInteractionEnabled = true
+
avatarStackedContainerButton.addTarget(self, action: #selector(StatusView.avatarStackedContainerButtonDidPressed(_:)), for: .touchUpInside)
revealContentWarningButton.addTarget(self, action: #selector(StatusView.revealContentWarningButtonDidPressed(_:)), for: .touchUpInside)
pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside)
@@ -438,49 +433,21 @@ extension StatusView {
}
extension StatusView {
-
- private func cleanUpContentWarning() {
- contentWarningOverlayView.blurContentImageView.image = nil
- }
-
- func drawContentWarningImageView() {
- guard window != nil else {
- return
- }
-
- guard needsDrawContentOverlay, statusContainerStackView.frame != .zero else {
- cleanUpContentWarning()
- return
+
+ func updateContentWarningDisplay(isHidden: Bool, animated: Bool) {
+ func updateOverlayView() {
+ contentWarningOverlayView.contentOverlayView.alpha = isHidden ? 0 : 1
}
- 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)
- contentWarningOverlayView.blurContentImageView.contentScaleFactor = traitCollection.displayScale
- contentWarningOverlayView.blurContentImageView.image = image
- }
-
- func updateContentWarningDisplay(isHidden: Bool, animated: Bool) {
- needsDrawContentOverlay = !isHidden
-
- if !isHidden {
- drawContentWarningImageView()
- }
-
if animated {
- UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) { [weak self] in
- guard let self = self else { return }
- self.contentWarningOverlayView.alpha = isHidden ? 0 : 1
- } completion: { _ in
- // do nothing
+ let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) {
+ updateOverlayView()
}
+ animator.startAnimation()
} else {
- contentWarningOverlayView.alpha = isHidden ? 0 : 1
+ updateOverlayView()
}
-
+
contentWarningOverlayView.blurContentWarningTitleLabel.isHidden = isHidden
contentWarningOverlayView.blurContentWarningLabel.isHidden = isHidden
}
@@ -512,14 +479,14 @@ extension StatusView {
delegate?.statusView(self, headerInfoLabelDidPressed: headerInfoLabel)
}
- @objc private func avatarButtonDidPressed(_ sender: UIButton) {
+ @objc private func avatarImageViewDidPressed(_ sender: UITapGestureRecognizer) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
- delegate?.statusView(self, avatarButtonDidPressed: sender)
+ delegate?.statusView(self, avatarImageViewDidPressed: avatarImageView)
}
@objc private func avatarStackedContainerButtonDidPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
- delegate?.statusView(self, avatarButtonDidPressed: sender)
+ delegate?.statusView(self, avatarImageViewDidPressed: avatarStackedContainerButton.topLeadingAvatarStackedImageView)
}
@objc private func revealContentWarningButtonDidPressed(_ sender: UIButton) {
@@ -562,9 +529,8 @@ extension StatusView: PlayerContainerViewDelegate {
extension StatusView: AvatarConfigurableView {
static var configurableAvatarImageSize: CGSize { return Self.avatarImageSize }
static var configurableAvatarImageCornerRadius: CGFloat { return 4 }
- var configurableAvatarImageView: UIImageView? { return nil }
- var configurableAvatarButton: UIButton? { return avatarButton }
- var configurableVerifiedBadgeImageView: UIImageView? { nil }
+ var configurableAvatarImageView: UIImageView? { avatarImageView }
+ var configurableAvatarButton: UIButton? { nil }
}
#if canImport(SwiftUI) && DEBUG
@@ -592,7 +558,7 @@ struct StatusView_Previews: PreviewProvider {
UIViewPreview(width: 375) {
let statusView = StatusView()
statusView.headerContainerView.isHidden = false
- statusView.avatarButton.isHidden = true
+ statusView.avatarImageView.isHidden = true
statusView.avatarStackedContainerButton.isHidden = false
statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(
with: AvatarConfigurableViewConfiguration(
@@ -642,7 +608,6 @@ struct StatusView_Previews: PreviewProvider {
statusView.setNeedsLayout()
statusView.layoutIfNeeded()
statusView.updateContentWarningDisplay(isHidden: false, animated: false)
- statusView.drawContentWarningImageView()
let images = MosaicImageView_Previews.images
let mosaics = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxSize: CGSize(width: 375, height: 162))
for (i, mosaic) in mosaics.enumerated() {
diff --git a/Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift b/Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift
index 1e4bd24f..965d710d 100644
--- a/Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift
+++ b/Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift
@@ -7,7 +7,9 @@
import os.log
import UIKit
-final class AvatarStackedImageView: UIImageView { }
+import FLAnimatedImage
+
+final class AvatarStackedImageView: FLAnimatedImageView { }
// MARK: - AvatarConfigurableView
extension AvatarStackedImageView: AvatarConfigurableView {
diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift
index 32de2c3e..784a4bfa 100644
--- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift
+++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift
@@ -21,7 +21,7 @@ protocol StatusTableViewCellDelegate: AnyObject {
var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { get }
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel)
- func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton)
+ func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarImageViewDidPressed imageView: UIImageView)
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)
@@ -93,16 +93,6 @@ final class StatusTableViewCell: UITableViewCell, StatusCell {
_init()
}
- override func layoutSubviews() {
- super.layoutSubviews()
-
- // precondition: app is active
- guard UIApplication.shared.applicationState == .active else { return }
- DispatchQueue.main.async {
- self.statusView.drawContentWarningImageView()
- }
- }
-
}
extension StatusTableViewCell {
@@ -154,18 +144,7 @@ extension StatusTableViewCell {
resetSeparatorLineLayout()
}
-
- override func setHighlighted(_ highlighted: Bool, animated: Bool) {
- super.setHighlighted(highlighted, animated: animated)
-
- resetContentOverlayBlurImageBackgroundColor(selected: highlighted)
- }
-
- override func setSelected(_ selected: Bool, animated: Bool) {
- super.setSelected(selected, animated: animated)
-
- resetContentOverlayBlurImageBackgroundColor(selected: selected)
- }
+
}
@@ -199,11 +178,7 @@ extension StatusTableViewCell {
}
}
}
-
- private func resetContentOverlayBlurImageBackgroundColor(selected: Bool) {
- let imageViewBackgroundColor: UIColor? = selected ? selectedBackgroundView?.backgroundColor : backgroundColor
- statusView.contentWarningOverlayView.blurContentImageView.backgroundColor = imageViewBackgroundColor
- }
+
}
// MARK: - MosaicImageViewContainerPresentable
@@ -301,9 +276,9 @@ extension StatusTableViewCell: StatusViewDelegate {
func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) {
delegate?.statusTableViewCell(self, statusView: statusView, headerInfoLabelDidPressed: label)
}
-
- func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) {
- delegate?.statusTableViewCell(self, statusView: statusView, avatarButtonDidPressed: button)
+
+ func statusView(_ statusView: StatusView, avatarImageViewDidPressed imageView: UIImageView) {
+ delegate?.statusTableViewCell(self, statusView: statusView, avatarImageViewDidPressed: imageView)
}
func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) {