From 51c29fa564b400ea519f41f04094def47338d79c Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 23 Jun 2021 20:47:49 +0800 Subject: [PATCH] fix: toggle content overlay not works for reblog issue. Update content overlay UI --- Localization/app.json | 5 +- Mastodon.xcodeproj/project.pbxproj | 17 + .../xcschemes/xcschememanagement.plist | 6 +- .../xcshareddata/swiftpm/Package.resolved | 18 + Mastodon/Diffiable/Item/Item.swift | 8 +- .../Section/NotificationSection.swift | 1 - .../Diffiable/Section/ReportSection.swift | 1 - .../Diffiable/Section/StatusSection.swift | 828 +++++++++--------- Mastodon/Extension/ActiveLabel.swift | 3 - Mastodon/Extension/UIView.swift | 2 +- Mastodon/Generated/Assets.swift | 3 + Mastodon/Generated/Strings.swift | 8 +- .../Protocol/AvatarConfigurableView.swift | 126 +-- ...Provider+StatusTableViewCellDelegate.swift | 2 +- .../StatusProvider+UITableViewDelegate.swift | 1 + .../StatusProvider/StatusProviderFacade.swift | 11 +- .../ContentWarningOverlay/Contents.json | 9 + .../background.colorset/Contents.json | 38 + .../Resources/ar.lproj/Localizable.strings | 5 +- .../Resources/en.lproj/Localizable.strings | 5 +- .../NotificationStatusTableViewCell.swift | 34 +- .../Header/View/ProfileHeaderView.swift | 3 +- .../Report/ReportedStatusTableviewCell.swift | 19 +- .../Container/MosaicImageViewContainer.swift | 2 +- .../Content/ContentWarningOverlayView.swift | 50 +- .../Scene/Share/View/Content/StatusView.swift | 137 ++- .../Control/AvatarStackContainerButton.swift | 4 +- .../TableviewCell/StatusTableViewCell.swift | 37 +- 28 files changed, 671 insertions(+), 712 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/ContentWarningOverlay/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/ContentWarningOverlay/background.colorset/Contents.json 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) {